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>
This commit is contained in:
113
internal/evidence/tsa.go
Normal file
113
internal/evidence/tsa.go
Normal file
@@ -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
|
||||
}
|
||||
134
internal/evidence/tsa_test.go
Normal file
134
internal/evidence/tsa_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user