ProofedMesh/services/verification-service/internal/chain/chain_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

415 lines
12 KiB
Go

package chain
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"time"
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
"basicstack.de/proofedmesh/services/verification-service/internal/store"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
const (
approvalKeyID = "trust-api/approval-signer-2026-q4"
transformKeyID = "trust-api/agent-testagent-transform-2026-q4"
)
// fixedClock returns a deterministic Now function.
func fixedClock(ts time.Time) func() time.Time { return func() time.Time { return ts } }
// fakeFetcher is a map-backed tlog.Fetcher.
type fakeFetcher struct {
entries map[string]tlog.Entry
}
func (f *fakeFetcher) Fetch(_ context.Context, ref string) (tlog.Entry, error) {
e, ok := f.entries[ref]
if !ok {
return tlog.Entry{}, tlog.ErrNotFound
}
return e, nil
}
// fixture bundles everything a happy-path test needs. Negative-path
// tests derive from fixture and mutate exactly one input.
type fixture struct {
t *testing.T
now time.Time
payloadHash string
decisionID string
sinkTarget string
approvalEntry tlog.Entry
transformEntry tlog.Entry
tree *memTree
keyring *registry.Static
storev store.Store
}
func newFixture(t *testing.T) *fixture {
t.Helper()
now := time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC)
payloadHash := "sha256:" + hex.EncodeToString(sha(seedBytes("payload-xyz")))
keyring := registry.NewStatic()
// Approval signer.
apPub, apPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen approval key: %v", err)
}
keyring.Register(approvalKeyID, apPub)
// Transform signer.
trPub, trPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen transform key: %v", err)
}
keyring.Register(transformKeyID, trPub)
// Approval statement. Content_hash is self-asserted — in the real
// pipeline the orchestrator computes it from the preview's canonical
// content; here we just mint a stable value and reuse it as the
// subject digest to satisfy the content-binding check.
approvalContent := "sha256:" + hex.EncodeToString(sha(seedBytes("approval-xyz")))
apStmt := map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{map[string]any{
"name": "approval/pv-xyz",
"digest": map[string]any{"sha256": stripSha256Prefix(approvalContent)},
}},
"predicateType": "https://basicstack.de/attestations/approval/v1",
"predicate": map[string]any{
"approval_schema": "basicstack.de/attestations/approval/v1",
"preview_id": "pv-xyz",
"session_id": "sess-xyz",
"content_hash": approvalContent,
"allowed_transformations": []any{"expense.map_to_sink"},
"allowed_sinks": []any{"sink:dummy-sink"},
"expires_at": now.Add(10 * time.Minute).Format(time.RFC3339),
"performed_by": map[string]any{
"iss": "https://pocket.local",
"sub": "user-xyz",
"session_id": "sess-xyz",
},
"performed_at": now.Format(time.RFC3339),
"verification_digits": "1234",
},
}
apBytes, err := canonical.Marshal(apStmt)
if err != nil {
t.Fatalf("canonical approval: %v", err)
}
apEnv := dsse.Sign("application/vnd.in-toto+json", apBytes, approvalKeyID, apPriv)
// Transform statement. Subject digest == payload_hash (hex only).
trStmt := map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{map[string]any{
"name": "transform/wf-xyz/handover",
"digest": map[string]any{"sha256": stripSha256Prefix(payloadHash)},
}},
"predicateType": "https://basicstack.de/attestations/transform/v1",
"predicate": map[string]any{
"workflow_id": "wf-xyz",
"actor": "agent-testagent-transform",
"operation": "expense.map_to_sink",
"sink_target": "sink:dummy-sink",
"approval_ref": "(filled below)",
"transform_version": "v0.1.0",
"issued_at": now.Format(time.RFC3339),
},
}
trBytes, err := canonical.Marshal(trStmt)
if err != nil {
t.Fatalf("canonical transform: %v", err)
}
trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv)
// Build a two-leaf Merkle tree out of the two envelopes.
tree := &memTree{}
apEntry := tree.add(apEnv)
trEntry := tree.add(trEnv)
tree.refreshProofs(&apEntry, &trEntry)
return &fixture{
t: t,
now: now,
payloadHash: payloadHash,
decisionID: "dec-xyz-1",
sinkTarget: "sink:dummy-sink",
approvalEntry: apEntry,
transformEntry: trEntry,
tree: tree,
keyring: keyring,
storev: store.NewMemory(),
}
}
func (f *fixture) verifier() *Verifier {
fetcher := &fakeFetcher{entries: map[string]tlog.Entry{
f.approvalEntry.ContentHash: f.approvalEntry,
f.transformEntry.ContentHash: f.transformEntry,
}}
return &Verifier{
TLog: fetcher,
Keyring: f.keyring,
Store: f.storev,
Now: fixedClock(f.now),
Approval: ApprovalConfig{
ExpectedKeyID: approvalKeyID,
TransformKeyID: transformKeyID,
},
}
}
func (f *fixture) request() Request {
return Request{
DecisionID: f.decisionID,
ApprovalRef: f.approvalEntry.ContentHash,
TransformRef: f.transformEntry.ContentHash,
PayloadHash: f.payloadHash,
SinkTarget: f.sinkTarget,
}
}
func TestVerify_HappyPath(t *testing.T) {
f := newFixture(t)
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if !verdict.Valid {
t.Fatalf("verdict not valid: %+v", verdict)
}
if verdict.Replay {
t.Fatalf("verdict replay flag unexpectedly set")
}
if len(verdict.Actors) != 2 {
t.Fatalf("actors: want 2, got %v", verdict.Actors)
}
if verdict.Actors[0] != approvalKeyID || verdict.Actors[1] != transformKeyID {
t.Fatalf("actors: %v", verdict.Actors)
}
}
func TestVerify_Replay(t *testing.T) {
f := newFixture(t)
v := f.verifier()
// First call reserves the decision_id.
verdict, err := v.Verify(context.Background(), f.request())
if err != nil || !verdict.Valid {
t.Fatalf("first call: want valid=true, got valid=%v err=%v reasons=%v", verdict.Valid, err, verdict.Reasons)
}
// Second call with the SAME decision_id MUST land as replay=true,
// valid=false — the store refuses the insert so the chain does not
// re-authorise the sink write.
v2 := f.verifier()
v2.Store = f.storev // keep the same store so the first row is still there
verdict2, err := v2.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("second call err: %v", err)
}
if verdict2.Valid {
t.Fatalf("second call: want valid=false, got %+v", verdict2)
}
if !verdict2.Replay {
t.Fatalf("second call: want replay=true, got %+v", verdict2)
}
if !containsReason(verdict2.Reasons, ReasonReplay) {
t.Fatalf("replay reason missing: %v", verdict2.Reasons)
}
}
func TestVerify_BadSignature(t *testing.T) {
f := newFixture(t)
// Flip a bit in the transform signature so dsse.Verify rejects it.
env := f.transformEntry.Envelope
raw, _ := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig)
raw[0] ^= 0xff
env.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(raw)
f.transformEntry.Envelope = env
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !containsReason(verdict.Reasons, ReasonTransformSignatureInvalid) {
t.Fatalf("want %s, got %v", ReasonTransformSignatureInvalid, verdict.Reasons)
}
}
func TestVerify_InclusionProofMissing(t *testing.T) {
f := newFixture(t)
// Drop the audit path on the approval entry — the proof should fail
// to recompute the tree root.
f.approvalEntry.InclusionProof.Hashes = nil
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonApprovalInclusionInvalid) {
t.Fatalf("want %s*, got %v", ReasonApprovalInclusionInvalid, verdict.Reasons)
}
}
func TestVerify_HashDrift(t *testing.T) {
f := newFixture(t)
// Flip the payload_hash the caller claims; transform.subject.digest
// no longer matches.
driftHash := "sha256:" + hex.EncodeToString(sha(seedBytes("wrong-payload")))
req := f.request()
req.PayloadHash = driftHash
v := f.verifier()
verdict, err := v.Verify(context.Background(), req)
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonTransformHashDrift) {
t.Fatalf("want %s*, got %v", ReasonTransformHashDrift, verdict.Reasons)
}
}
func TestVerify_SinkNotAllowed(t *testing.T) {
f := newFixture(t)
req := f.request()
req.SinkTarget = "sink:unknown"
v := f.verifier()
verdict, err := v.Verify(context.Background(), req)
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonSinkNotAllowed) {
t.Fatalf("want %s*, got %v", ReasonSinkNotAllowed, verdict.Reasons)
}
}
func TestVerify_Expired(t *testing.T) {
f := newFixture(t)
// Push the clock past the approval's expires_at.
v := f.verifier()
v.Now = fixedClock(f.now.Add(30 * time.Minute))
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonApprovalExpired) {
t.Fatalf("want %s*, got %v", ReasonApprovalExpired, verdict.Reasons)
}
}
func TestVerify_OperationNotAllowed(t *testing.T) {
// Rebuild the fixture with a transform that uses an operation the
// approval does not list — this is a stricter negative than SinkNotAllowed
// since it exercises predicate.allowed_transformations.
f := newFixtureCustom(t, func(trPredicate map[string]any) {
trPredicate["operation"] = "expense.unauthorised"
})
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonTransformationNotAllowed) {
t.Fatalf("want %s*, got %v", ReasonTransformationNotAllowed, verdict.Reasons)
}
}
// ----- helpers -----
// newFixtureCustom builds a fixture with a transform-predicate mutation
// applied before signing. Used by tests that need to exercise fields
// outside the default fixture (e.g. operation not in allowed_transformations).
func newFixtureCustom(t *testing.T, mutateTransform func(map[string]any)) *fixture {
t.Helper()
f := newFixture(t)
// Pull the transform payload out, mutate, re-sign, re-tree.
_, trPriv, _ := ed25519.GenerateKey(rand.Reader)
// Re-register with the registry so the signing matches.
trPub := trPriv.Public().(ed25519.PublicKey)
f.keyring.Register(transformKeyID, trPub)
trPredicate := map[string]any{
"workflow_id": "wf-xyz",
"actor": "agent-testagent-transform",
"operation": "expense.map_to_sink",
"sink_target": "sink:dummy-sink",
"approval_ref": "(filled below)",
"transform_version": "v0.1.0",
"issued_at": f.now.Format(time.RFC3339),
}
mutateTransform(trPredicate)
trStmt := map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{map[string]any{
"name": "transform/wf-xyz/handover",
"digest": map[string]any{"sha256": stripSha256Prefix(f.payloadHash)},
}},
"predicateType": "https://basicstack.de/attestations/transform/v1",
"predicate": trPredicate,
}
trBytes, err := canonical.Marshal(trStmt)
if err != nil {
t.Fatalf("re-canonicalise transform: %v", err)
}
trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv)
// Rebuild the tree so proofs stay consistent.
tree := &memTree{}
apEntry := tree.add(f.approvalEntry.Envelope)
trEntry := tree.add(trEnv)
tree.refreshProofs(&apEntry, &trEntry)
f.approvalEntry = apEntry
f.transformEntry = trEntry
f.tree = tree
return f
}
func containsReason(reasons []string, want string) bool {
for _, r := range reasons {
if r == want {
return true
}
}
return false
}
func hasPrefix(reasons []string, prefix string) bool {
for _, r := range reasons {
if r == prefix || len(r) > len(prefix) && strings.HasPrefix(r, prefix+":") {
return true
}
}
return false
}