- services/transparency-log/pkg/backend: Backend interface with in-memory append-log (RFC 6962 Merkle tree) + Trillian wrapper (prod stub). - Idempotenz-Pflicht: Wiederaufruf mit identischem content_hash liefert 200 + gleichen log_index + aktuellen inclusion_proof. - content_hash-Verifikation serverseitig; Mismatch → 409. - pkg/httpapi: ADR-0005-konformer Handler (201/200/400/404/409/503). - cmd/transparency-log: minimaler HTTP-Server, memory default, --backend trillian als Stub (503). - Interop-Test in services/admin-api/internal/tlog/interop_test.go fährt die echte transparency-log pkg gegen die admin-api HTTPSubmitter — Happy-Path, Idempotenz und content_hash-Mismatch abgedeckt. - go test: 10 Tests transparency-log + 2 Interop-Tests admin-api, go vet clean in beiden Modulen. Folge-Subtask: Trillian-Deployment + Keys (siehe ADR-0005 Open Points). Co-Authored-By: Paperclip <noreply@paperclip.ing>
203 lines
5.6 KiB
Go
203 lines
5.6 KiB
Go
package backend
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func sampleEnvelope(payload, sig string) Envelope {
|
|
return Envelope{
|
|
PayloadType: "application/vnd.in-toto+json",
|
|
Payload: payload,
|
|
Signatures: []EnvelopeSignature{{KeyID: "k1", Sig: sig}},
|
|
}
|
|
}
|
|
|
|
func mustHash(t *testing.T, env Envelope) string {
|
|
t.Helper()
|
|
h, err := EnvelopeContentHash(env)
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func TestMemory_SubmitAndIdempotency(t *testing.T) {
|
|
m := NewMemory().WithClock(func() time.Time {
|
|
return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
|
|
})
|
|
env := sampleEnvelope("aGVsbG8", "c2ln")
|
|
ch := mustHash(t, env)
|
|
|
|
r1, fresh, err := m.Submit(context.Background(), env, ch)
|
|
if err != nil {
|
|
t.Fatalf("first submit: %v", err)
|
|
}
|
|
if !fresh {
|
|
t.Errorf("first submit should be fresh")
|
|
}
|
|
if r1.LogIndex != 0 {
|
|
t.Errorf("first log_index: want 0, got %d", r1.LogIndex)
|
|
}
|
|
if r1.ContentHash != ch {
|
|
t.Errorf("content_hash echoed wrong: %q vs %q", r1.ContentHash, ch)
|
|
}
|
|
if r1.InclusionProof.TreeSize != 1 {
|
|
t.Errorf("tree_size: %d", r1.InclusionProof.TreeSize)
|
|
}
|
|
if len(r1.InclusionProof.Hashes) != 0 {
|
|
t.Errorf("single-leaf audit path should be empty, got %d entries", len(r1.InclusionProof.Hashes))
|
|
}
|
|
|
|
r2, fresh2, err := m.Submit(context.Background(), env, ch)
|
|
if err != nil {
|
|
t.Fatalf("second submit: %v", err)
|
|
}
|
|
if fresh2 {
|
|
t.Errorf("second submit should NOT be fresh (idempotent repeat)")
|
|
}
|
|
if r2.LogIndex != r1.LogIndex {
|
|
t.Errorf("idempotent log_index drifted: %d -> %d", r1.LogIndex, r2.LogIndex)
|
|
}
|
|
if !r2.LoggedAt.Equal(r1.LoggedAt) {
|
|
t.Errorf("logged_at should be stable across idempotent repeats")
|
|
}
|
|
if r2.InclusionProof.TreeRootHash != r1.InclusionProof.TreeRootHash {
|
|
t.Errorf("tree_root drifted without new entries: %q vs %q", r1.InclusionProof.TreeRootHash, r2.InclusionProof.TreeRootHash)
|
|
}
|
|
}
|
|
|
|
func TestMemory_ContentHashMismatch(t *testing.T) {
|
|
m := NewMemory()
|
|
env := sampleEnvelope("aGVsbG8", "c2ln")
|
|
wrong := "sha256:" + strings.Repeat("0", 64)
|
|
_, _, err := m.Submit(context.Background(), env, wrong)
|
|
if err == nil {
|
|
t.Fatalf("expected ErrContentHashMismatch, got nil")
|
|
}
|
|
if err != ErrContentHashMismatch {
|
|
t.Fatalf("wrong error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMemory_MultipleEntriesGrowRoot(t *testing.T) {
|
|
m := NewMemory()
|
|
ctx := context.Background()
|
|
var lastRoot string
|
|
for i, payload := range []string{"a", "b", "c", "d", "e"} {
|
|
env := sampleEnvelope(payload, "s")
|
|
ch := mustHash(t, env)
|
|
r, fresh, err := m.Submit(ctx, env, ch)
|
|
if err != nil {
|
|
t.Fatalf("submit %d: %v", i, err)
|
|
}
|
|
if !fresh {
|
|
t.Errorf("submit %d should be fresh", i)
|
|
}
|
|
if r.LogIndex != int64(i) {
|
|
t.Errorf("submit %d log_index: want %d got %d", i, i, r.LogIndex)
|
|
}
|
|
if r.InclusionProof.TreeSize != int64(i+1) {
|
|
t.Errorf("submit %d tree_size: want %d got %d", i, i+1, r.InclusionProof.TreeSize)
|
|
}
|
|
if r.InclusionProof.TreeRootHash == lastRoot {
|
|
t.Errorf("tree_root did not change after new entry %d", i)
|
|
}
|
|
lastRoot = r.InclusionProof.TreeRootHash
|
|
}
|
|
}
|
|
|
|
// TestMemory_InclusionProofVerifiesAgainstRoot cross-checks that for a tree
|
|
// of several entries (including a non-power-of-two size), the audit path
|
|
// returned alongside every leaf verifies against the advertised tree root
|
|
// per RFC 6962 §2.1.1. If this breaks, our Merkle code is wrong.
|
|
func TestMemory_InclusionProofVerifiesAgainstRoot(t *testing.T) {
|
|
for _, n := range []int{1, 2, 3, 4, 5, 7, 8, 13} {
|
|
m := NewMemory()
|
|
ctx := context.Background()
|
|
envs := make([]Envelope, n)
|
|
hashes := make([]string, n)
|
|
for i := range envs {
|
|
envs[i] = sampleEnvelope(string(rune('A'+i)), "s")
|
|
hashes[i] = mustHash(t, envs[i])
|
|
if _, _, err := m.Submit(ctx, envs[i], hashes[i]); err != nil {
|
|
t.Fatalf("n=%d seed %d: %v", n, i, err)
|
|
}
|
|
}
|
|
for i := range envs {
|
|
r, _, err := m.Submit(ctx, envs[i], hashes[i])
|
|
if err != nil {
|
|
t.Fatalf("n=%d fetch leaf %d: %v", n, i, err)
|
|
}
|
|
canonical, err := canonicalJSON(envs[i])
|
|
if err != nil {
|
|
t.Fatalf("canonical: %v", err)
|
|
}
|
|
leaf := leafHash(canonical)
|
|
rootBytes, err := decodePrefixedHex(r.InclusionProof.TreeRootHash)
|
|
if err != nil {
|
|
t.Fatalf("decode root: %v", err)
|
|
}
|
|
var proof [][]byte
|
|
for _, h := range r.InclusionProof.Hashes {
|
|
b, err := decodePrefixedHex(h)
|
|
if err != nil {
|
|
t.Fatalf("decode sibling: %v", err)
|
|
}
|
|
proof = append(proof, b)
|
|
}
|
|
if err := verifyInclusionRFC6962(int64(i), int64(n), leaf, proof, rootBytes); err != nil {
|
|
t.Errorf("n=%d leaf %d: audit path does not verify: %v", n, i, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// verifyInclusionRFC6962 implements the RFC 6962 §2.1.1 audit path
|
|
// verification algorithm. Returns nil if proof proves leafHash is at
|
|
// leafIndex in a tree of size treeSize with the given root.
|
|
func verifyInclusionRFC6962(leafIndex, treeSize int64, leaf []byte, proof [][]byte, root []byte) error {
|
|
if leafIndex >= treeSize {
|
|
return errors.New("leaf index out of range")
|
|
}
|
|
fn, sn := leafIndex, treeSize-1
|
|
r := append([]byte(nil), leaf...)
|
|
for _, p := range proof {
|
|
if sn == 0 {
|
|
return errors.New("extra proof element")
|
|
}
|
|
if fn&1 == 1 || fn == sn {
|
|
r = nodeHash(p, r)
|
|
if fn&1 == 0 {
|
|
for fn != 0 && fn&1 == 0 {
|
|
fn >>= 1
|
|
sn >>= 1
|
|
}
|
|
}
|
|
} else {
|
|
r = nodeHash(r, p)
|
|
}
|
|
fn >>= 1
|
|
sn >>= 1
|
|
}
|
|
if sn != 0 {
|
|
return errors.New("incomplete proof")
|
|
}
|
|
if !bytes.Equal(r, root) {
|
|
return errors.New("root mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodePrefixedHex(s string) ([]byte, error) {
|
|
const prefix = "sha256:"
|
|
if len(s) <= len(prefix) || s[:len(prefix)] != prefix {
|
|
return nil, errors.New("hash missing sha256: prefix")
|
|
}
|
|
return hex.DecodeString(s[len(prefix):])
|
|
}
|