Files
deklarix/internal/evidence/tsa_test.go
noroot 917b51299b feat: add RFC-3161 timestamping (internal/evidence)
HTTPTimestamper builds a Request directly from a precomputed SHA-256
digest (Request{HashAlgorithm, HashedMessage, Nonce}.Marshal(), not
CreateRequest(io.Reader) which would hash the input itself), POSTs it to
a TSA, and validates that the parsed response's hash algorithm, hashed
message and nonce actually match what was sent before accepting the
token — a response that doesn't match the request isn't a valid
timestamp for that hash, regardless of whether it parses.

Uses github.com/digitorus/timestamp for the RFC-3161/ASN.1 encoding
rather than hand-rolling it.

TSA choice (open point in CLAUDE.md): FreeTSA.org for now — free,
RFC-3161-compliant, verified end-to-end with a live smoke test, but
not eIDAS-qualified. Documented as needing an upgrade to a qualified
provider (D-Trust, Bundesdruckerei, ...) before real customer use, same
treatment as the open legal questions in rules/OPEN.md.

Tests cover request/response validation without network (hash size
guard, a fake server that parses and checks the incoming request,
non-200 and malformed-response handling) plus a real integration test
against FreeTSA gated behind DEKLARIX_TSA_INTEGRATION, mirroring the
DATABASE_URL-gated pattern in internal/store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:07:53 +02:00

135 lines
4.1 KiB
Go

package evidence_test
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/digitorus/timestamp"
"github.com/netcell-it/deklarix/internal/evidence"
)
func TestTimestampRejectsWrongHashSize(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
_, err := ts.Timestamp(context.Background(), []byte("zu kurz"))
if err == nil {
t.Fatal("expected error for a non-SHA-256-sized hash, got nil")
}
if called {
t.Fatal("expected no HTTP call for an invalid hash size")
}
}
// TestTimestampSendsValidRequest prüft serverseitig, dass Timestamp()
// einen tatsächlich gültigen, RFC-3161-konformen Request schickt (parsbar,
// korrekter Hash, korrekter Algorithmus, Nonce gesetzt) — ohne dafür eine
// echte signierte Antwort fälschen zu müssen.
func TestTimestampSendsValidRequest(t *testing.T) {
hash := evidence.HashBytes([]byte("test-content"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/timestamp-query" {
t.Errorf("Content-Type = %q, want application/timestamp-query", ct)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request body: %v", err)
}
req, err := timestamp.ParseRequest(body)
if err != nil {
t.Errorf("ParseRequest: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if !bytes.Equal(req.HashedMessage, hash) {
t.Errorf("HashedMessage = %x, want %x", req.HashedMessage, hash)
}
if req.Nonce == nil {
t.Error("expected a nonce to be set on the request")
}
// Keine echte TSA hier — nur die Request-Validierung interessiert
// dieser Test. Ein absichtlich ungültiger Response-Body lässt
// Timestamp() mit einem Parse-Fehler zurückkommen, was für diesen
// Test in Ordnung ist.
w.WriteHeader(http.StatusOK)
w.Write([]byte("not a valid timestamp response"))
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
_, err := ts.Timestamp(context.Background(), hash)
if err == nil {
t.Fatal("expected a parse error for the fake response, got nil")
}
}
func TestTimestampRejectsNon200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("tsa down"))
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
hash := evidence.HashBytes([]byte("x"))
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
t.Fatal("expected error for non-200 TSA status, got nil")
}
}
func TestTimestampRejectsMalformedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("garbage, not ASN.1 DER"))
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
hash := evidence.HashBytes([]byte("x"))
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
t.Fatal("expected error for malformed TSA response, got nil")
}
}
// TestTimestampIntegration ruft die echte Standard-TSA (FreeTSA.org) auf.
// Läuft nur, wenn DEKLARIX_TSA_INTEGRATION gesetzt ist — kein Netzwerkzugriff
// in normalen Testläufen (siehe internal/store für dasselbe Muster mit
// DATABASE_URL).
func TestTimestampIntegration(t *testing.T) {
if os.Getenv("DEKLARIX_TSA_INTEGRATION") == "" {
t.Skip("DEKLARIX_TSA_INTEGRATION nicht gesetzt, überspringe echten TSA-Aufruf")
}
hash := evidence.HashBytes([]byte(t.Name()))
ts := evidence.NewHTTPTimestamper("")
token, err := ts.Timestamp(context.Background(), hash)
if err != nil {
t.Fatalf("Timestamp: %v", err)
}
parsed, err := timestamp.Parse(token)
if err != nil {
t.Fatalf("Parse(token): %v", err)
}
if !bytes.Equal(parsed.HashedMessage, hash) {
t.Fatalf("token hash = %x, want %x", parsed.HashedMessage, hash)
}
if parsed.Time.IsZero() {
t.Fatal("expected a non-zero timestamp time")
}
}