feat(s15): ADR-0014 accepted — bundle provenance root, key lifecycle, offline revocation
Option C. A verifier bundle over a provenance stream carries provenance_root (Merkle over one leaf per event: seq_no, capsule_root, installation, key, assurance, occurred_at, under attesto.provenance.v1.bundle_tree), the event count and vault_key_lifecycle, all conditional so legacy bundle hashes are unchanged. Rust is normative (edge/src/bundle_tree.rs, nine golden vectors); Python, Go and TypeScript verify an inclusion and apply the frozen revocation rule against the receipt time offline. The inclusion endpoint in router.py lands with the next commit, which carries the shared router edits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 "<none>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *seqNo)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ var ProvenanceDomains = map[string]struct{}{
|
|||||||
"attesto.disclosure.v2": {},
|
"attesto.disclosure.v2": {},
|
||||||
"attesto.zk.range.v1.statement": {},
|
"attesto.zk.range.v1.statement": {},
|
||||||
"attesto.zk.range.v1.transcript": {},
|
"attesto.zk.range.v1.transcript": {},
|
||||||
|
"attesto.provenance.v1.bundle_tree": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopLeafRoles is the canonical order of the six typed top-tree leaves. No
|
// TopLeafRoles is the canonical order of the six typed top-tree leaves. No
|
||||||
|
|||||||
+169
-4
@@ -167,10 +167,10 @@ func TestProvenanceMalformedRandomizersAreRefused(t *testing.T) {
|
|||||||
func TestProvenanceDomainRegistryMatchesRust(t *testing.T) {
|
func TestProvenanceDomainRegistryMatchesRust(t *testing.T) {
|
||||||
vector := loadProvenanceVector(t, "provenance-domain-registry")
|
vector := loadProvenanceVector(t, "provenance-domain-registry")
|
||||||
domains := vector["domains"].([]any)
|
domains := vector["domains"].([]any)
|
||||||
// Eighteen provenance domains plus the three REVIEW-02 protocols that own
|
// Nineteen provenance domains (ADR-0014 added the bundle tree) plus the
|
||||||
// their own preimage spaces: disclosure v2 and the ZK range statement and
|
// three REVIEW-02 protocols that own their own preimage spaces: disclosure
|
||||||
// transcript.
|
// v2 and the ZK range statement and transcript.
|
||||||
const expected = 21
|
const expected = 22
|
||||||
if len(domains) != len(ProvenanceDomains) || len(domains) != expected {
|
if len(domains) != len(ProvenanceDomains) || len(domains) != expected {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"registry size mismatch: vector=%d go=%d expected=%d",
|
"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"])
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user