feat: add evidence hashing (internal/evidence)

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 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 13:54:29 +02:00
parent 15bf277bee
commit d54636f57a
3 changed files with 117 additions and 0 deletions

32
internal/evidence/hash.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}