diff --git a/CLAUDE.md b/CLAUDE.md index a8035fd..9278b3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -377,6 +377,12 @@ journalctl -u deklarix -f ## Offene Punkte - Umfang des Regelsatzes für den ersten Pilotkunden festlegen -- Welche TSA für die Zeitstempel +- **TSA für die Zeitstempel:** interimsweise FreeTSA.org (frei, RFC-3161- + konform, siehe `internal/evidence.DefaultTSAURL`) — funktioniert + technisch, ist aber **nicht eIDAS-qualifiziert**. Vor echtem + Kundeneinsatz auf einen eIDAS-qualifizierten Zeitstempeldienst (z. B. + D-Trust, Bundesdruckerei) umstellen, der die gesetzliche + Vermutungswirkung nach eIDAS Art. 41 hat — dafür ist ein Vertrag/Account + nötig, kein anonymer HTTP-Call. - Löschkonzept versus Beweisarchiv sauber ausformulieren - Markenanmeldung Deklarix, sobald erster Kunde zahlt diff --git a/go.mod b/go.mod index 9d88006..50709c9 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/netcell-it/deklarix go 1.26.6 require ( + github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 03a0d00..f275412 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= diff --git a/internal/evidence/tsa.go b/internal/evidence/tsa.go new file mode 100644 index 0000000..52609a6 --- /dev/null +++ b/internal/evidence/tsa.go @@ -0,0 +1,113 @@ +package evidence + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "fmt" + "io" + "math/big" + "net/http" + + "github.com/digitorus/timestamp" +) + +// DefaultTSAURL ist eine freie, RFC-3161-konforme Time-Stamp Authority. +// +// NICHT eIDAS-qualifiziert — reicht, um die Beweiskette technisch +// funktionsfähig zu haben, aber vor echtem Kundeneinsatz auf einen +// eIDAS-qualifizierten Zeitstempeldienst umstellen (z. B. D-Trust, +// Bundesdruckerei), der eine gesetzliche Vermutungswirkung nach +// eIDAS Art. 41 hat. Siehe CLAUDE.md, Offene Punkte. +const DefaultTSAURL = "https://freetsa.org/tsr" + +// HTTPTimestamper implementiert Timestamper gegen eine RFC-3161-TSA +// über HTTP (application/timestamp-query, siehe RFC 3161 Abschnitt 3.4). +type HTTPTimestamper struct { + url string + httpClient *http.Client +} + +// TimestamperOption konfiguriert einen HTTPTimestamper. +type TimestamperOption func(*HTTPTimestamper) + +// WithTimestamperHTTPClient überschreibt den verwendeten *http.Client +// (für Tests). +func WithTimestamperHTTPClient(hc *http.Client) TimestamperOption { + return func(t *HTTPTimestamper) { t.httpClient = hc } +} + +// NewHTTPTimestamper erstellt einen Timestamper. Ein leerer url-Wert +// verwendet DefaultTSAURL. +func NewHTTPTimestamper(url string, opts ...TimestamperOption) *HTTPTimestamper { + if url == "" { + url = DefaultTSAURL + } + t := &HTTPTimestamper{url: url, httpClient: http.DefaultClient} + for _, opt := range opts { + opt(t) + } + return t +} + +// Timestamp fragt einen RFC-3161-Zeitstempel für hash (ein SHA-256- +// Digest) an und liefert den rohen TimeStampToken zurück. Die Antwort +// wird gegen den angefragten Hash und die gesendete Nonce geprüft, +// bevor der Token akzeptiert wird — eine TSA-Antwort, die nicht zum +// eigenen Request passt, ist kein gültiger Zeitstempel für diesen Hash. +func (t *HTTPTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) { + if len(hash) != crypto.SHA256.Size() { + return nil, fmt.Errorf("evidence: timestamp erwartet einen SHA-256-Digest (%d Bytes), bekam %d", crypto.SHA256.Size(), len(hash)) + } + + nonce, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64)) + if err != nil { + return nil, fmt.Errorf("evidence: nonce generieren: %w", err) + } + + req := timestamp.Request{ + HashAlgorithm: crypto.SHA256, + HashedMessage: hash, + Nonce: nonce, + Certificates: true, + } + reqBytes, err := req.Marshal() + if err != nil { + return nil, fmt.Errorf("evidence: timestamp request marshal: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(reqBytes)) + if err != nil { + return nil, fmt.Errorf("evidence: request bauen: %w", err) + } + httpReq.Header.Set("Content-Type", "application/timestamp-query") + + resp, err := t.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("evidence: TSA-Request fehlgeschlagen: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("evidence: TSA-Response lesen: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("evidence: TSA-Status %d: %s", resp.StatusCode, string(body)) + } + + ts, err := timestamp.ParseResponse(body) + if err != nil { + return nil, fmt.Errorf("evidence: TSA-Response parse: %w", err) + } + + if ts.HashAlgorithm != crypto.SHA256 || !bytes.Equal(ts.HashedMessage, hash) { + return nil, fmt.Errorf("evidence: TSA-Antwort passt nicht zum angefragten Hash") + } + if ts.Nonce == nil || ts.Nonce.Cmp(nonce) != 0 { + return nil, fmt.Errorf("evidence: TSA-Antwort hat falsche oder fehlende Nonce") + } + + return ts.RawToken, nil +} diff --git a/internal/evidence/tsa_test.go b/internal/evidence/tsa_test.go new file mode 100644 index 0000000..2b2433c --- /dev/null +++ b/internal/evidence/tsa_test.go @@ -0,0 +1,134 @@ +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") + } +}