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 }