diff --git a/bundle_provenance.go b/bundle_provenance.go new file mode 100644 index 0000000..7130f83 --- /dev/null +++ b/bundle_provenance.go @@ -0,0 +1,522 @@ +package attesto + +// The bundle provenance tree (ADR-0014, Option C) and the frozen revocation +// rule an offline verifier applies through it. +// +// A verifier bundle over a provenance stream commits to the capsule roots it +// spans through one Merkle root. Everything here is a client of +// edge/src/bundle_tree.rs; the bundle-tree-* vectors pin the agreement. The +// revocation rule mirrors the platform's key_revocation.evaluate exactly, so +// the ingest path and an offline verifier reach the same verdict from the same +// facts. + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "sort" + "time" +) + +const ( + BundleTreeDomain = "attesto.provenance.v1.bundle_tree" + bundleTreeName = "bundle_provenance" + BundleInclusionKind = "bundle_provenance_inclusion" + bundleProvenanceRootKey = "provenance_root" + bundleProvenanceCountKey = "provenance_event_count" + bundleKeyLifecycleKey = "vault_key_lifecycle" + + KeyStatusValid = "valid" + KeyStatusRevokedAtReceipt = "revoked_at_receipt" + KeyStatusUnknownInstallation = "unknown_installation" + KeyStatusNotEvaluated = "not_evaluated" + FlagSuspectBackdated = "suspect_backdated" + // RevocationReasonUnrecorded marks an instant migration reconstructed rather + // than measured. The verdict stands; the caller is told not to read the + // instant as measured. + RevocationReasonUnrecorded = "unrecorded" + + InclusionValid = "VALID" + InclusionInvalid = "INVALID" +) + +// BundleLeaf is one provenance event as the bundle tree commits to it. The +// installation, key, assurance and vault-claimed occurred_at are bound with the +// capsule root on purpose: revocation is applied to the installation that +// produced the capsule, and a leaf carrying only the root would let that +// installation be swapped under it. +type BundleLeaf struct { + SeqNo int64 `json:"seq_no"` + CapsuleRoot string `json:"capsule_root"` + InstallationID string `json:"installation_id"` + KeyID string `json:"key_id"` + VaultAssurance string `json:"vault_assurance"` + OccurredAt string `json:"occurred_at"` +} + +// BundleProvenanceTree is the committed tree plus what a prover needs. +type BundleProvenanceTree struct { + LeafCount int + MerkleRoot string + ProvenanceRoot string + OrderedLeaves []BundleLeaf + OrderedLeafDigests []string +} + +// BundleInclusionProof is one leaf, proven to the typed provenance root. +type BundleInclusionProof struct { + Leaf BundleLeaf `json:"leaf"` + LeafCount int `json:"leaf_count"` + Steps []ProvenanceProofStep `json:"steps"` + ProvenanceRoot string `json:"provenance_root"` +} + +func bundleLeafValue(leaf BundleLeaf) (map[string]any, error) { + if leaf.SeqNo < 0 { + return nil, fmt.Errorf("bundle leaf seq_no must be a non-negative integer") + } + if leaf.InstallationID == "" || leaf.KeyID == "" || leaf.OccurredAt == "" { + return nil, fmt.Errorf("bundle leaf installation_id, key_id and occurred_at must be non-empty") + } + known := false + for _, level := range VaultAssuranceLevels { + if level == leaf.VaultAssurance { + known = true + break + } + } + if !known { + // L3 included: it is verifier-derived and has no on-wire form, so a + // leaf claiming it is malformed rather than merely invalid. + return nil, fmt.Errorf("bundle leaf vault_assurance must be one of %v", VaultAssuranceLevels) + } + if err := assertProvenanceDigest("leaf.capsule_root", leaf.CapsuleRoot); err != nil { + return nil, err + } + return map[string]any{ + "kind": "leaf", + "seq_no": leaf.SeqNo, + "capsule_root": leaf.CapsuleRoot, + "installation_id": leaf.InstallationID, + "key_id": leaf.KeyID, + "vault_assurance": leaf.VaultAssurance, + "occurred_at": leaf.OccurredAt, + }, nil +} + +// BundleProvenanceLeaf hashes one provenance event as the bundle tree commits +// to it. +func BundleProvenanceLeaf(leaf BundleLeaf) (string, error) { + value, err := bundleLeafValue(leaf) + if err != nil { + return "", err + } + if err := AssertCommitmentSafeNumbers(value, "$"); err != nil { + return "", err + } + return DomainHashHex(BundleTreeDomain, value) +} + +func bundleTypedRoot(leafCount int, merkleRoot string) (string, error) { + return DomainHashHex(BundleTreeDomain, map[string]any{ + "kind": "root", + "tree": bundleTreeName, + "leaf_count": leafCount, + "merkle_root": merkleRoot, + }) +} + +// BundleProvenanceRoot folds the events a bundle spans, in seq_no order, into +// its typed root. A repeated seq_no is refused: one event cannot be two leaves. +func BundleProvenanceRoot(leaves []BundleLeaf) (*BundleProvenanceTree, error) { + if len(leaves) == 0 { + return nil, fmt.Errorf("cannot build an empty %s tree", bundleTreeName) + } + ordered := append([]BundleLeaf(nil), leaves...) + sort.SliceStable(ordered, func(left, right int) bool { + return ordered[left].SeqNo < ordered[right].SeqNo + }) + digests := make([]string, 0, len(ordered)) + for index, leaf := range ordered { + if index > 0 && ordered[index-1].SeqNo == leaf.SeqNo { + return nil, fmt.Errorf("duplicate leaf id %d", leaf.SeqNo) + } + digest, err := BundleProvenanceLeaf(leaf) + if err != nil { + return nil, err + } + digests = append(digests, digest) + } + merkleRoot, err := provenanceFold(BundleTreeDomain, digests) + if err != nil { + return nil, err + } + root, err := bundleTypedRoot(len(digests), merkleRoot) + if err != nil { + return nil, err + } + return &BundleProvenanceTree{ + LeafCount: len(digests), + MerkleRoot: merkleRoot, + ProvenanceRoot: root, + OrderedLeaves: ordered, + OrderedLeafDigests: digests, + }, nil +} + +func collectProvenanceProof(domain string, level []string, index int) ([]ProvenanceProofStep, error) { + steps := []ProvenanceProofStep{} + current := append([]string(nil), level...) + for len(current) > 1 { + next := make([]string, 0, (len(current)+1)/2) + nextIndex := index + for cursor := 0; cursor < len(current); cursor += 2 { + if cursor+1 >= len(current) { + // Promoted node: it rises with no sibling, so no proof step. + if cursor == index { + nextIndex = len(next) + } + next = append(next, current[cursor]) + continue + } + if cursor == index { + steps = append(steps, ProvenanceProofStep{Side: "right", Sibling: current[cursor+1]}) + nextIndex = len(next) + } else if cursor+1 == index { + steps = append(steps, ProvenanceProofStep{Side: "left", Sibling: current[cursor]}) + nextIndex = len(next) + } + node, err := provenanceNode(domain, current[cursor], current[cursor+1]) + if err != nil { + return nil, err + } + next = append(next, node) + } + current = next + index = nextIndex + } + return steps, nil +} + +// BundleProvenanceProof proves one event under the bundle's provenance root. +func BundleProvenanceProof(leaves []BundleLeaf, seqNo int64) (*BundleInclusionProof, error) { + tree, err := BundleProvenanceRoot(leaves) + if err != nil { + return nil, err + } + for index, leaf := range tree.OrderedLeaves { + if leaf.SeqNo != seqNo { + continue + } + steps, err := collectProvenanceProof(BundleTreeDomain, tree.OrderedLeafDigests, index) + if err != nil { + return nil, err + } + return &BundleInclusionProof{ + Leaf: leaf, + LeafCount: tree.LeafCount, + Steps: steps, + ProvenanceRoot: tree.ProvenanceRoot, + }, nil + } + return nil, fmt.Errorf("unknown seq_no: %d", seqNo) +} + +// VerifyBundleProvenanceInclusion checks that leaf sits under provenanceRoot. +// +// The leaf is re-hashed from its fields, never taken as a digest, so a proof +// cannot substitute one between leaf and root. It returns (false, nil) for a +// cryptographic failure and an error for a malformed object. +func VerifyBundleProvenanceInclusion(provenanceRoot string, leaf BundleLeaf, steps []ProvenanceProofStep, leafCount int) (bool, error) { + if err := assertProvenanceDigest("provenance_root", provenanceRoot); err != nil { + return false, err + } + if leafCount < 1 { + return false, fmt.Errorf("leaf_count must be a positive integer") + } + digest, err := BundleProvenanceLeaf(leaf) + if err != nil { + return false, err + } + merkleRoot, err := replayProvenanceProof(BundleTreeDomain, digest, steps) + if err != nil { + return false, err + } + derived, err := bundleTypedRoot(leafCount, merkleRoot) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare([]byte(derived), []byte(provenanceRoot)) == 1, nil +} + +// KeyRevocationVerdict is what the key lifecycle says about one event, and why. +type KeyRevocationVerdict struct { + Status string `json:"status"` + Flags []string `json:"flags"` + RevokedAt *time.Time `json:"revoked_at"` + Reason string `json:"reason"` + // True when the effective instant was reconstructed by migration. The + // verdict still stands; the caller is told not to read it as measured. + InstantReconstructed bool `json:"instant_reconstructed"` +} + +// Accepted reports whether the key was live at receipt. It says nothing about +// the signature, which is checked separately. +func (v KeyRevocationVerdict) Accepted() bool { return v.Status == KeyStatusValid } + +// EvaluateKeyRevocation decides whether a key was live when the platform +// received the event. +// +// Revocation is evaluated against the platform receipt time, never the +// vault-claimed occurred_at: a holder controls what it claims, not when the +// platform received it. The boundary is inclusive — an event receipted exactly +// at the revocation instant is revoked, because the alternative gives a +// compromised key one more accepted event. A claim that predates revocation +// while its receipt does not is flagged suspect_backdated in addition to being +// revoked, not instead of. +// +// A nil revokedAt means the key was never revoked, which is a different thing +// from a key revoked in the future and must not be conflated: the second is a +// scheduled retirement and is still evidence. +func EvaluateKeyRevocation(revokedAt *time.Time, receiptTime time.Time, claimedOccurredAt *time.Time, reason string) KeyRevocationVerdict { + if revokedAt == nil { + return KeyRevocationVerdict{Status: KeyStatusValid, Flags: []string{}} + } + effective := revokedAt.UTC() + received := receiptTime.UTC() + reconstructed := reason == RevocationReasonUnrecorded + if received.Before(effective) { + return KeyRevocationVerdict{ + Status: KeyStatusValid, + Flags: []string{}, + RevokedAt: &effective, + Reason: reason, + InstantReconstructed: reconstructed, + } + } + flags := []string{KeyStatusRevokedAtReceipt} + if claimedOccurredAt != nil && claimedOccurredAt.UTC().Before(effective) { + flags = append(flags, FlagSuspectBackdated) + } + return KeyRevocationVerdict{ + Status: KeyStatusRevokedAtReceipt, + Flags: flags, + RevokedAt: &effective, + Reason: reason, + InstantReconstructed: reconstructed, + } +} + +// BundleProvenanceNotClaimed states what a verified inclusion does not prove. +var BundleProvenanceNotClaimed = []map[string]string{ + { + "id": "bundle_asserts_capsule_existence_not_contents", + "statement": "The provenance_root proves this capsule root was among the events " + + "the bundle spans. It says nothing about what the capsule contains; the " + + "platform never opens one.", + }, + { + "id": "revocation_evaluated_against_platform_receipt_time_not_vault_occurred_at", + "statement": "Key revocation is evaluated against the platform receipt time of " + + "this seq_no. The vault-claimed occurred_at is reported, never trusted to " + + "escape revocation.", + }, + { + "id": "key_lifecycle_as_of_bundle_build", + "statement": "vault_key_lifecycle is the installation's lifecycle when the bundle " + + "was built. A revocation recorded later is not in this bundle.", + }, +} + +// BundleProvenanceReport is what one inclusion established against its bundle, +// fact by fact. Inclusion and KeyStatus are separate on purpose: a capsule root +// can be provably under the bundle while its key was revoked before receipt, +// and collapsing the two would lose exactly the distinction an investigator +// needs. Ok is true only when the bundle hash holds, the inclusion verifies and +// the key was live at receipt. +type BundleProvenanceReport struct { + Ok bool `json:"ok"` + Inclusion string `json:"inclusion"` + KeyStatus string `json:"key_status"` + Flags []string `json:"flags"` + SeqNo *int64 `json:"seq_no"` + InstallationID string `json:"installation_id"` + CapsuleRoot string `json:"capsule_root"` + ReceiptTime string `json:"receipt_time"` + RevokedAt string `json:"revoked_at"` + Problems []string `json:"problems"` + NotClaimed []map[string]string `json:"not_claimed"` +} + +func parseRFC3339(raw string) (*time.Time, bool) { + parsed, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return nil, false + } + return &parsed, true +} + +func receiptIssuedAt(bundle map[string]any, seqNo int64) string { + for _, raw := range asSlice(bundle["receipts"]) { + item, ok := raw.(map[string]any) + if !ok { + continue + } + itemSeq, ok := item["seq_no"].(float64) + if !ok || int64(itemSeq) != seqNo { + continue + } + receipt, _ := item["receipt"].(map[string]any) + payload, _ := receipt["payload"].(map[string]any) + return toString(payload["issued_at"]) + } + return "" +} + +// VerifyBundleProvenance verifies one capsule's inclusion in a verifier bundle, +// offline. It needs nothing but the bundle and the inclusion object: it +// recomputes the bundle hash so the provenance root and key lifecycle are +// authenticated, checks the leaf under the root, takes the receipt time for the +// leaf's seq_no from the bundle's own receipts, and applies the frozen +// revocation rule to the installation the leaf names. Every problem is +// collected rather than returned on the first one. +func VerifyBundleProvenance(bundle map[string]any, inclusion map[string]any) BundleProvenanceReport { + problems := []string{} + payload, ok := bundle["payload"].(map[string]any) + if !ok { + payload = map[string]any{} + problems = append(problems, "invalid bundle object") + } + bundleHash := toString(bundle["bundle_hash"]) + if len(payload) > 0 { + derived, err := DomainHashHex(ProofstreamDomains["bundle"], payload) + if err != nil || derived != bundleHash { + problems = append(problems, "bundle_hash mismatch") + } + } + + var leaf BundleLeaf + var seqNo *int64 + if rawLeaf, ok := inclusion["leaf"].(map[string]any); ok { + if encoded, err := json.Marshal(rawLeaf); err == nil { + _ = json.Unmarshal(encoded, &leaf) + } + if value, ok := rawLeaf["seq_no"].(float64); ok { + n := int64(value) + seqNo = &n + } + } + + if toString(inclusion["kind"]) != BundleInclusionKind { + problems = append(problems, "inclusion is not a bundle_provenance_inclusion object") + } + if toString(inclusion["bundle_hash"]) != bundleHash { + problems = append(problems, "inclusion is for a different bundle") + } + + included := InclusionInvalid + declaredRoot, hasRoot := payload[bundleProvenanceRootKey].(string) + if !hasRoot { + problems = append(problems, "bundle carries no provenance_root") + } else { + if toString(inclusion["provenance_root"]) != declaredRoot { + problems = append(problems, "inclusion names a different provenance_root") + } + leafCount, _ := inclusion["leaf_count"].(float64) + declaredCount, _ := payload[bundleProvenanceCountKey].(float64) + if leafCount != declaredCount { + problems = append(problems, "inclusion leaf_count does not match provenance_event_count") + } + var steps []ProvenanceProofStep + if encoded, err := json.Marshal(inclusion["steps"]); err == nil { + _ = json.Unmarshal(encoded, &steps) + } + verified, err := VerifyBundleProvenanceInclusion(declaredRoot, leaf, steps, int(leafCount)) + switch { + case err != nil: + problems = append(problems, "inclusion is malformed: "+err.Error()) + case verified: + included = InclusionValid + default: + problems = append(problems, "leaf is not included under the bundle's provenance_root") + } + } + + receiptTime := "" + if seqNo != nil { + receiptTime = receiptIssuedAt(bundle, *seqNo) + } + if receiptTime == "" { + problems = append(problems, fmt.Sprintf("bundle carries no receipt for seq_no %v", formatSeqNo(seqNo))) + } + + var entry map[string]any + for _, raw := range asSlice(payload[bundleKeyLifecycleKey]) { + candidate, ok := raw.(map[string]any) + if ok && toString(candidate["installation_id"]) == leaf.InstallationID && leaf.InstallationID != "" { + entry = candidate + break + } + } + + keyStatus := KeyStatusUnknownInstallation + flags := []string{} + revokedAt := "" + switch { + case entry == nil: + problems = append(problems, fmt.Sprintf("installation %s is not in the bundle's key lifecycle", leaf.InstallationID)) + case receiptTime == "": + keyStatus = KeyStatusNotEvaluated + default: + if toString(entry["key_id"]) != leaf.KeyID { + problems = append(problems, "key lifecycle key_id does not match the leaf") + } + received, ok := parseRFC3339(receiptTime) + if !ok { + keyStatus = KeyStatusNotEvaluated + problems = append(problems, "receipt issued_at is not an RFC 3339 instant") + break + } + var effective *time.Time + if raw := toString(entry["revoked_at"]); raw != "" { + parsed, ok := parseRFC3339(raw) + if !ok { + keyStatus = KeyStatusNotEvaluated + problems = append(problems, "key lifecycle is malformed: revoked_at is not an RFC 3339 instant") + break + } + effective = parsed + revokedAt = raw + } + claimed, _ := parseRFC3339(leaf.OccurredAt) + verdict := EvaluateKeyRevocation(effective, *received, claimed, toString(entry["revocation_reason"])) + keyStatus = verdict.Status + flags = verdict.Flags + if !verdict.Accepted() { + problems = append(problems, fmt.Sprintf( + "installation %s was revoked before seq_no %s was received", leaf.InstallationID, formatSeqNo(seqNo), + )) + } + } + + return BundleProvenanceReport{ + Ok: len(problems) == 0 && included == InclusionValid && keyStatus == KeyStatusValid, + Inclusion: included, + KeyStatus: keyStatus, + Flags: flags, + SeqNo: seqNo, + InstallationID: leaf.InstallationID, + CapsuleRoot: leaf.CapsuleRoot, + ReceiptTime: receiptTime, + RevokedAt: revokedAt, + Problems: problems, + NotClaimed: BundleProvenanceNotClaimed, + } +} + +func formatSeqNo(seqNo *int64) string { + if seqNo == nil { + return "" + } + return fmt.Sprintf("%d", *seqNo) +} diff --git a/bundle_provenance_verification_test.go b/bundle_provenance_verification_test.go new file mode 100644 index 0000000..46d7520 --- /dev/null +++ b/bundle_provenance_verification_test.go @@ -0,0 +1,268 @@ +package attesto + +// ADR-0014 — a verifier bundle's provenance root, checked offline. +// +// The Merkle rules are pinned by bundle-tree-* in the golden corpus; these +// tests cover what sits on top of them: the bundle hash authenticating the root +// and the key lifecycle, the receipt time coming from the bundle's own receipts, +// and the frozen revocation rule applied to the installation the leaf names. + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +const testRevokedAt = "2026-08-18T12:40:00.000Z" + +func testBundleLeaf(seqNo int64, installation, occurredAt string) BundleLeaf { + if occurredAt == "" { + occurredAt = fmt.Sprintf("2026-08-18T12:3%d:00.000Z", seqNo) + } + return BundleLeaf{ + SeqNo: seqNo, + CapsuleRoot: strings.Repeat(fmt.Sprintf("%02x", seqNo), 32), + InstallationID: installation, + KeyID: "key-" + installation, + VaultAssurance: "L1", + OccurredAt: occurredAt, + } +} + +func testLifecycle(installation, revokedAt, reason string) map[string]any { + status := "active" + var revoked any + if revokedAt != "" { + status = "revoked" + revoked = revokedAt + } + var reasonValue any + if reason != "" { + reasonValue = reason + } + return map[string]any{ + "installation_id": installation, + "key_id": "key-" + installation, + "public_key_hex": strings.Repeat("ab", 32), + "status": status, + "revoked_at": revoked, + "revocation_reason": reasonValue, + "replaced_by_installation_id": nil, + "revocation_instant_reconstructed": reason == "unrecorded", + } +} + +// roundTrip turns typed values into the map[string]any shape a JSON bundle has. +func roundTrip(t *testing.T, value any) map[string]any { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(encoded, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +func testBundle(t *testing.T, leaves []BundleLeaf, lifecycle []map[string]any, issued map[int64]string) map[string]any { + t.Helper() + tree, err := BundleProvenanceRoot(leaves) + if err != nil { + t.Fatalf("root: %v", err) + } + payload := map[string]any{ + "kind": "verifier-bundle", + "event_count": len(leaves), + "provenance_root": tree.ProvenanceRoot, + "provenance_event_count": tree.LeafCount, + "vault_key_lifecycle": lifecycle, + } + hash, err := DomainHashHex(ProofstreamDomains["bundle"], payload) + if err != nil { + t.Fatalf("hash: %v", err) + } + receipts := []map[string]any{} + for seqNo, moment := range issued { + receipts = append(receipts, map[string]any{ + "seq_no": seqNo, + "receipt": map[string]any{"payload": map[string]any{"seq_no": seqNo, "issued_at": moment}}, + }) + } + return roundTrip(t, map[string]any{"payload": payload, "bundle_hash": hash, "receipts": receipts}) +} + +func testInclusion(t *testing.T, bundle map[string]any, leaves []BundleLeaf, seqNo int64) map[string]any { + t.Helper() + proof, err := BundleProvenanceProof(leaves, seqNo) + if err != nil { + t.Fatalf("prove: %v", err) + } + inclusion := roundTrip(t, proof) + inclusion["kind"] = BundleInclusionKind + inclusion["protocol"] = "ATTESTO-PROOFSTREAM-001" + inclusion["protocol_version"] = "0.1-alpha" + inclusion["bundle_hash"] = bundle["bundle_hash"] + return inclusion +} + +var ( + testLeaves = []BundleLeaf{testBundleLeaf(1, "lvi_alpha", ""), testBundleLeaf(2, "lvi_beta", ""), testBundleLeaf(3, "lvi_alpha", "")} + testIssued = map[int64]string{1: "2026-08-18T12:31:05.000Z", 2: "2026-08-18T12:32:05.000Z", 3: "2026-08-18T12:33:05.000Z"} +) + +func liveLifecycle() []map[string]any { + return []map[string]any{testLifecycle("lvi_alpha", "", ""), testLifecycle("lvi_beta", "", "")} +} + +func TestBundleProvenanceValidInclusionUnderLiveKeyIsAccepted(t *testing.T) { + bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued) + report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2)) + if !report.Ok { + t.Fatalf("expected ok: %v", report.Problems) + } + if report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusValid || len(report.Flags) != 0 { + t.Fatalf("unexpected report: %+v", report) + } + if *report.SeqNo != 2 || report.InstallationID != "lvi_beta" || report.ReceiptTime != testIssued[2] { + t.Fatalf("unexpected facts: %+v", report) + } + ids := map[string]bool{} + for _, claim := range report.NotClaimed { + ids[claim["id"]] = true + } + if !ids["bundle_asserts_capsule_existence_not_contents"] || + !ids["revocation_evaluated_against_platform_receipt_time_not_vault_occurred_at"] { + t.Fatalf("non-claims missing: %v", report.NotClaimed) + } +} + +func TestBundleProvenanceForeignLeafIsNotIncluded(t *testing.T) { + bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued) + inclusion := testInclusion(t, bundle, testLeaves, 1) + inclusion["leaf"].(map[string]any)["capsule_root"] = strings.Repeat("ee", 32) + report := VerifyBundleProvenance(bundle, inclusion) + if report.Ok || report.Inclusion != InclusionInvalid { + t.Fatalf("foreign leaf must not be included: %+v", report) + } + // The key verdict is still reported: the two facts are independent. + if report.KeyStatus != KeyStatusValid { + t.Fatalf("key status must still be evaluated: %+v", report) + } +} + +func TestBundleProvenanceWrongRootIsRefused(t *testing.T) { + bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued) + inclusion := testInclusion(t, bundle, testLeaves, 1) + inclusion["provenance_root"] = strings.Repeat("ab", 32) + report := VerifyBundleProvenance(bundle, inclusion) + if report.Ok || !strings.Contains(strings.Join(report.Problems, ";"), "different provenance_root") { + t.Fatalf("wrong root must be refused: %+v", report) + } +} + +func TestBundleProvenanceKeyRevokedBeforeReceipt(t *testing.T) { + leaves := []BundleLeaf{testBundleLeaf(1, "lvi_alpha", ""), testBundleLeaf(2, "lvi_beta", ""), testBundleLeaf(3, "lvi_alpha", "2026-08-18T12:45:00.000Z")} + issued := map[int64]string{1: testIssued[1], 2: testIssued[2], 3: "2026-08-18T12:45:05.000Z"} + lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, "key_compromise"), testLifecycle("lvi_beta", "", "")} + bundle := testBundle(t, leaves, lifecycle, issued) + + report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, leaves, 3)) + if report.Ok || report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusRevokedAtReceipt { + t.Fatalf("expected revoked_at_receipt: %+v", report) + } + if strings.Join(report.Flags, ",") != "revoked_at_receipt" || report.RevokedAt != testRevokedAt { + t.Fatalf("unexpected flags: %+v", report) + } + + // The same installation's earlier event, received before revocation, stands. + earlier := VerifyBundleProvenance(bundle, testInclusion(t, bundle, leaves, 1)) + if !earlier.Ok || earlier.KeyStatus != KeyStatusValid { + t.Fatalf("earlier event must stand: %+v", earlier) + } +} + +func TestBundleProvenanceBackdatedClaimIsFlagged(t *testing.T) { + issued := map[int64]string{1: testIssued[1], 2: testIssued[2], 3: "2026-08-18T12:45:05.000Z"} + lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, ""), testLifecycle("lvi_beta", "", "")} + bundle := testBundle(t, testLeaves, lifecycle, issued) + report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 3)) + if report.KeyStatus != KeyStatusRevokedAtReceipt || strings.Join(report.Flags, ",") != "revoked_at_receipt,suspect_backdated" { + t.Fatalf("expected suspect_backdated: %+v", report) + } +} + +func TestBundleProvenanceUnknownInstallation(t *testing.T) { + bundle := testBundle(t, testLeaves, []map[string]any{testLifecycle("lvi_alpha", "", "")}, testIssued) + report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2)) + if report.Ok || report.Inclusion != InclusionValid || report.KeyStatus != KeyStatusUnknownInstallation { + t.Fatalf("expected unknown_installation: %+v", report) + } +} + +func TestBundleProvenanceTamperedLifecycleBreaksTheBundleHash(t *testing.T) { + lifecycle := []map[string]any{testLifecycle("lvi_alpha", testRevokedAt, ""), testLifecycle("lvi_beta", "", "")} + bundle := testBundle(t, testLeaves, lifecycle, testIssued) + inclusion := testInclusion(t, bundle, testLeaves, 1) + entries := bundle["payload"].(map[string]any)["vault_key_lifecycle"].([]any) + entries[0].(map[string]any)["revoked_at"] = nil + report := VerifyBundleProvenance(bundle, inclusion) + if report.Ok || !strings.Contains(strings.Join(report.Problems, ";"), "bundle_hash mismatch") { + t.Fatalf("a tampered lifecycle must break the bundle hash: %+v", report) + } +} + +func TestBundleProvenanceMissingReceiptLeavesTheKeyUnevaluated(t *testing.T) { + bundle := testBundle(t, testLeaves, liveLifecycle(), map[int64]string{1: testIssued[1]}) + report := VerifyBundleProvenance(bundle, testInclusion(t, bundle, testLeaves, 2)) + if report.Ok || report.KeyStatus != KeyStatusNotEvaluated { + t.Fatalf("expected not_evaluated: %+v", report) + } +} + +func TestBundleProvenanceInclusionForAnotherBundleIsRefused(t *testing.T) { + bundle := testBundle(t, testLeaves, liveLifecycle(), testIssued) + inclusion := testInclusion(t, bundle, testLeaves, 1) + inclusion["bundle_hash"] = strings.Repeat("00", 32) + report := VerifyBundleProvenance(bundle, inclusion) + if !strings.Contains(strings.Join(report.Problems, ";"), "inclusion is for a different bundle") { + t.Fatalf("expected refusal: %+v", report) + } +} + +func TestKeyRevocationMirrorsThePlatformRule(t *testing.T) { + revoked := time.Date(2026, 8, 18, 12, 40, 0, 0, time.UTC) + never := EvaluateKeyRevocation(nil, revoked, nil, "") + if never.Status != KeyStatusValid || len(never.Flags) != 0 || never.RevokedAt != nil { + t.Fatalf("never revoked must be valid: %+v", never) + } + before := EvaluateKeyRevocation(&revoked, revoked.Add(-time.Millisecond), nil, "") + if !before.Accepted() || !before.RevokedAt.Equal(revoked) { + t.Fatalf("received before revocation must be valid: %+v", before) + } + // Inclusive boundary: exactly at the instant is revoked. + at := EvaluateKeyRevocation(&revoked, revoked, nil, "") + if at.Status != KeyStatusRevokedAtReceipt || strings.Join(at.Flags, ",") != "revoked_at_receipt" { + t.Fatalf("at the instant must be revoked: %+v", at) + } + claimedBefore := revoked.Add(-time.Minute) + backdated := EvaluateKeyRevocation(&revoked, revoked.Add(time.Minute), &claimedBefore, "") + if strings.Join(backdated.Flags, ",") != "revoked_at_receipt,suspect_backdated" { + t.Fatalf("backdated claim must be flagged: %+v", backdated) + } + claimedAfter := revoked.Add(time.Minute) + honest := EvaluateKeyRevocation(&revoked, revoked.Add(time.Minute), &claimedAfter, "") + if strings.Join(honest.Flags, ",") != "revoked_at_receipt" { + t.Fatalf("honest claim must not be flagged: %+v", honest) + } + reconstructed := EvaluateKeyRevocation(&revoked, revoked.Add(-time.Hour), nil, "unrecorded") + if !reconstructed.Accepted() || !reconstructed.InstantReconstructed { + t.Fatalf("unrecorded reason must be reported: %+v", reconstructed) + } + if EvaluateKeyRevocation(&revoked, revoked.Add(-time.Hour), nil, "rotation").InstantReconstructed { + t.Fatalf("a measured instant must not be reported as reconstructed") + } +} diff --git a/provenance.go b/provenance.go index d2c4c9a..5a5acfb 100644 --- a/provenance.go +++ b/provenance.go @@ -55,6 +55,7 @@ var ProvenanceDomains = map[string]struct{}{ "attesto.disclosure.v2": {}, "attesto.zk.range.v1.statement": {}, "attesto.zk.range.v1.transcript": {}, + "attesto.provenance.v1.bundle_tree": {}, } // TopLeafRoles is the canonical order of the six typed top-tree leaves. No diff --git a/provenance_parity_test.go b/provenance_parity_test.go index cdc0bc2..600eca4 100644 --- a/provenance_parity_test.go +++ b/provenance_parity_test.go @@ -167,10 +167,10 @@ func TestProvenanceMalformedRandomizersAreRefused(t *testing.T) { func TestProvenanceDomainRegistryMatchesRust(t *testing.T) { vector := loadProvenanceVector(t, "provenance-domain-registry") domains := vector["domains"].([]any) - // Eighteen provenance domains plus the three REVIEW-02 protocols that own - // their own preimage spaces: disclosure v2 and the ZK range statement and - // transcript. - const expected = 21 + // Nineteen provenance domains (ADR-0014 added the bundle tree) plus the + // three REVIEW-02 protocols that own their own preimage spaces: disclosure + // v2 and the ZK range statement and transcript. + const expected = 22 if len(domains) != len(ProvenanceDomains) || len(domains) != expected { t.Fatalf( "registry size mismatch: vector=%d go=%d expected=%d", @@ -560,3 +560,168 @@ func TestProvenanceSafeAssemblyAcceptsAWellFormedTree(t *testing.T) { t.Fatalf("capsule root mismatch:\n got %s\nwant %s", root, expected["capsule_root"]) } } + +// ------------------------------------------------------ bundle provenance tree + +func bundleLeaves(t *testing.T, raw any) []BundleLeaf { + t.Helper() + encoded, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal leaves: %v", err) + } + var leaves []BundleLeaf + if err := json.Unmarshal(encoded, &leaves); err != nil { + t.Fatalf("unmarshal leaves: %v", err) + } + return leaves +} + +func bundleInclusion(t *testing.T, raw any) BundleInclusionProof { + t.Helper() + encoded, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal inclusion: %v", err) + } + var proof BundleInclusionProof + if err := json.Unmarshal(encoded, &proof); err != nil { + t.Fatalf("unmarshal inclusion: %v", err) + } + return proof +} + +func verifyBundleInclusion(t *testing.T, proof BundleInclusionProof) bool { + t.Helper() + ok, err := VerifyBundleProvenanceInclusion(proof.ProvenanceRoot, proof.Leaf, proof.Steps, proof.LeafCount) + if err != nil { + t.Fatalf("verify inclusion: %v", err) + } + return ok +} + +func TestBundleTreeReproducesRustRoot(t *testing.T) { + vector := loadProvenanceVector(t, "bundle-tree-valid") + expected := vector["expected"].(map[string]any) + leaves := bundleLeaves(t, vector["leaves"]) + for index, leaf := range leaves { + digest, err := BundleProvenanceLeaf(leaf) + if err != nil { + t.Fatalf("leaf %d: %v", index, err) + } + if digest != expected["leaf_digests"].([]any)[index] { + t.Fatalf("leaf %d digest mismatch", index) + } + } + tree, err := BundleProvenanceRoot(leaves) + if err != nil { + t.Fatalf("root: %v", err) + } + if tree.LeafCount != 5 || tree.MerkleRoot != expected["merkle_root"] || tree.ProvenanceRoot != expected["provenance_root"] { + t.Fatalf("tree mismatch: %+v", tree) + } + reversed := make([]BundleLeaf, 0, len(leaves)) + for index := len(leaves) - 1; index >= 0; index-- { + reversed = append(reversed, leaves[index]) + } + shuffled, err := BundleProvenanceRoot(reversed) + if err != nil || shuffled.ProvenanceRoot != tree.ProvenanceRoot { + t.Fatalf("caller order must not change the root: %v", err) + } +} + +func TestBundleTreeSingleLeafIsItsOwnMerkleRoot(t *testing.T) { + vector := loadProvenanceVector(t, "bundle-tree-single-leaf") + expected := vector["expected"].(map[string]any) + tree, err := BundleProvenanceRoot(bundleLeaves(t, vector["leaves"])) + if err != nil { + t.Fatalf("root: %v", err) + } + if tree.MerkleRoot != tree.OrderedLeafDigests[0] || tree.MerkleRoot != expected["merkle_root"] { + t.Fatalf("single leaf must be its own merkle root") + } + if tree.ProvenanceRoot != expected["provenance_root"] { + t.Fatalf("provenance root mismatch") + } + proof := bundleInclusion(t, vector["inclusion"]) + if len(proof.Steps) != 0 || verifyBundleInclusion(t, proof) != vector["expected_verified"].(bool) { + t.Fatalf("single-leaf inclusion must verify with no steps") + } +} + +func TestBundleTreeEveryLeafProvesToTheRoot(t *testing.T) { + vector := loadProvenanceVector(t, "bundle-tree-inclusion-valid") + leaves := bundleLeaves(t, loadProvenanceVector(t, "bundle-tree-valid")["leaves"]) + for _, raw := range vector["cases"].([]any) { + c := raw.(map[string]any) + proof := bundleInclusion(t, c["inclusion"]) + if !c["expected_verified"].(bool) || !verifyBundleInclusion(t, proof) { + t.Fatalf("seq_no %d must verify", proof.Leaf.SeqNo) + } + // The Go prover reproduces the Rust proof exactly, including the + // promoted leaf that emits no step for its odd level. + produced, err := BundleProvenanceProof(leaves, proof.Leaf.SeqNo) + if err != nil { + t.Fatalf("prove %d: %v", proof.Leaf.SeqNo, err) + } + want, _ := json.Marshal(proof) + got, _ := json.Marshal(produced) + if string(want) != string(got) { + t.Fatalf("proof for seq_no %d differs from Rust:\n%s\n%s", proof.Leaf.SeqNo, want, got) + } + } +} + +func TestBundleTreeRefusesTheFrozenNegatives(t *testing.T) { + for _, name := range []string{ + "bundle-tree-inclusion-wrong-root", + "bundle-tree-inclusion-wrong-seq-no", + "bundle-tree-inclusion-wrong-installation", + "bundle-tree-inclusion-wrong-capsule-root", + "bundle-tree-wrong-domain", + } { + vector := loadProvenanceVector(t, name) + if vector["expected_verified"].(bool) { + t.Fatalf("%s should be a negative vector", name) + } + if verifyBundleInclusion(t, bundleInclusion(t, vector["inclusion"])) { + t.Fatalf("%s verified but must not", name) + } + } +} + +func TestBundleTreeLeafDomainIsLoadBearing(t *testing.T) { + vector := loadProvenanceVector(t, "bundle-tree-wrong-domain") + leaf := bundleLeaves(t, []any{vector["leaf"]})[0] + digest, err := BundleProvenanceLeaf(leaf) + if err != nil { + t.Fatalf("leaf: %v", err) + } + if digest != vector["bundle_tree_leaf_digest"] || digest == vector["wrong_domain_leaf_digest"] { + t.Fatalf("leaf digest must be domain-separated") + } +} + +func TestBundleTreePromotesAndRefusesADuplicateSeqNo(t *testing.T) { + vector := loadProvenanceVector(t, "bundle-tree-leaf-duplicated-not-promoted") + leaves := bundleLeaves(t, vector["leaves"]) + if _, err := BundleProvenanceRoot(leaves); err == nil || !strings.Contains(err.Error(), "duplicate leaf id") { + t.Fatalf("a repeated seq_no must be refused, got %v", err) + } + five, err := BundleProvenanceRoot(leaves[:5]) + if err != nil { + t.Fatalf("root: %v", err) + } + if five.MerkleRoot != vector["promoted_merkle_root"] || five.MerkleRoot == vector["duplicated_fold_merkle_root"] { + t.Fatalf("odd leaf must be promoted, never duplicated") + } +} + +func TestBundleTreeRefusesASignedL3AndAnEmptyBundle(t *testing.T) { + leaf := bundleLeaves(t, loadProvenanceVector(t, "bundle-tree-valid")["leaves"])[0] + leaf.VaultAssurance = "L3" + if _, err := BundleProvenanceLeaf(leaf); err == nil { + t.Fatalf("a leaf claiming L3 must be malformed") + } + if _, err := BundleProvenanceRoot(nil); err == nil { + t.Fatalf("an empty bundle has no tree") + } +}