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
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>
415 lines
12 KiB
Go
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
|
|
}
|