From d54636f57a803f837fc6a924b5a00e1134d0492a Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 13:54:29 +0200 Subject: [PATCH] feat: add evidence hashing (internal/evidence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHA-256 over raw asset bytes and over the canonical JSON form of metadata (encoding/json already sorts map keys and preserves struct field order deterministically, so no separate canonicalization library is needed for our own fixed types). A Timestamper interface stands in for the RFC-3161 timestamp step — no concrete implementation yet, since which TSA to use is an open decision (CLAUDE.md "Offene Punkte") that directly affects the archive's evidentiary weight, not a purely technical choice to make silently. Co-Authored-By: Claude Sonnet 5 --- internal/evidence/hash.go | 32 ++++++++++++++++ internal/evidence/hash_test.go | 67 ++++++++++++++++++++++++++++++++++ internal/evidence/timestamp.go | 18 +++++++++ 3 files changed, 117 insertions(+) create mode 100644 internal/evidence/hash.go create mode 100644 internal/evidence/hash_test.go create mode 100644 internal/evidence/timestamp.go diff --git a/internal/evidence/hash.go b/internal/evidence/hash.go new file mode 100644 index 0000000..8a4bba6 --- /dev/null +++ b/internal/evidence/hash.go @@ -0,0 +1,32 @@ +// Package evidence bildet die Beweiskette: Hashing von Assets und +// Metadaten, RFC-3161-Zeitstempel über diesen Hash. Das Append-only- +// Prinzip selbst wird von internal/store (Postgres-Trigger) erzwungen — +// dieses Paket liefert nur die Bausteine, die evidence_package braucht. +package evidence + +import ( + "crypto/sha256" + "encoding/json" + "fmt" +) + +// HashBytes berechnet den SHA-256-Digest über beliebige Bytes, z. B. +// ein Asset wie Screenshot oder Video. +func HashBytes(data []byte) []byte { + sum := sha256.Sum256(data) + return sum[:] +} + +// HashMetadata berechnet den SHA-256-Digest über die kanonisierte JSON- +// Repräsentation von v. "Kanonisiert" heißt hier: encoding/json sortiert +// Objektschlüssel bereits deterministisch, und Struct-Felder behalten +// ihre Deklarationsreihenfolge — für unsere eigenen, fest definierten +// Go-Typen ist das schon eine stabile, reproduzierbare Serialisierung, +// ohne dass es eine eigene Kanonisierungs-Bibliothek bräuchte. +func HashMetadata(v any) ([]byte, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("evidence: metadata marshal: %w", err) + } + return HashBytes(data), nil +} diff --git a/internal/evidence/hash_test.go b/internal/evidence/hash_test.go new file mode 100644 index 0000000..eb9cac2 --- /dev/null +++ b/internal/evidence/hash_test.go @@ -0,0 +1,67 @@ +package evidence_test + +import ( + "bytes" + "testing" + + "github.com/netcell-it/deklarix/internal/evidence" +) + +func TestHashBytesIsDeterministic(t *testing.T) { + data := []byte("ein beispiel-asset") + a := evidence.HashBytes(data) + b := evidence.HashBytes(data) + if !bytes.Equal(a, b) { + t.Fatalf("HashBytes ist nicht deterministisch: %x != %x", a, b) + } + if len(a) != 32 { + t.Fatalf("expected 32-byte SHA-256 digest, got %d bytes", len(a)) + } +} + +func TestHashBytesDiffersForDifferentInput(t *testing.T) { + a := evidence.HashBytes([]byte("foo")) + b := evidence.HashBytes([]byte("bar")) + if bytes.Equal(a, b) { + t.Fatal("expected different hashes for different input, got the same") + } +} + +func TestHashMetadataIsStableAcrossMapKeyOrder(t *testing.T) { + m1 := map[string]any{"a": 1, "b": 2, "c": 3} + m2 := map[string]any{"c": 3, "a": 1, "b": 2} + + h1, err := evidence.HashMetadata(m1) + if err != nil { + t.Fatalf("HashMetadata(m1): %v", err) + } + h2, err := evidence.HashMetadata(m2) + if err != nil { + t.Fatalf("HashMetadata(m2): %v", err) + } + if !bytes.Equal(h1, h2) { + t.Fatal("HashMetadata should be stable across map key insertion order") + } +} + +func TestHashMetadataDiffersForDifferentContent(t *testing.T) { + h1, err := evidence.HashMetadata(map[string]any{"a": 1}) + if err != nil { + t.Fatalf("HashMetadata: %v", err) + } + h2, err := evidence.HashMetadata(map[string]any{"a": 2}) + if err != nil { + t.Fatalf("HashMetadata: %v", err) + } + if bytes.Equal(h1, h2) { + t.Fatal("expected different hashes for different metadata content") + } +} + +func TestHashMetadataRejectsUnmarshalableValue(t *testing.T) { + // Kanäle können nicht als JSON serialisiert werden — muss einen + // Fehler liefern statt still einen falschen Hash zurückzugeben. + if _, err := evidence.HashMetadata(make(chan int)); err == nil { + t.Fatal("expected error for unmarshalable value, got nil") + } +} diff --git a/internal/evidence/timestamp.go b/internal/evidence/timestamp.go new file mode 100644 index 0000000..144320d --- /dev/null +++ b/internal/evidence/timestamp.go @@ -0,0 +1,18 @@ +package evidence + +import "context" + +// Timestamper fragt einen RFC-3161-Zeitstempel für einen Hash (i. d. R. +// aus HashBytes/HashMetadata) bei einer Time-Stamp Authority (TSA) an +// und liefert den rohen Zeitstempel-Token zurück, wie er unverändert in +// evidence_package.timestamp_token gespeichert wird. +// +// Es gibt hier bewusst noch keine konkrete Implementierung: welche TSA +// verwendet wird, ist in CLAUDE.md als offener Punkt vermerkt (freie +// TSA vs. eIDAS-qualifizierter Zeitstempeldienst — das betrifft direkt +// die Beweiskraft des Archivs und ist keine rein technische +// Entscheidung). Sobald das geklärt ist, implementiert ein konkreter +// Typ dieses Interface. +type Timestamper interface { + Timestamp(ctx context.Context, hash []byte) (token []byte, err error) +}