diff --git a/provenance.go b/provenance.go index ed67ac8..97c771e 100644 --- a/provenance.go +++ b/provenance.go @@ -48,9 +48,9 @@ var ProvenanceDomains = map[string]struct{}{ "attesto.provenance.v1.evidence_tree": {}, "attesto.provenance.v1.policy_tree": {}, "attesto.provenance.v1.attestation": {}, - "attesto.disclosure.v2": {}, - "attesto.zk.range.v1.statement": {}, - "attesto.zk.range.v1.transcript": {}, + "attesto.disclosure.v2": {}, + "attesto.zk.range.v1.statement": {}, + "attesto.zk.range.v1.transcript": {}, } // TopLeafRoles is the canonical order of the six typed top-tree leaves. No @@ -263,6 +263,40 @@ func TopLeafDigest(leafRole, commitment, randomizer string) (string, error) { }) } +// OrderedTopLeafDigests builds the six typed top leaves, requiring each role +// exactly once. +// +// CapsuleRoot receives digests, so by then a role is no longer visible and a +// tree carrying evidence_root twice with vault_identity_commitment missing folds +// to a root it will accept. The check has to happen here, where the roles still +// exist, which is also why callers should reach for this rather than assembling +// the slice themselves. +func OrderedTopLeafDigests(commitments, randomizers map[string]string) ([]string, error) { + for _, role := range TopLeafRoles { + if _, ok := commitments[role]; !ok { + return nil, fmt.Errorf("capsule tree is missing top leaf %s", role) + } + } + known := make(map[string]struct{}, len(TopLeafRoles)) + for _, role := range TopLeafRoles { + known[role] = struct{}{} + } + for role := range commitments { + if _, ok := known[role]; !ok { + return nil, fmt.Errorf("unknown top leaf role: %s", role) + } + } + digests := make([]string, 0, len(TopLeafRoles)) + for _, role := range TopLeafRoles { + digest, err := TopLeafDigest(role, commitments[role], randomizers[role]) + if err != nil { + return nil, err + } + digests = append(digests, digest) + } + return digests, nil +} + // CapsuleRoot folds the six typed top leaves into the capsule root. func CapsuleRoot(orderedTopLeafDigests []string) (string, error) { if len(orderedTopLeafDigests) != len(TopLeafRoles) { diff --git a/provenance_parity_test.go b/provenance_parity_test.go index c8c096f..cdc0bc2 100644 --- a/provenance_parity_test.go +++ b/provenance_parity_test.go @@ -11,6 +11,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -55,13 +56,20 @@ func TestProvenanceCorpusIsPresentAndTyped(t *testing.T) { if vector["protocol"] != ProvenanceProtocol { t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"]) } + // Four outcomes, deliberately distinct. "invalid" means the check ran + // and answered no; "rejected" means the input was refused before any + // check could run; "differs" is not a validity claim but a requirement + // that two values not be equal. switch vector["expectation"] { - case "valid": - case "invalid": + case "valid", "differs": + case "invalid", "rejected": invalid++ default: t.Fatalf("%s: bad expectation %v", entry, vector["expectation"]) } + if vector["requires"] == nil { + t.Fatalf("%s: does not declare what it requires", entry) + } } if invalid < 5 { t.Fatalf("corpus carries only %d negative vectors", invalid) @@ -393,3 +401,162 @@ func TestProvenanceCanonicalJSONMatchesRust(t *testing.T) { } } } + +// --------------------------------------------------- canonical claim descriptor + +func TestProvenanceCanonicalClaimDescriptorReproducesRustLeaf(t *testing.T) { + // A claim leaf commits subject, authority and evidence refs, not just a + // value. REVIEW-02 ยง7.2 widened the descriptor precisely so a disclosure + // cannot present semantics the leaf never committed; building the older + // shape here would break every cross-language disclosure. + vector := loadProvenanceVector(t, "provenance-canonical-claim-descriptor-valid") + digest, err := ProvenanceCommitmentDigest( + vector["domain"].(string), + vector["descriptor"], + vector["randomizer"].(string), + ) + if err != nil { + t.Fatalf("commit: %v", err) + } + if digest != vector["expected_digest"].(string) { + t.Fatalf("claim leaf mismatch:\n got %s\nwant %s", digest, vector["expected_digest"]) + } +} + +func TestProvenanceClaimBackingChangesTheLeaf(t *testing.T) { + // Two claims that read the same but rest on different backing must not share + // a leaf: sharing one would let a claim attested by one provider be presented + // as attested by another, which no later check catches. + for _, name := range []string{ + "provenance-canonical-claim-descriptor-authority-mutation", + "provenance-canonical-claim-descriptor-evidence-ref-mutation", + } { + vector := loadProvenanceVector(t, name) + digest, err := ProvenanceCommitmentDigest( + vector["domain"].(string), + vector["descriptor"], + vector["randomizer"].(string), + ) + if err != nil { + t.Fatalf("%s: commit: %v", name, err) + } + if digest != vector["expected_digest"].(string) { + t.Fatalf("%s: digest mismatch:\n got %s\nwant %s", name, digest, vector["expected_digest"]) + } + if digest == vector["must_differ_from"].(string) { + t.Fatalf("%s: leaf collided with the unmutated claim", name) + } + } +} + +// ------------------------------------------------------ malformed capsule trees + +func TestProvenanceMalformedCapsuleTreeIsRefused(t *testing.T) { + // The duplicate-role case is the one that needs OrderedTopLeafDigests: + // CapsuleRoot receives digests, and by then the role is gone, so a tree + // carrying one role twice and another not at all folds to a root it accepts. + vector := loadProvenanceVector(t, "provenance-capsule-root-invalid-leaf") + valid := loadProvenanceVector(t, "provenance-capsule-root-valid") + + digest := strings.Repeat("aa", 32) + randomizer := strings.Repeat("11", 32) + commitments := map[string]string{} + randomizers := map[string]string{} + for _, role := range TopLeafRoles { + commitments[role] = digest + randomizers[role] = randomizer + } + + reasons := map[string]struct{}{} + cases := vector["cases"].([]any) + for _, raw := range cases { + c := raw.(map[string]any) + switch { + case c["omit_role"] != nil: + broken := map[string]string{} + for role, value := range commitments { + if role != c["omit_role"].(string) { + broken[role] = value + } + } + if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil { + t.Fatalf("a tree missing %v was accepted", c["omit_role"]) + } + case c["duplicate_role"] != nil: + role := c["duplicate_role"].(string) + dropped := "" + for _, candidate := range TopLeafRoles { + if candidate != role { + dropped = candidate + break + } + } + broken := map[string]string{} + for r, value := range commitments { + if r != dropped { + broken[r] = value + } + } + broken[role+"_again"] = digest + if _, err := OrderedTopLeafDigests(broken, randomizers); err == nil { + t.Fatalf("a tree carrying %s twice was accepted", role) + } + case c["malformed_commitment"] != nil: + if _, err := TopLeafDigest("claims_root", c["malformed_commitment"].(string), randomizer); err == nil { + t.Fatal("a malformed commitment was accepted") + } + case c["empty_subtree"] != nil: + if _, err := SubtreeMerkleRoot(c["empty_subtree"].(string), nil); err == nil { + t.Fatalf("an empty %v tree was accepted", c["empty_subtree"]) + } + case c["duplicate_leaf_id"] != nil: + leaves := leafInputs(t, valid["claims"]) + repeated := leaves[0] + repeated.LeafID = c["duplicate_leaf_id"].(string) + if _, err := OrderSubtreeLeaves([]SubtreeLeafInput{repeated, repeated}); err == nil { + t.Fatal("a duplicate leaf id was accepted") + } + default: + t.Fatalf("unhandled refusal case: %v", c) + } + reasons[c["expected_error"].(string)] = struct{}{} + } + + if len(reasons) != len(cases) { + t.Fatalf("each case must fail for its own reason: %d reasons for %d cases", len(reasons), len(cases)) + } +} + +func TestProvenanceSafeAssemblyAcceptsAWellFormedTree(t *testing.T) { + // The refusals above must not be a function that refuses everything. + vector := loadProvenanceVector(t, "provenance-capsule-root-valid") + claimsRoot, _ := buildSubtree(t, "claims", vector["claims"]) + evidenceRoot, _ := buildSubtree(t, "evidence", vector["evidence"]) + policyRoot, _ := buildSubtree(t, "policy_results", vector["policy_results"]) + + commitments := map[string]string{ + "subject_commitment": vector["subject_commitment"].(string), + "claims_root": claimsRoot, + "evidence_root": evidenceRoot, + "policy_results_root": policyRoot, + "attestation_commitment": vector["attestation_commitment"].(string), + "vault_identity_commitment": vector["vault_identity_commitment"].(string), + } + randomizers := map[string]string{} + for role, value := range vector["top_randomizers"].(map[string]any) { + randomizers[role] = value.(string) + } + + digests, err := OrderedTopLeafDigests(commitments, randomizers) + if err != nil { + t.Fatalf("assemble: %v", err) + } + root, err := CapsuleRoot(digests) + if err != nil { + t.Fatalf("capsule root: %v", err) + } + expected := vector["expected"].(map[string]any) + if root != expected["capsule_root"].(string) { + t.Fatalf("capsule root mismatch:\n got %s\nwant %s", root, expected["capsule_root"]) + } +}