ProofedMesh/services/verification-service/internal/tlog/proof_test.go
CTO (Paperclip) 0ae29ae342
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 16s
validate / JSON Schema (ajv) (push) Successful in 20s
build-images / signing-service image (push) Successful in 24s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612)
Adds services/verification-service exposing POST /api/v1/verify/chain
for sink:dummy-sink. The handler runs the six hard-coded chain checks
described in DEV-612 and reserves decision_id in a new
`verification_decisions` table so a replayed sink delivery is refused
without recomputing signatures.

- DSSE signature verification against configurable approval + transform
  signer key_ids, loaded from VERIFICATION_SIGNER_KEYS (pem/hex/b64/b64u)
- RFC 6962 inclusion-proof verifier (shared leaf/node hashing with
  services/transparency-log/pkg/backend)
- Content binding: subject.digest.sha256 must match predicate.content_hash
  (approval) and request.payload_hash (transform)
- Hard-coded coverage of allowed_transformations / allowed_sinks /
  expires_at predicates (OPA deferred to Phase 2 per the subtask)
- Replay guard via new db/trust/migrations/0005_verification_decisions
  (in-memory store ships; Postgres skeleton mirrors sibling pattern)
- Dockerfile mirrors signing-service pattern (distroless static + nonroot)
- README documents endpoint, reason-code taxonomy, required predicate
  fields, and the Phase-2 non-scope

Tests: happy path + 7 negatives (bad signature, missing inclusion proof,
hash drift, sink not allowed, expired, replay, operation not allowed).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:35:35 +00:00

168 lines
4.7 KiB
Go

package tlog
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"testing"
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
)
// Local re-implementation of the transparency-log's in-memory Merkle
// backend. Kept here (rather than importing it) so this module stays
// free of cross-module dependencies — the whole point of the proof
// verifier is that it closes against bytes, not against the server
// that produced them.
type tree struct {
leaves [][]byte
}
func (t *tree) addEnvelope(env dsse.Envelope) (Entry, string) {
leafBytes, _ := canonical.Marshal(env)
t.leaves = append(t.leaves, leafHash(leafBytes))
idx := int64(len(t.leaves) - 1)
root := merkleTreeHash(t.leaves)
path := auditPath(t.leaves, int(idx))
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
hash := "sha256:" + canonical.SHA256Hex(leafBytes)
return Entry{
ContentHash: hash,
Envelope: env,
LogIndex: idx,
InclusionProof: InclusionProof{
TreeSize: int64(len(t.leaves)),
TreeRootHash: "sha256:" + hex.EncodeToString(root),
Hashes: hashes,
},
}, hash
}
func merkleTreeHash(leaves [][]byte) []byte {
switch len(leaves) {
case 0:
h := leafHash(nil)
return h
case 1:
return append([]byte(nil), leaves[0]...)
}
k := largestPow2LessThan(len(leaves))
return nodeHash(merkleTreeHash(leaves[:k]), merkleTreeHash(leaves[k:]))
}
func auditPath(leaves [][]byte, m int) [][]byte {
n := len(leaves)
if n <= 1 {
return nil
}
k := largestPow2LessThan(n)
if m < k {
return append(auditPath(leaves[:k], m), merkleTreeHash(leaves[k:]))
}
return append(auditPath(leaves[k:], m-k), merkleTreeHash(leaves[:k]))
}
func largestPow2LessThan(n int) int {
k := 1
for k < n {
k <<= 1
}
return k >> 1
}
func newEnvelope(t *testing.T, s string) dsse.Envelope {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
return dsse.Sign("application/vnd.in-toto+json", []byte(s), "k1", priv)
}
func TestVerifyEntry_SingleLeafTree(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
if err := VerifyEntry(entry); err != nil {
t.Fatalf("single-leaf: %v", err)
}
}
func TestVerifyEntry_MultiLeafTree(t *testing.T) {
var tr tree
// Append five envelopes; verify every leaf's inclusion proof.
entries := make([]Entry, 0, 5)
for i := 0; i < 5; i++ {
e, _ := tr.addEnvelope(newEnvelope(t, "payload-"+string(rune('a'+i))))
entries = append(entries, e)
}
// Re-fetch the up-to-date proofs for every leaf against the final tree.
for i := range entries {
leaf := tr.leaves[i]
path := auditPath(tr.leaves, i)
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
entries[i].InclusionProof = InclusionProof{
TreeSize: int64(len(tr.leaves)),
TreeRootHash: "sha256:" + hex.EncodeToString(merkleTreeHash(tr.leaves)),
Hashes: hashes,
}
_ = leaf
if err := VerifyEntry(entries[i]); err != nil {
t.Fatalf("leaf %d: %v", i, err)
}
}
}
func TestVerifyEntry_TamperedRoot(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
tr.addEnvelope(newEnvelope(t, `{"i":1}`))
// Re-fetch leaf 0's proof against the 2-leaf tree.
path := auditPath(tr.leaves, 0)
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
entry.InclusionProof = InclusionProof{
TreeSize: 2,
TreeRootHash: "sha256:" + hex.EncodeToString(merkleTreeHash(tr.leaves)),
Hashes: hashes,
}
// Flip a bit in the stated root — proof must fail.
rootBytes, _ := hex.DecodeString(entry.InclusionProof.TreeRootHash[len("sha256:"):])
rootBytes[0] ^= 0xff
entry.InclusionProof.TreeRootHash = "sha256:" + hex.EncodeToString(rootBytes)
err := VerifyEntry(entry)
if !errors.Is(err, ErrInclusionProofInvalid) {
t.Fatalf("want ErrInclusionProofInvalid, got %v", err)
}
}
func TestVerifyEntry_ContentHashMismatch(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
// Flip a bit in the envelope payload after the proof was captured.
entry.Envelope.Payload = "aaaa"
err := VerifyEntry(entry)
if !errors.Is(err, ErrInclusionProofMalformed) {
t.Fatalf("want ErrInclusionProofMalformed, got %v", err)
}
}
func TestVerifyEntry_IndexOutOfRange(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
entry.LogIndex = 7
entry.InclusionProof.TreeSize = 7
err := VerifyEntry(entry)
if !errors.Is(err, ErrInclusionProofMalformed) {
t.Fatalf("want ErrInclusionProofMalformed, got %v", err)
}
}