diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5d4b88..a83aa936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +- **Proposal: `references` entries validated by recomputation, and `position-commitment` registered (#321). Version gate for v0.3.** Section 3.1.2 modelled every reference as a fact a party holds and a verifier fetches, so `resolver` was required and rule 4 told a producer with no obliged party to omit the entry. That is wrong when `id` is a digest the verifier recomputes from a proof the holder already carries: nothing is fetched or retained. The v0.3 draft schema adds an optional `validation` member on an entry, whose only value is `recompute`. On such an entry the recomputation must bind the entry to this record, because the proof is public once presented and any record could otherwise carry it; this record's digest is the §3.1.3 preimage. `resolver` and `retention` must be absent, since no party has undertaken anything. "Cannot recompute" (the entry is absent, not a finding) and "recomputed and disagreed" (a contradiction, never reported as an absence) are separate outcomes, the distinction `docs/rfcs/a2a-delegation-profile.md` §4.3 draws for delegation links. `scripts/gen_v03_draft_schema.py` takes the member, the entry's `required` list and its resolver condition from the draft and everything else from canonical. `position-commitment` is registered in §3.1.2, the `rel` description of `schema/trace-claim.json` and its packaged copies, `docs/schema.md` and `docs/references-registry.md`; under v0.2 it earns nothing a pointer does not, and `resolver` is still required. `examples/position-commitment/` (generated by its own script, recomputed by `tests/test_position_commitment_fixtures.py`, named in `tests/test_adequacy_all_sets.py`) holds five v0.3 draft records: a proof that establishes the position, the same proof on another record, no proof held, an altered proof, and a proof whose committer key only the proof itself supplies. The committer keys and the sequence come from a separate `relying-party.json`, never from a proof, because agreement under a key the proof carries establishes consistency and not ordering. **Compatibility:** every existing record keeps validating against both schemas, and no v0.2 rule changes. Forward compatibility does not hold: a record that uses `validation` is rejected by the v0.2 schema and by `TrustRecord`, which is why this is a breaking change under GOVERNANCE.md with a 30-day comment period. The reference library does not read `references[]` and gains no verification path. Requested on #321 by @imran-siddique; construction and the binding correction from Michael Argento (Argento Computing Inc.), named with permission. + - **`content_marking`: consumer field and strict UTF-8 / RFC 8259 JSON boundaries.** `verify_assertion` now validates `subject` (SPIFFE or DID URI), `eat_profile` (non-empty string), and optional `anchor` (non-empty string) on both the assertion and the resolved diff --git a/conformance/schemas/trace-claim.json b/conformance/schemas/trace-claim.json index 0de908b2..910139ac 100644 --- a/conformance/schemas/trace-claim.json +++ b/conformance/schemas/trace-claim.json @@ -286,7 +286,7 @@ "rel": { "type": "string", "minLength": 1, - "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system." + "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system. position-commitment: a commitment to a position in an externally ordered sequence, taken before the digest of this record exists; id is the commitment, validated by recomputation from a proof the holder carries and that commits this record, rather than by resolution. Under v0.2 the entry still names a resolver, so a producer that retains nothing omits it; under the v0.3 draft, an entry carrying validation recompute names no resolver." }, "id": { "type": "string", diff --git a/docs/references-registry.md b/docs/references-registry.md index 4abb3428..f8368141 100644 --- a/docs/references-registry.md +++ b/docs/references-registry.md @@ -39,7 +39,7 @@ schema description, in `docs/schema.md`, or in this document. ## Registered values -The six labels agreed so far, what each one points at, and where it is defined: +The seven labels agreed so far, what each one points at, and where it is defined: | `rel` | Referenced object | Defined in | Example | |---|---|---|---| @@ -49,6 +49,7 @@ The six labels agreed so far, what each one points at, and where it is defined: | `condition-appraisal` | An independent check's finding on whether a stated condition is established by a stated subject | This document, below | [`examples/condition-appraisal/`](../examples/condition-appraisal/) | | `observed-effect` | A signed record of the state change an observer outside the agent saw over one interval | This document, below | [`examples/observed-effect/`](../examples/observed-effect/) | | `applicability-assessment` | An assessor's statement of whether a stated legal provision applies to a stated party, held in another system | This document, below | [`examples/applicability-assessment/`](../examples/applicability-assessment/) | +| `position-commitment` | A commitment to a position in an externally ordered sequence, taken before the digest of this record exists | This document, below | [`examples/position-commitment/`](../examples/position-commitment/) | The first three were registered by the change that introduced the block (#197) and their referenced objects are defined to the extent the documents cited define them. @@ -281,6 +282,81 @@ not a finding for or against the record. The example set holds `applies`, `does-not-apply`, and `undetermined` objects side by side, and the records citing them verify identically. +## `position-commitment` + +### Meaning + +The record points at a position it took in an externally ordered sequence before its +own digest existed. The construction that prompted the registration (#321): an enclave +allocates a slot before the record's digest exists, signs the slot record, and the +not-before is an ordered sequence the producer does not operate. The slot record +travels inside a proof the holder carries. Nothing is held anywhere for a verifier to +fetch, so what the relation lets a relying party establish is when the position was +taken relative to the sequence, which a timestamp inside the record cannot show +because the producer writes it. + +Two neighbours it is not. It is not a transparency-log inclusion: an inclusion proof +shows a record was logged after it existed, and this commitment is taken before. And +it is not `origin`: the position says nothing about where the record's evidence came +from. + +### The referenced object + +A proof the holder carries, not an object a resolver retains. Its wire format belongs +to the sequence and the committing party; what registration fixes is what it must +carry. + +| Fact | Carried as | Why it is required | +|---|---|---| +| The position | The commitment that is the entry's `id` | Recomputing it from the proof is the check | +| The sequence | An identifier for the externally ordered sequence and the position within it | The not-before comes from a sequence the producer does not operate | +| The record binding | This record's digest, committed by the proof | Without it the same proof passes for any record that carries it | +| The committing party | An identifier and a reference to its verification key, where the slot record is signed | Lets a relying party check who allocated the slot | + +This record's digest is the §3.1.3 preimage applied to this record: the RFC 8785 +canonical form of the complete record as published, `signature` member included. + +No resolver undertakes to retain the proof, which is why the entry names none. That +removes the retention undertaking, not the need for the bytes: recomputing later still +needs the proof, so a holder who wants the entry checked keeps the proof bytes, and a +verifier handed none treats the entry as absent. + +The committed example, [`examples/position-commitment/`](../examples/position-commitment/), +shows one concrete shape for these facts: a slot whose digest is the `id`, and a +binding the committer signs once the record exists, which commits the slot and this +record's digest. A producer whose sequence or committer emits another shape that +carries these facts is within this definition. + +### What a verifier may conclude + +Under v0.2, nothing beyond any other pointer. Recomputation is not a check v0.2 +defines, rule 3 of §3.1.2 governs, and `resolver` is still required, so a producer +that retains nothing must omit the entry. The registration is useful under v0.2 only +so that two implementations mean the same thing by the name. + +Under the v0.3 draft, an entry carrying `validation: "recompute"` has three outcomes, +each reported separately: + +1. **The verifier does not hold the proof.** The entry is absent. That is not a + finding against the record. +2. **The recomputation agrees and the proof commits this record's digest.** The + proof is consistent with this record. Whether the position was taken for this + record, and where it sits in the sequence, rests on trust the relying party + supplies itself: a committer key and a copy of the sequence it holds independently + of the proof. A proof checked under a key it carries for itself establishes + consistency, not an independently established ordering. +3. **The recomputation runs and disagrees, or the proof commits another record's + digest.** A contradiction in the record, never reported as an absence. + +The example set holds each of these apart: a proof that establishes the position +under an independently held key and sequence, the same proof carried by another +record, no proof held, a proof altered to an earlier position, and a proof whose only +committer key is the one it carries. The records' signatures verify in every case. + +The second outcome is the property the producer gave up by omitting the entry under +v0.2: telling a backdate from a late commit by a contradiction inside the record, +rather than bounding the commit by rule. + ## Adding a name A registration is a non-breaking spec change of the informative kind under diff --git a/docs/schema.md b/docs/schema.md index 60646f58..64b6e38f 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -145,12 +145,14 @@ An array of pointers to facts held outside this record: an authorization decided | Field | Type | Required | Description | |---|---|---|---| -| `rel` | string | **yes** | Registered values: `authorized-intent`, `approval-outcome`, `behavior-trace`, `condition-appraisal`, `observed-effect`, `applicability-assessment`. A registry rather than a closed set, so the schema does not restrict which relation is named: only that one is: the value must be non-empty | +| `rel` | string | **yes** | Registered values: `authorized-intent`, `approval-outcome`, `behavior-trace`, `condition-appraisal`, `observed-effect`, `applicability-assessment`, `position-commitment`. A registry rather than a closed set, so the schema does not restrict which relation is named: only that one is: the value must be non-empty | | `id` | string | **yes** | Identifier of the referenced fact within the resolver's system | | `resolver` | string | **yes** | Identifier of the party obliged to resolve `id` | | `retention` | string | no | ISO 8601 duration the resolver undertakes to keep `id` resolvable. An undertaking only; nothing enforces it | | `digest` | string | no | `sha256:` or `sha384:` digest of the referenced object, when the producer holds it at issue time | +The v0.3 draft schema adds one optional member, `validation`, whose only value is `recompute`: `id` is then checked by recomputation from evidence the verifier holds and that commits this record, rather than by resolution, and `resolver` and `retention` must be absent. This schema rejects the member. Section 3.1.2, "Entries validated by recomputation", has the rules. + `rel` is open where `origin.kind` is closed. Section 3.1.1 says `kind` is a closed set "because the value of the field is that a verifier can key on it"; section 3.1.2 calls `rel`'s values a registry and does not say that, so a new relation is a spec change and not also a schema change. What each registered value's referenced object is, what a verifier may conclude from it, and how a name is added are in [`references-registry.md`](references-registry.md). Spec section 3.1.2 also binds verifiers: one **must not** reject a record because an entry cannot be resolved, and **must not** treat a resolved entry as attested evidence. A reference that could invalidate a record would hand whoever controls the target a way to invalidate evidence they do not hold. Both are verifier behaviour, so neither the schema nor the reference model can enforce them; they are conformance-suite rules. What the schema and the model do enforce is the shape, and that a producer who cannot name a `resolver` cannot emit an empty one. diff --git a/examples/README.md b/examples/README.md index 4586f085..fc3e54b2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -75,6 +75,13 @@ checking any archived record, including these. `does-not-apply` and an `undetermined` assessment that verify identically to `applies`, unresolvable, and an issuer whose key the relying party does not hold. Every digest recomputes; see that directory's README and `docs/references-registry.md`. +- `position-commitment/`: five signed v0.3 draft Trust Records, each carrying a + `position-commitment` entry with `validation: "recompute"` and no resolver, beside the + proofs a holder presents and a separate `relying-party.json` holding the committer keys + and sequence the relying party supplies itself: a position established, the same proof + on another record, no proof held, a proof altered to an earlier position, and a proof + whose only committer key is the one it carries. Every digest recomputes; see that + directory's README and `docs/references-registry.md`. - `signature-encoding/`: three signed Trust Records that separate a canonically-encoded embedded signature from two non-canonical respellings of the same 64 bytes. Proposed, not accepted normative text (#247); carries a `scan_published_signatures.py` tool diff --git a/examples/position-commitment/01-position-established.json b/examples/position-commitment/01-position-established.json new file mode 100644 index 00000000..ed2c5485 --- /dev/null +++ b/examples/position-commitment/01-position-established.json @@ -0,0 +1,41 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.3", + "iat": 1792487100, + "subject": "spiffe://trust.example.org/agent/settlement", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "4lYwTNnFrnAzuzGYnCjHgaKjy2cCsjf9e7QrQ4z-sP8" + } + }, + "references": [ + { + "rel": "position-commitment", + "id": "sha256:a57dff7b9562239543a519a6a773ccba86c368b8eb2b2307b074b78c5502b6e9", + "validation": "recompute" + } + ], + "signature": "sm5urBa3Gktx7A2ZJpml8lCKUT5kLkaBu_fPLvjoDkh_7D53o2qzr9-iD_UuPEDnwhXZwrO3Du8FEk9BuPdWAg" +} diff --git a/examples/position-commitment/02-same-proof-other-record.json b/examples/position-commitment/02-same-proof-other-record.json new file mode 100644 index 00000000..05c14029 --- /dev/null +++ b/examples/position-commitment/02-same-proof-other-record.json @@ -0,0 +1,41 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.3", + "iat": 1792487100, + "subject": "spiffe://trust.example.org/agent/settlement-replay", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "4lYwTNnFrnAzuzGYnCjHgaKjy2cCsjf9e7QrQ4z-sP8" + } + }, + "references": [ + { + "rel": "position-commitment", + "id": "sha256:a57dff7b9562239543a519a6a773ccba86c368b8eb2b2307b074b78c5502b6e9", + "validation": "recompute" + } + ], + "signature": "7u956pusYtnXk2OJtDCo88NJHlKmBjLj_zXGjfBBOwzjuHbqHlP8kEb_uxAbYhTTEyE3tu3oHfxXhk0HO35gAg" +} diff --git a/examples/position-commitment/03-proof-not-held.json b/examples/position-commitment/03-proof-not-held.json new file mode 100644 index 00000000..29685804 --- /dev/null +++ b/examples/position-commitment/03-proof-not-held.json @@ -0,0 +1,41 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.3", + "iat": 1792487100, + "subject": "spiffe://trust.example.org/agent/settlement/3", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "4lYwTNnFrnAzuzGYnCjHgaKjy2cCsjf9e7QrQ4z-sP8" + } + }, + "references": [ + { + "rel": "position-commitment", + "id": "sha256:db4cf289d429612133ac5fb523c5bd047da48af0b961410a53019c2e36aa936e", + "validation": "recompute" + } + ], + "signature": "wpDJbcLvNzPN4JqkifFGI1GZsikzLeNLzi16DCfuyUZZFS10QftXHgm1F165nHcPisFOhUvaDwRCQ7Ty2ZgVAg" +} diff --git a/examples/position-commitment/04-proof-altered.json b/examples/position-commitment/04-proof-altered.json new file mode 100644 index 00000000..f3c18e42 --- /dev/null +++ b/examples/position-commitment/04-proof-altered.json @@ -0,0 +1,41 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.3", + "iat": 1792487100, + "subject": "spiffe://trust.example.org/agent/settlement/4", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "4lYwTNnFrnAzuzGYnCjHgaKjy2cCsjf9e7QrQ4z-sP8" + } + }, + "references": [ + { + "rel": "position-commitment", + "id": "sha256:58eee2aa583eba15bd577f7bf9ba58e0e6329b1fd18d88535540a760c087f5a9", + "validation": "recompute" + } + ], + "signature": "x0_QtCFhtQjHs8SUQrF9XX7MsZKIN9S0XfflZ4UCt9Iof_MukWFKfrYG4ovb0q1U3zLAAVkNaOUp9edPWjozAQ" +} diff --git a/examples/position-commitment/05-committer-key-self-supplied.json b/examples/position-commitment/05-committer-key-self-supplied.json new file mode 100644 index 00000000..e718f8c5 --- /dev/null +++ b/examples/position-commitment/05-committer-key-self-supplied.json @@ -0,0 +1,41 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.3", + "iat": 1792487100, + "subject": "spiffe://trust.example.org/agent/settlement/5", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "4lYwTNnFrnAzuzGYnCjHgaKjy2cCsjf9e7QrQ4z-sP8" + } + }, + "references": [ + { + "rel": "position-commitment", + "id": "sha256:128de392656b4b653cede1012e46c794db83c827e7b312f8194eda635e65654a", + "validation": "recompute" + } + ], + "signature": "uSAQZfPPz4w_NuQF6avpurby9-sWgBFVbgnlUM5A5Q-Vj7693OoIT9HNv9IfE5LHVWkcrPg-AQuYRzaGSHdVBw" +} diff --git a/examples/position-commitment/README.md b/examples/position-commitment/README.md new file mode 100644 index 00000000..d97c48b5 --- /dev/null +++ b/examples/position-commitment/README.md @@ -0,0 +1,108 @@ +# A position in an externally ordered sequence as a `position-commitment` entry + +A TRACE Trust Record can carry a commitment to a position it took in an externally +ordered sequence before its own digest existed. These fixtures show that composition +under the v0.3 draft. Each record carries one `references` entry with +`rel: "position-commitment"` and `validation: "recompute"`, no `resolver` and no +`retention`, and its `id` is the SHA-256 of the RFC 8785 canonical form of the slot +object inside the proof the holder carries. + +The relation is defined in [`docs/references-registry.md`](../../docs/references-registry.md), +and recomputation in §3.1.2, "Entries validated by recomputation". The v0.2 schema +refuses every record here, which is the version gate; the records validate against +[`schema/trace-claim-v0.3-draft.json`](../../schema/trace-claim-v0.3-draft.json) and bind +nothing until v0.3 is adopted. + +Everything derives from one published seed through +[`gen_position_commitment_vectors.py`](gen_position_commitment_vectors.py), so the set +regenerates byte for byte; the sequence, the committing enclave and the records are +illustrative, and every digest and signature recomputes from the committed files. +[`tests/test_position_commitment_fixtures.py`](../../tests/test_position_commitment_fixtures.py) +recomputes every verdict from those bytes rather than reading it from `expected.json`, +and re-runs the generator against the committed files. + +## Two inputs, kept apart + +| File | Supplied by | What it is | +|---|---|---| +| `proof-*.json` | The holder | The slot, the committer's binding, and `committer_jwk`, a key the proof supplies for itself | +| `relying-party.json` | The relying party | The committer keys it trusts, by RFC 7638 thumbprint, and its own copy of the sequence: the value published at each position | + +Only the second can make a position independently established. A proof whose digests +all agree and whose signatures verify under the key it carries is consistent with +itself, and anyone holding a key can produce one. Case `05` is that proof. + +## The proof + +| Member | What it is | +|---|---| +| `slot.sequence`, `slot.position`, `slot.sequence_value` | The external sequence, the position taken, and the value the sequence published there, so the slot cannot predate that position | +| `slot.committer`, `slot.committer_key_id` | The party that allocated the slot, and the thumbprint of its Ed25519 key | +| `slot.allocated_at`, `slot.signature` | When it was allocated, and the committer's signature over the RFC 8785 form of the slot without this member | +| `binding.slot_commitment` | The slot's digest, equal to the entry's `id` | +| `binding.record_digest` | This record's digest: the §3.1.3 preimage, the complete record as published with `signature` included | +| `binding.bound_at`, `binding.signature` | When the committer bound the slot to the record, and its signature over the binding | +| `committer_jwk` | The key the proof supplies; checked against `committer_key_id`, never trusted for it | + +The slot exists before the record and the binding after it, which is how `id` can be +inside the record while the proof commits the record's digest. + +No resolver undertakes to keep any of this, so the entry names none. Later +recomputation still needs the proof bytes: the holder keeps them, and a verifier +handed none has nothing to recompute (case `03`). + +## Fixture cases + +Expected results are machine-readable in [`expected.json`](expected.json). Every +record's signature verifies under the producer key, and every record validates against +the v0.3 draft schema; what differs is the proof the holder presents and what the +relying party holds. + +| Record | Proof presented | Slot recomputes to `id` | Binds this record | Committer key held independently | Signatures | Sequence position | Verdict | +|---|---|---|---|---|---|---|---| +| `01-position-established.json` | `proof-01.json` | yes | yes | yes | verify | matches | position established | +| `02-same-proof-other-record.json` | `proof-01.json` | yes | **no** | not checked | not checked | not checked | contradiction: commits another record | +| `03-proof-not-held.json` | **none** | n/a | n/a | n/a | n/a | n/a | position absent | +| `04-proof-altered.json` | `proof-04-altered.json` | **no** | not checked | not checked | not checked | not checked | contradiction: commitment mismatch | +| `05-committer-key-self-supplied.json` | `proof-05.json` | yes | yes | **no** | verify under the proof's own key | matches | consistent, not established | + +`01` is the matching record. The slot recomputes to the entry's `id`, the binding +commits this record's digest, both signatures verify under a committer key the +relying party holds, and the sequence value in the slot is the one the relying party's +own copy holds at that position. + +`02` is another record carrying `01`'s entry byte for byte, presented with `01`'s +proof. The commitment recomputes, so a check that stopped there would pass it. The +binding commits `01`'s digest, not `02`'s, and that is a contradiction in the record. + +`03` carries a real commitment whose proof the verifier is never handed. The entry is +absent. That is not a finding against the record, and it is a different answer from +the contradictions in `02` and `04`. + +`04` was issued against `proof-04.json`, which establishes its position. The proof +presented, `proof-04-altered.json`, differs only in `slot.position`, rewritten to an +earlier one: a backdate. The slot no longer recomputes to the `id` the record signed, +and the result is a contradiction, never an absence. + +`05` recomputes and binds, and both signatures verify, but only under +`committer_jwk`, the key the proof carries. The relying party holds no key for that +committer, so the agreement establishes consistency and not ordering. Adding that key +to the relying party's trust would turn the verdict into position established; the +tests show that step is the relying party's to take, never the proof's. + +Two further verdicts are exercised in memory by the tests: a slot that names the +trusted committer's key id but is signed by another key (contradiction: signature +invalid), and a slot under the trusted key whose sequence value differs from the +relying party's copy (contradiction: sequence mismatch). + +None of these outcomes changes `runtime.platform` or makes the entry attested evidence +of what the execution did. + +## Running the checks + +``` +python -m pytest tests/test_position_commitment_fixtures.py +``` + +To regenerate, run the generator with no arguments. It is deterministic, so the +committed files only change when the generator does. diff --git a/examples/position-commitment/expected.json b/examples/position-commitment/expected.json new file mode 100644 index 00000000..091a1ede --- /dev/null +++ b/examples/position-commitment/expected.json @@ -0,0 +1,64 @@ +{ + "trace_signer_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "4lYwTNnFrnAzuzGYnCjHgaKjy2cCsjf9e7QrQ4z-sP8" + }, + "cases": { + "01-position-established.json": { + "proof": "proof-01.json", + "record_signature_verifies": true, + "proof_held": true, + "commitment_recomputes": true, + "binds_this_record": true, + "committer_key_independent": true, + "signatures_verify": true, + "sequence_position_matches": true, + "verdict": "position-established" + }, + "02-same-proof-other-record.json": { + "proof": "proof-01.json", + "record_signature_verifies": true, + "proof_held": true, + "commitment_recomputes": true, + "binds_this_record": false, + "committer_key_independent": null, + "signatures_verify": null, + "sequence_position_matches": null, + "verdict": "contradiction-commits-another-record" + }, + "03-proof-not-held.json": { + "proof": null, + "record_signature_verifies": true, + "proof_held": false, + "commitment_recomputes": null, + "binds_this_record": null, + "committer_key_independent": null, + "signatures_verify": null, + "sequence_position_matches": null, + "verdict": "position-absent" + }, + "04-proof-altered.json": { + "proof": "proof-04-altered.json", + "record_signature_verifies": true, + "proof_held": true, + "commitment_recomputes": false, + "binds_this_record": null, + "committer_key_independent": null, + "signatures_verify": null, + "sequence_position_matches": null, + "verdict": "contradiction-commitment-mismatch" + }, + "05-committer-key-self-supplied.json": { + "proof": "proof-05.json", + "record_signature_verifies": true, + "proof_held": true, + "commitment_recomputes": true, + "binds_this_record": true, + "committer_key_independent": false, + "signatures_verify": true, + "sequence_position_matches": true, + "verdict": "position-consistent-not-established" + } + } +} diff --git a/examples/position-commitment/gen_position_commitment_vectors.py b/examples/position-commitment/gen_position_commitment_vectors.py new file mode 100644 index 00000000..cce9971b --- /dev/null +++ b/examples/position-commitment/gen_position_commitment_vectors.py @@ -0,0 +1,281 @@ +"""Generate examples/position-commitment: five signed v0.3 draft Trust Records, each +carrying one `position-commitment` entry with `validation: "recompute"`, the proofs a +holder presents for them, and the trust a relying party supplies for itself. + +Every key derives from one published seed, so the set regenerates byte for byte and +tests/test_position_commitment_fixtures.py holds the committed files to this script. +The sequence, the committing enclave and the records are illustrative, and every +digest and signature in the set recomputes from what is committed. Nothing here is a +production record. + +Two inputs are kept apart on purpose. A proof is what the holder presents, and it may +carry a key for the party that signed it. `relying-party.json` is what the relying +party supplies itself: the committer keys it trusts and its own copy of the ordered +sequence. Only the second can make the position independently established; a proof +that verifies under the key it carries is consistent with itself and nothing more. + +Usage: python examples/position-commitment/gen_position_commitment_vectors.py [--out DIR] +""" + +from __future__ import annotations + +import argparse +import base64 +import copy +import hashlib +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import rfc8785 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentrust_trace import key_to_jwk, sign_record + +SEED = b"trace-spec examples/position-commitment 2026-10-20" +HERE = Path(__file__).resolve().parent + +PROFILE = "tag:agentrust-io.com,2026:trace-v0.3" +SEQUENCE = "https://beacon.example.org/sequence/1" +COMMITTER = "https://slots.enclave.example" +OTHER_COMMITTER = "https://slots.unknown.example" +IAT = int(datetime(2026, 10, 20, 9, 5, tzinfo=UTC).timestamp()) + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def jcs_sha256(value: Any) -> str: + return "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() + + +def key(label: str) -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(SEED + b"|" + label.encode()).digest() + ) + + +def thumbprint(jwk: dict[str, str]) -> str: + """RFC 7638: SHA-256 over the required members, lexically ordered, no whitespace.""" + required = {k: jwk[k] for k in ("crv", "kty", "x")} + encoded = json.dumps(required, separators=(",", ":"), sort_keys=True).encode() + return b64u(hashlib.sha256(encoded).digest()) + + +def sequence_value(position: int) -> str: + """The value the external sequence published at a position, derived from the seed. + A slot carries it, so the slot cannot have been taken before that position.""" + return "sha256:" + hashlib.sha256(SEED + b"|sequence|" + str(position).encode()).hexdigest() + + +def signed(body: dict[str, Any], signer: Ed25519PrivateKey) -> dict[str, Any]: + """Ed25519 over the RFC 8785 canonical form of the object without `signature`.""" + return {**body, "signature": b64u(signer.sign(rfc8785.dumps(body)))} + + +def slot(position: int, committer: str, signer: Ed25519PrivateKey, at: str) -> dict[str, Any]: + """Allocated before any record exists. The entry's `id` is the digest of this object.""" + return signed( + { + "type": "position-slot", + "sequence": SEQUENCE, + "position": position, + "sequence_value": sequence_value(position), + "committer": committer, + "committer_key_id": thumbprint(key_to_jwk(signer)), + "allocated_at": at, + }, + signer, + ) + + +def record(producer: Ed25519PrivateKey, subject: str, commitment: str) -> dict[str, Any]: + unsigned = { + "eat_profile": PROFILE, + "iat": IAT, + "subject": subject, + "model": {"provider": "example", "model_id": "example-model"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "0" * 64}, + "policy": {"bundle_hash": "sha256:" + "b" * 64, "enforcement_mode": "enforce"}, + "data_class": "confidential", + "build_provenance": {"slsa_level": 1, "digest": "sha256:" + "e" * 64}, + "appraisal": {"status": "none", "verifier": "https://verifier.example.org"}, + "cnf": {"jwk": key_to_jwk(producer)}, + "references": [ + {"rel": "position-commitment", "id": commitment, "validation": "recompute"} + ], + } + return sign_record(unsigned, producer) + + +def record_digest(rec: dict[str, Any]) -> str: + """Spec section 3.1.3: RFC 8785 canonical form of the complete record as published, + `signature` member included.""" + return jcs_sha256(rec) + + +def proof( + slot_obj: dict[str, Any], rec: dict[str, Any], signer: Ed25519PrivateKey, at: str +) -> dict[str, Any]: + """What the holder carries: the slot, and the committer's binding of the slot to the + record digest, taken once the record exists. `committer_jwk` is the key the proof + supplies for itself; a relying party that trusts it has trusted the proof.""" + binding = signed( + { + "type": "position-binding", + "slot_commitment": jcs_sha256(slot_obj), + "record_digest": record_digest(rec), + "committer_key_id": slot_obj["committer_key_id"], + "bound_at": at, + }, + signer, + ) + return { + "type": "position-commitment-proof", + "slot": slot_obj, + "binding": binding, + "committer_jwk": key_to_jwk(signer), + } + + +def build() -> dict[str, Any]: + producer = key("producer") + committer, other_committer = key("committer"), key("other-committer") + subject = "spiffe://trust.example.org/agent/settlement" + other_subject = "spiffe://trust.example.org/agent/settlement-replay" + + slot1 = slot(8812, COMMITTER, committer, "2026-10-20T09:00:00Z") + rec1 = record(producer, subject, jcs_sha256(slot1)) + proof1 = proof(slot1, rec1, committer, "2026-10-20T09:05:01Z") + + # Another record carrying exactly the same entry. The proof is public once + # presented, so nothing stops a second record from carrying it. + rec2 = record(producer, other_subject, jcs_sha256(slot1)) + + # A real commitment whose proof the verifier is never handed. + slot3 = slot(8813, COMMITTER, committer, "2026-10-20T09:00:10Z") + rec3 = record(producer, subject + "/3", jcs_sha256(slot3)) + + slot4 = slot(8814, COMMITTER, committer, "2026-10-20T09:00:20Z") + rec4 = record(producer, subject + "/4", jcs_sha256(slot4)) + proof4 = proof(slot4, rec4, committer, "2026-10-20T09:05:02Z") + # The proof as later presented, its position rewritten to an earlier one: the + # backdate the relation exists to expose. The slot no longer recomputes to the + # entry's `id`, and the committer's signature no longer covers the slot. + proof4_altered = copy.deepcopy(proof4) + proof4_altered["slot"]["position"] = 8700 + + # Signed by a committer the relying party does not hold a key for. The proof + # carries that key, so every digest and signature checks out under it. + slot5 = slot(8815, OTHER_COMMITTER, other_committer, "2026-10-20T09:00:30Z") + rec5 = record(producer, subject + "/5", jcs_sha256(slot5)) + proof5 = proof(slot5, rec5, other_committer, "2026-10-20T09:05:03Z") + + committer_jwk = key_to_jwk(committer) + relying_party = { + "note": ( + "Supplied by the relying party, never read from a proof: the committer keys " + "it trusts and its own copy of the ordered sequence." + ), + "committer_keys": {thumbprint(committer_jwk): committer_jwk}, + "sequence": { + "id": SEQUENCE, + "positions": {str(p): sequence_value(p) for p in (8700, 8812, 8813, 8814, 8815)}, + }, + } + + absent = { + "proof_held": False, + "commitment_recomputes": None, + "binds_this_record": None, + "committer_key_independent": None, + "signatures_verify": None, + "sequence_position_matches": None, + } + expected = { + "trace_signer_jwk": key_to_jwk(producer), + "cases": { + "01-position-established.json": { + "proof": "proof-01.json", + "record_signature_verifies": True, + "proof_held": True, + "commitment_recomputes": True, + "binds_this_record": True, + "committer_key_independent": True, + "signatures_verify": True, + "sequence_position_matches": True, + "verdict": "position-established", + }, + "02-same-proof-other-record.json": { + "proof": "proof-01.json", + "record_signature_verifies": True, + "proof_held": True, + "commitment_recomputes": True, + "binds_this_record": False, + "committer_key_independent": None, + "signatures_verify": None, + "sequence_position_matches": None, + "verdict": "contradiction-commits-another-record", + }, + "03-proof-not-held.json": { + "proof": None, + "record_signature_verifies": True, + **absent, + "verdict": "position-absent", + }, + "04-proof-altered.json": { + "proof": "proof-04-altered.json", + "record_signature_verifies": True, + "proof_held": True, + "commitment_recomputes": False, + "binds_this_record": None, + "committer_key_independent": None, + "signatures_verify": None, + "sequence_position_matches": None, + "verdict": "contradiction-commitment-mismatch", + }, + "05-committer-key-self-supplied.json": { + "proof": "proof-05.json", + "record_signature_verifies": True, + "proof_held": True, + "commitment_recomputes": True, + "binds_this_record": True, + "committer_key_independent": False, + "signatures_verify": True, + "sequence_position_matches": True, + "verdict": "position-consistent-not-established", + }, + }, + } + + return { + "01-position-established.json": rec1, + "02-same-proof-other-record.json": rec2, + "03-proof-not-held.json": rec3, + "04-proof-altered.json": rec4, + "05-committer-key-self-supplied.json": rec5, + "proof-01.json": proof1, + "proof-04.json": proof4, + "proof-04-altered.json": proof4_altered, + "proof-05.json": proof5, + "relying-party.json": relying_party, + "expected.json": expected, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=HERE) + out = parser.parse_args().out + out.mkdir(parents=True, exist_ok=True) + files = build() + for name, value in files.items(): + text = json.dumps(value, indent=2, ensure_ascii=False) + "\n" + (out / name).write_bytes(text.encode("utf-8")) + print(f"{len(files)} files written to {out}") + + +if __name__ == "__main__": + main() diff --git a/examples/position-commitment/proof-01.json b/examples/position-commitment/proof-01.json new file mode 100644 index 00000000..4125b826 --- /dev/null +++ b/examples/position-commitment/proof-01.json @@ -0,0 +1,26 @@ +{ + "type": "position-commitment-proof", + "slot": { + "type": "position-slot", + "sequence": "https://beacon.example.org/sequence/1", + "position": 8812, + "sequence_value": "sha256:adda5fb5b1273925865f3a8c1b983f8a5e51d0126942000e45864cfa5096d321", + "committer": "https://slots.enclave.example", + "committer_key_id": "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0", + "allocated_at": "2026-10-20T09:00:00Z", + "signature": "vCDxcGCDVcDJfE3uHOZoxPx__wSS69yMnXYfF4LC7rT7Novv1XkPX5i3UXGGUUbFWDJi00rFE8h984esyfX4Ag" + }, + "binding": { + "type": "position-binding", + "slot_commitment": "sha256:a57dff7b9562239543a519a6a773ccba86c368b8eb2b2307b074b78c5502b6e9", + "record_digest": "sha256:5e988f0ac116589aaaca6d729e10d4f73638d8bf9a8f051f21efc66b23edff37", + "committer_key_id": "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0", + "bound_at": "2026-10-20T09:05:01Z", + "signature": "UZn-snya88cuVW_NHlBaQRFQBeMB9JCgf4skZy7fSSQeJFt5A6Aovc3JZIFGjUZ-_MRYqJSoRBMxyzrfDIOrBg" + }, + "committer_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "3fbaE_Jek7CQEm8TCSZywsFrzwTY8aIgfXwOabciWbM" + } +} diff --git a/examples/position-commitment/proof-04-altered.json b/examples/position-commitment/proof-04-altered.json new file mode 100644 index 00000000..36d59763 --- /dev/null +++ b/examples/position-commitment/proof-04-altered.json @@ -0,0 +1,26 @@ +{ + "type": "position-commitment-proof", + "slot": { + "type": "position-slot", + "sequence": "https://beacon.example.org/sequence/1", + "position": 8700, + "sequence_value": "sha256:37a2114c5c5c113efd550e5e7f13734551f9f57c882446260e6252d35599a114", + "committer": "https://slots.enclave.example", + "committer_key_id": "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0", + "allocated_at": "2026-10-20T09:00:20Z", + "signature": "vg3yEZ3A6hPG3cVrcMeENnEENusvRPd-o2jrjwjH4DgVw_UhlDHYc7OOg5m410OG3fRfcB7C1a6_Q8MxOTsGBw" + }, + "binding": { + "type": "position-binding", + "slot_commitment": "sha256:58eee2aa583eba15bd577f7bf9ba58e0e6329b1fd18d88535540a760c087f5a9", + "record_digest": "sha256:18e195c487074f1534d38113b562bff1ff538f4e4926fd85d72688bbc3c7c624", + "committer_key_id": "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0", + "bound_at": "2026-10-20T09:05:02Z", + "signature": "GNNsX5RvfyhIdFEPYZCxwBDOnyygcplZVVDe7KAV9DvZAtAu11ETqldGBrGE9orMgaTCxGvWcANLvQmXZCIQAQ" + }, + "committer_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "3fbaE_Jek7CQEm8TCSZywsFrzwTY8aIgfXwOabciWbM" + } +} diff --git a/examples/position-commitment/proof-04.json b/examples/position-commitment/proof-04.json new file mode 100644 index 00000000..84803d84 --- /dev/null +++ b/examples/position-commitment/proof-04.json @@ -0,0 +1,26 @@ +{ + "type": "position-commitment-proof", + "slot": { + "type": "position-slot", + "sequence": "https://beacon.example.org/sequence/1", + "position": 8814, + "sequence_value": "sha256:37a2114c5c5c113efd550e5e7f13734551f9f57c882446260e6252d35599a114", + "committer": "https://slots.enclave.example", + "committer_key_id": "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0", + "allocated_at": "2026-10-20T09:00:20Z", + "signature": "vg3yEZ3A6hPG3cVrcMeENnEENusvRPd-o2jrjwjH4DgVw_UhlDHYc7OOg5m410OG3fRfcB7C1a6_Q8MxOTsGBw" + }, + "binding": { + "type": "position-binding", + "slot_commitment": "sha256:58eee2aa583eba15bd577f7bf9ba58e0e6329b1fd18d88535540a760c087f5a9", + "record_digest": "sha256:18e195c487074f1534d38113b562bff1ff538f4e4926fd85d72688bbc3c7c624", + "committer_key_id": "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0", + "bound_at": "2026-10-20T09:05:02Z", + "signature": "GNNsX5RvfyhIdFEPYZCxwBDOnyygcplZVVDe7KAV9DvZAtAu11ETqldGBrGE9orMgaTCxGvWcANLvQmXZCIQAQ" + }, + "committer_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "3fbaE_Jek7CQEm8TCSZywsFrzwTY8aIgfXwOabciWbM" + } +} diff --git a/examples/position-commitment/proof-05.json b/examples/position-commitment/proof-05.json new file mode 100644 index 00000000..ff089f49 --- /dev/null +++ b/examples/position-commitment/proof-05.json @@ -0,0 +1,26 @@ +{ + "type": "position-commitment-proof", + "slot": { + "type": "position-slot", + "sequence": "https://beacon.example.org/sequence/1", + "position": 8815, + "sequence_value": "sha256:19005d39bba5b2410fefc9eb68cebe70ab915233c40608c17691251470bd0051", + "committer": "https://slots.unknown.example", + "committer_key_id": "zgPUbLN-YTV7R-vWp7mYla8VjpgValaW_cF4R6X3mTg", + "allocated_at": "2026-10-20T09:00:30Z", + "signature": "zwOWtxz2TCWjdZk8_08YydGMQwMA0_4aAEULFxLjJty3Ipr7rPvMjemoodOR3OJo2q16taXDZIWaFTyO73VfAA" + }, + "binding": { + "type": "position-binding", + "slot_commitment": "sha256:128de392656b4b653cede1012e46c794db83c827e7b312f8194eda635e65654a", + "record_digest": "sha256:3320a3349052eef11bb7025c9d9ab652149c1746bc39b8167ecf836ae6226816", + "committer_key_id": "zgPUbLN-YTV7R-vWp7mYla8VjpgValaW_cF4R6X3mTg", + "bound_at": "2026-10-20T09:05:03Z", + "signature": "UWhQWO6O2CdufzdnIFxY0-pB25gwd8GtrrYt6CFGYyHJyTh6V_cAa_Omx3uBXjPoh9JcSHKY6zcNo_b_IQoDBg" + }, + "committer_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "D-5ime3kN0efQ2Z27nPFiecGttiAcbE86yH2sPUMVX0" + } +} diff --git a/examples/position-commitment/relying-party.json b/examples/position-commitment/relying-party.json new file mode 100644 index 00000000..872beeb5 --- /dev/null +++ b/examples/position-commitment/relying-party.json @@ -0,0 +1,20 @@ +{ + "note": "Supplied by the relying party, never read from a proof: the committer keys it trusts and its own copy of the ordered sequence.", + "committer_keys": { + "2pnANDR1rqwnBIQZzv2v7MTHnuhoFeCW0gMfcHvW8f0": { + "kty": "OKP", + "crv": "Ed25519", + "x": "3fbaE_Jek7CQEm8TCSZywsFrzwTY8aIgfXwOabciWbM" + } + }, + "sequence": { + "id": "https://beacon.example.org/sequence/1", + "positions": { + "8700": "sha256:4328011aab180ac501467048cdbb54940307db1c061d2c66288a4b2434c38836", + "8812": "sha256:adda5fb5b1273925865f3a8c1b983f8a5e51d0126942000e45864cfa5096d321", + "8813": "sha256:f0a856a9f0bf5d131dbd8565ee677b1c021d43e5141bfcc7a3090cc599ce8296", + "8814": "sha256:37a2114c5c5c113efd550e5e7f13734551f9f57c882446260e6252d35599a114", + "8815": "sha256:19005d39bba5b2410fefc9eb68cebe70ab915233c40608c17691251470bd0051" + } + } +} diff --git a/schema/trace-claim-v0.3-draft.json b/schema/trace-claim-v0.3-draft.json index 899f2455..cc119ef1 100644 --- a/schema/trace-claim-v0.3-draft.json +++ b/schema/trace-claim-v0.3-draft.json @@ -33,7 +33,7 @@ } }, "title": "TRACE Trust Record (v0.3 runtime-evidence draft)", - "description": "DRAFT. v0.3 profile boundary plus runtime.evidence and manifest. Binds nothing until docs/rfcs/runtime-evidence-profile.md and #483 are adopted. Outside the profile identifier, runtime.evidence, and manifest, members are byte-identical to schema/trace-claim.json so the diff is reviewable.", + "description": "DRAFT. v0.3 profile boundary plus runtime.evidence, manifest, and the references entry's validation member. Binds nothing until docs/rfcs/runtime-evidence-profile.md, #483 and #321 are adopted. Outside the profile identifier, runtime.evidence, manifest, and the references entry's validation member, required list and resolver condition, members are byte-identical to schema/trace-claim.json so the diff is reviewable.", "type": "object", "required": [ "eat_profile", @@ -350,14 +350,13 @@ "type": "object", "required": [ "rel", - "id", - "resolver" + "id" ], "properties": { "rel": { "type": "string", "minLength": 1, - "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system." + "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system. position-commitment: a commitment to a position in an externally ordered sequence, taken before the digest of this record exists; id is the commitment, validated by recomputation from a proof the holder carries and that commits this record, rather than by resolution. Under v0.2 the entry still names a resolver, so a producer that retains nothing omits it; under the v0.3 draft, an entry carrying validation recompute names no resolver." }, "id": { "type": "string", @@ -378,9 +377,36 @@ "type": "string", "pattern": "^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$", "description": "SHA-256 or SHA-384 digest of the referenced object, when the producer holds it at issue time." + }, + "validation": { + "type": "string", + "enum": [ + "recompute" + ], + "description": "DRAFT (#321). How id is checked. Absent: by resolution through resolver, as in v0.2. recompute: id is validated by recomputation from evidence the verifier holds, and that evidence must commit this record (spec section 3.1.2). No party is obliged to retain anything, so resolver and retention are absent. A verifier that cannot recompute because it lacks the evidence treats the entry as absent; a recomputation that runs and disagrees is a contradiction and is never reported as an absence." } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { + "if": { + "required": [ + "validation" + ] + }, + "then": { + "properties": { + "resolver": false, + "retention": false + } + }, + "else": { + "required": [ + "resolver" + ] + } + } + ] } }, "reproducibility": { diff --git a/schema/trace-claim.json b/schema/trace-claim.json index 0de908b2..910139ac 100644 --- a/schema/trace-claim.json +++ b/schema/trace-claim.json @@ -286,7 +286,7 @@ "rel": { "type": "string", "minLength": 1, - "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system." + "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system. position-commitment: a commitment to a position in an externally ordered sequence, taken before the digest of this record exists; id is the commitment, validated by recomputation from a proof the holder carries and that commits this record, rather than by resolution. Under v0.2 the entry still names a resolver, so a producer that retains nothing omits it; under the v0.3 draft, an entry carrying validation recompute names no resolver." }, "id": { "type": "string", diff --git a/scripts/gen_v03_draft_schema.py b/scripts/gen_v03_draft_schema.py index fd2df048..a280772b 100644 --- a/scripts/gen_v03_draft_schema.py +++ b/scripts/gen_v03_draft_schema.py @@ -10,8 +10,10 @@ The draft's own members, taken from the committed draft: ``$id``, ``title``, ``description``, ``properties.eat_profile``, -``properties.runtime.properties.evidence``, and ``properties.manifest``. -Everything else comes from canonical. +``properties.runtime.properties.evidence``, ``properties.manifest``, and, on a +``references`` entry, ``validation``, ``required`` and ``allOf`` (#321). +Everything else comes from canonical, so a relation registered in canonical +reaches the draft without a second edit. Usage: python scripts/gen_v03_draft_schema.py [--check] """ @@ -71,6 +73,16 @@ def build(canonical: dict[str, Any], draft: dict[str, Any]) -> dict[str, Any]: result["properties"] = _copy_member( result["properties"], draft["properties"], "manifest" ) + + # #321: an entry validated by recomputation names no resolver. The entry's own + # members stay canonical; only the new field and the resolver rule are the draft's. + entry = result["properties"]["references"]["items"] + draft_entry = draft["properties"]["references"]["items"] + entry["required"] = copy.deepcopy(draft_entry["required"]) + entry["properties"] = _copy_member( + entry["properties"], draft_entry["properties"], "validation" + ) + entry["allOf"] = copy.deepcopy(draft_entry["allOf"]) return result diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index f44a579d..dcbd8579 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -221,6 +221,7 @@ A `references` entry is a pointer, not evidence. What the signature attests is t | `resolver` | yes | Identifier of the party obliged to resolve `id`. | | `retention` | no | Period for which `resolver` undertakes to keep `id` resolvable, as an ISO 8601 duration. | | `digest` | no | Digest of the referenced object, when the producer holds it at issue time. | +| `validation` | no | **v0.3 draft only.** How `id` is checked. Absent means by resolution through `resolver`, the only mode this version defines. `recompute` is defined under "Entries validated by recomputation" below. | Registered `rel` values: @@ -230,6 +231,7 @@ Registered `rel` values: - **`condition-appraisal`**. An independent check's finding on whether a stated condition is established by a stated subject: a test run, a schema validation, a contract check. The referenced object binds the condition and the subject by digest and carries the outcome in the checker's own vocabulary. - **`observed-effect`**. A signed record of the state change an observer outside the agent saw over one interval: the state before and after, the paths observed, and the authority change ran under. - **`applicability-assessment`**. An assessor's statement of whether a stated legal provision applies to a stated party, held in another system: the party and its role in the instrument's own term, the jurisdiction, the provision and the date of the text read, the basis, and the outcome in a closed vocabulary the object names. +- **`position-commitment`**. A commitment to a position in an externally ordered sequence, taken before the digest of this record exists. `id` is the commitment to that position, validated by recomputation from a proof the holder carries and that commits this record, rather than by resolution. Under this version the entry earns nothing a pointer does not: recomputation is not a check this version defines, rule 3 governs, and `resolver` is still required, so a producer that retains nothing omits the entry. 1. `references` MUST NOT affect `runtime.platform`. A record carrying `references` and no `origin` block is `self` and carries whatever platform value it actually earned. 2. The record signature MUST cover `references`, under the canonicalisation in §3.2.2. @@ -244,7 +246,25 @@ Rule 3 is what makes the block safe to add. A reference that could invalidate a **`resolver` names who must retain, not who adjudicates.** The field is a retention undertaking: it identifies the party obliged to keep `id` resolvable, alongside `retention` as the period they undertake to keep it so. It is not a verification authority, and the two read as the same field until they are separated. The conformance suite refuses the opposite arrangement for a different field: TR-POL-003 takes its resolver from the caller and never derives it from the record, because a record that names its own checker can name one that agrees with it. Naming yourself as the party who must retain an artifact is ordinary and non-circular; naming yourself as the party who decides whether it is true is the circularity that rule refuses. An operator naming themselves as `resolver` for their own artifact is therefore within this section and would still be refused under TR-POL-003's rule, and both are correct. Rule 4 is aimed at a producer who can name no obliged party at all, not at one who is that party. -**Unsettled, and deliberately named rather than hidden.** `retention` states an undertaking and nothing in this specification enforces it. A reference is worth only the ability to resolve it later, and transparency-log practice shows that gap is real rather than theoretical: a record can remain valid and become unreachable when the index that addressed it is removed. §7 open question 3 covers the same ground for `transparency` and the two should be resolved together. +**Entries validated by recomputation (v0.3 draft).** + +Every rule above treats `id` as an address: something a party holds and a verifier fetches. When `id` is a digest the verifier recomputes from a proof the holder already carries, nothing is fetched, nothing is retained, and no party is obliged to do either. An entry marks that case with `validation: "recompute"`. The field is carried by `schema/trace-claim-v0.3-draft.json` and binds nothing until v0.3 is adopted. The v0.2 schema rejects an entry that carries it, which makes this a version gate rather than a patch: every existing record keeps validating, and a v0.2 verifier rejects a record that uses the field. + +It is a field and not a `rel` because how an entry is checked and what it relates to are separate questions. Putting the check into a relation's name would make the relation silently change the verification rule. Any relation, registered or not, may carry it. + +For an entry with `validation: "recompute"`: + +1. A verifier MUST validate `id` by recomputation from evidence it holds, never by resolution, and the recomputation MUST bind the entry to the record carrying it: the evidence MUST commit this record's digest. Establishing that the committed object exists is not enough. The evidence is public once presented, so any record could carry the same entry and pass a check that stops there. This record's digest is the one §3.1.3 defines for a parent record: SHA-256 (or SHA-384) of the RFC 8785 canonical form of the complete record as published, `signature` member included. +2. No party is obliged to retain anything, so rule 4 does not apply, and `resolver` and `retention` MUST be absent. On this entry they would state an undertaking nobody has made. +3. The two ways the check can end MUST be kept apart: + - The verifier cannot recompute because it does not hold the evidence. The entry is treated as absent. That is not a finding against the record, and rule 3 applies to it as it does to an entry that cannot be resolved. + - The recomputation runs and disagrees. That is a contradiction in the record, and a verifier MUST report it as one and MUST NOT report it as absent or unresolvable. + + This is the distinction [`docs/rfcs/a2a-delegation-profile.md`](../docs/rfcs/a2a-delegation-profile.md) §4.3 draws on the delegation surface, where evidence that resolves and contradicts fails and evidence that cannot be read downgrades. One sentence covering both outcomes is how a verifier ends up reporting tampering as absence. + +Rules 1 and 2 hold unchanged. A recomputation that agrees establishes the binding its relation defines and nothing more: it does not change `runtime.platform`, and the second half of rule 3 still holds, so the entry is not attested evidence of what the execution did. + +**Unsettled, and deliberately named rather than hidden.** `retention` states an undertaking and nothing in this specification enforces it. A reference checked by resolution is worth only the ability to resolve it later, and transparency-log practice shows that gap is real rather than theoretical: a record can remain valid and become unreachable when the index that addressed it is removed. §7 open question 3 covers the same ground for `transparency` and the two should be resolved together. An entry validated by recomputation, proposed above, sits outside the resolver half of this gap: no party undertakes to retain anything, so there is no undertaking to break. It does not escape the need for bytes. Recomputing later still needs the proof, so the holder who wants the entry checked keeps the proof bytes, and a verifier handed none treats the entry as absent. #### 3.1.3 `delegation.parent_record_hash`: what the digest covers diff --git a/src/agentrust_trace/schema/trace-v0.2.json b/src/agentrust_trace/schema/trace-v0.2.json index 0de908b2..910139ac 100644 --- a/src/agentrust_trace/schema/trace-v0.2.json +++ b/src/agentrust_trace/schema/trace-v0.2.json @@ -286,7 +286,7 @@ "rel": { "type": "string", "minLength": 1, - "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system." + "description": "Relationship type. The registered values are a registry that grows, so this is not a closed set. authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence. condition-appraisal: an independent check's finding on whether a stated condition is established by a stated subject, bound to both by digest. observed-effect: a signed record of the state change an observer outside the agent saw over one interval, with the state before and after, the paths observed and the authority change ran under. applicability-assessment: an assessor's statement of whether a stated legal provision applies to a stated party, held in another system. position-commitment: a commitment to a position in an externally ordered sequence, taken before the digest of this record exists; id is the commitment, validated by recomputation from a proof the holder carries and that commits this record, rather than by resolution. Under v0.2 the entry still names a resolver, so a producer that retains nothing omits it; under the v0.3 draft, an entry carrying validation recompute names no resolver." }, "id": { "type": "string", diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index b1276f9e..dc139cc1 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -327,6 +327,13 @@ def test_the_loader_reads_a_different_set_for_each_name() -> None: "recomputes every verdict from the committed record and " "assessment store instead of reading expected.json, and " "re-runs the generator", + # Not loadable here for the same reason: every record's signature verifies, and the + # cases differ only in the proof the holder presents and the trust the relying + # party supplies. + "position-commitment": "tests/test_position_commitment_fixtures.py, which recomputes " + "every verdict from the committed records, proofs and " + "relying-party trust instead of reading expected.json, and " + "re-runs the generator", } diff --git a/tests/test_position_commitment_fixtures.py b/tests/test_position_commitment_fixtures.py new file mode 100644 index 00000000..c3e90cb4 --- /dev/null +++ b/tests/test_position_commitment_fixtures.py @@ -0,0 +1,301 @@ +"""A position in an externally ordered sequence as a `position-commitment` entry +validated by recomputation (spec section 3.1.2, v0.3 draft, #321). + +Re-verifies examples/position-commitment/ from the committed bytes. The generator wrote +expected.json alongside the vectors, and a generator's own summary of its output is a +claim, so every outcome here is recomputed with this repository's dependencies and +compared with expected.json, never read from it. The generator is deterministic, and +the last test re-runs it against the committed files. + +The records use `validation: "recompute"`, which the v0.2 schema refuses, so +`verify_record()` cannot check them: each is validated against the v0.3 draft schema +and its signature checked with the SDK's own canonicalization, as +tests/test_runtime_evidence_vectors.py does for the other v0.3 draft set. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import jsonschema +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from agentrust_trace.sign import _canonical_bytes + +REPO = Path(__file__).resolve().parents[1] +DIR = REPO / "examples" / "position-commitment" +GENERATOR = DIR / "gen_position_commitment_vectors.py" +EXPECTED = json.loads((DIR / "expected.json").read_text(encoding="utf-8")) +RELYING_PARTY = json.loads((DIR / "relying-party.json").read_text(encoding="utf-8")) +DRAFT = json.loads((REPO / "schema" / "trace-claim-v0.3-draft.json").read_text(encoding="utf-8")) +V02 = json.loads((REPO / "schema" / "trace-claim.json").read_text(encoding="utf-8")) +CASES = sorted(EXPECTED["cases"]) +OUTCOME_KEYS = ( + "proof_held", + "commitment_recomputes", + "binds_this_record", + "committer_key_independent", + "signatures_verify", + "sequence_position_matches", + "verdict", +) + + +def _load(name: str) -> dict: + return json.loads((DIR / name).read_text(encoding="utf-8")) + + +def _jcs_sha256(obj: object) -> str: + return "sha256:" + hashlib.sha256(rfc8785.dumps(obj)).hexdigest() + + +def _b64u_decode(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _thumbprint(jwk: dict) -> str: + required = {k: jwk[k] for k in ("crv", "kty", "x")} + raw = json.dumps(required, separators=(",", ":"), sort_keys=True).encode() + return base64.urlsafe_b64encode(hashlib.sha256(raw).digest()).rstrip(b"=").decode() + + +def _signed_by(obj: dict, jwk: dict) -> bool: + body = {k: v for k, v in obj.items() if k != "signature"} + try: + Ed25519PublicKey.from_public_bytes(_b64u_decode(jwk["x"])).verify( + _b64u_decode(obj["signature"]), rfc8785.dumps(body) + ) + except InvalidSignature: + return False + return True + + +def _record_verifies(record: dict) -> None: + """The v0.3 draft envelope: schema-valid, and signed under the key the relying party + holds for the producer, not the one the record embeds.""" + jsonschema.validate(record, DRAFT) + jwk = EXPECTED["trace_signer_jwk"] + assert record["cnf"]["jwk"] == jwk + body = _canonical_bytes({k: v for k, v in record.items() if k != "signature"}) + Ed25519PublicKey.from_public_bytes(_b64u_decode(jwk["x"])).verify( + _b64u_decode(record["signature"]), body + ) + + +def _recompute(record: dict, proof: dict | None, relying_party: dict) -> dict: + """What a relying party concludes from a `position-commitment` entry. + + Section 3.1.2, "Entries validated by recomputation": not holding the proof makes the + entry absent, which is no finding against the record; a recomputation that runs and + disagrees is a contradiction and is never reported as an absence. Agreement under a + committer key the proof supplies for itself is consistency only: ordering is + established only under a key and a sequence the relying party holds independently. + """ + (entry,) = record["references"] + assert entry["rel"] == "position-commitment" + assert entry["validation"] == "recompute" + assert "resolver" not in entry and "retention" not in entry + result = dict.fromkeys(OUTCOME_KEYS) + if proof is None: + return {**result, "proof_held": False, "verdict": "position-absent"} + result["proof_held"] = True + slot, binding = proof["slot"], proof["binding"] + + result["commitment_recomputes"] = _jcs_sha256(slot) == entry["id"] + if not result["commitment_recomputes"]: + return {**result, "verdict": "contradiction-commitment-mismatch"} + + # Section 3.1.3's preimage: the complete record as published, signature included. + result["binds_this_record"] = ( + binding["slot_commitment"] == entry["id"] + and binding["record_digest"] == _jcs_sha256(record) + ) + if not result["binds_this_record"]: + return {**result, "verdict": "contradiction-commits-another-record"} + + kid = slot["committer_key_id"] + independent = relying_party["committer_keys"].get(kid) + result["committer_key_independent"] = independent is not None + jwk = independent if independent is not None else proof["committer_jwk"] + if _thumbprint(jwk) != kid or binding["committer_key_id"] != kid: + return {**result, "signatures_verify": False, "verdict": "contradiction-signature-invalid"} + result["signatures_verify"] = _signed_by(slot, jwk) and _signed_by(binding, jwk) + if not result["signatures_verify"]: + return {**result, "verdict": "contradiction-signature-invalid"} + + sequence = relying_party["sequence"] + result["sequence_position_matches"] = ( + slot["sequence"] == sequence["id"] + and sequence["positions"].get(str(slot["position"])) == slot["sequence_value"] + ) + if not result["committer_key_independent"]: + return {**result, "verdict": "position-consistent-not-established"} + if not result["sequence_position_matches"]: + return {**result, "verdict": "contradiction-sequence-mismatch"} + return {**result, "verdict": "position-established"} + + +def _case(name: str) -> dict: + case = EXPECTED["cases"][name] + proof = _load(case["proof"]) if case["proof"] else None + return _recompute(_load(name), proof, RELYING_PARTY) + + +def test_the_committed_records_are_exactly_the_declared_cases() -> None: + assert {p.name for p in DIR.glob("0*.json")} == set(CASES) + for case in EXPECTED["cases"].values(): + assert case["proof"] is None or (DIR / case["proof"]).is_file() + + +@pytest.mark.parametrize("name", CASES) +def test_every_case_verifies_as_a_v03_draft_trust_record(name: str) -> None: + # What the entry recomputes to never decides whether the signature verifies: the + # contradiction cases are signed records too, and the contradiction is reported. + _record_verifies(_load(name)) + assert EXPECTED["cases"][name]["record_signature_verifies"] is True + + +@pytest.mark.parametrize("name", CASES) +def test_v02_refuses_every_record_in_the_set(name: str) -> None: + """The version gate: recompute entries are a v0.3 draft shape only.""" + record = _load(name) + record["eat_profile"] = V02["properties"]["eat_profile"]["const"] + assert list(jsonschema.Draft202012Validator(V02).iter_errors(record)) + + +@pytest.mark.parametrize("name", CASES) +def test_the_outcome_is_recomputed_from_the_committed_bytes(name: str) -> None: + case = EXPECTED["cases"][name] + assert _case(name) == {k: case[k] for k in OUTCOME_KEYS} + + +def test_absence_and_contradiction_are_different_answers() -> None: + """Section 3.1.2 rule 3 of the recompute entries: no proof is absent, a proof that + disagrees is a contradiction, and neither is reported as the other.""" + assert _case("03-proof-not-held.json")["verdict"] == "position-absent" + for name in ("02-same-proof-other-record.json", "04-proof-altered.json"): + verdict = _case(name)["verdict"] + assert verdict.startswith("contradiction-"), name + assert _case(name)["proof_held"] is True + + +def test_the_same_proof_on_another_record_fails_only_on_the_binding() -> None: + """`02` carries `01`'s entry byte for byte and is handed `01`'s proof. The commitment + recomputes, so a check that stopped at the slot would pass it; only the record + digest the binding commits tells the two records apart.""" + first, second = _load("01-position-established.json"), _load("02-same-proof-other-record.json") + assert first["references"] == second["references"] + assert _jcs_sha256(first) != _jcs_sha256(second) + proof = _load("proof-01.json") + assert proof["binding"]["record_digest"] == _jcs_sha256(first) + observed = _recompute(second, proof, RELYING_PARTY) + assert observed["commitment_recomputes"] is True + assert observed["binds_this_record"] is False + + +def test_the_altered_proof_differs_in_the_position_only() -> None: + original, altered = _load("proof-04.json"), _load("proof-04-altered.json") + assert original["binding"] == altered["binding"] + changed = [k for k in original["slot"] if original["slot"][k] != altered["slot"][k]] + assert changed == ["position"] + assert altered["slot"]["position"] < original["slot"]["position"] + # The unaltered proof establishes the position, so the alteration is the whole cause. + record = _load("04-proof-altered.json") + assert _recompute(record, original, RELYING_PARTY)["verdict"] == "position-established" + + +def test_a_self_supplied_key_is_consistency_not_ordering() -> None: + """`05` checks out on every digest and both signatures under the key its proof + carries. Handing the relying party that key would make it `position-established`, + which is the step a verifier must not take on the proof's word.""" + record, proof = _load("05-committer-key-self-supplied.json"), _load("proof-05.json") + assert proof["slot"]["committer_key_id"] not in RELYING_PARTY["committer_keys"] + trusting = copy.deepcopy(RELYING_PARTY) + trusting["committer_keys"][proof["slot"]["committer_key_id"]] = proof["committer_jwk"] + assert _recompute(record, proof, trusting)["verdict"] == "position-established" + assert _recompute(record, proof, RELYING_PARTY)["verdict"] == ( + "position-consistent-not-established" + ) + + +def test_a_forged_slot_under_the_trusted_key_id_is_a_contradiction() -> None: + """A proof that names the trusted committer's key id but carries its own key: the + relying party checks against the key it holds, so the forgery does not verify.""" + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from agentrust_trace import key_to_jwk + + forger = Ed25519PrivateKey.from_private_bytes(b"\x01" * 32) + kid = next(iter(RELYING_PARTY["committer_keys"])) + proof = copy.deepcopy(_load("proof-01.json")) + body = {k: v for k, v in proof["slot"].items() if k != "signature"} + body["committer_key_id"] = kid + proof["slot"] = { + **body, + "signature": base64.urlsafe_b64encode(forger.sign(rfc8785.dumps(body))) + .rstrip(b"=") + .decode(), + } + proof["committer_jwk"] = key_to_jwk(forger) + record = copy.deepcopy(_load("01-position-established.json")) + # Only the entry is under test here, so the record is not re-signed. + record["references"][0]["id"] = _jcs_sha256(proof["slot"]) + proof["binding"]["slot_commitment"] = record["references"][0]["id"] + proof["binding"]["record_digest"] = _jcs_sha256(record) + observed = _recompute(record, proof, RELYING_PARTY) + assert observed["committer_key_independent"] is True + assert observed["signatures_verify"] is False + assert observed["verdict"] == "contradiction-signature-invalid" + + +def test_a_slot_off_the_relying_partys_sequence_is_a_contradiction() -> None: + """Under a trusted key, a slot whose sequence value is not the one the relying + party's own copy holds at that position contradicts the sequence.""" + relying_party = copy.deepcopy(RELYING_PARTY) + relying_party["sequence"]["positions"]["8812"] = "sha256:" + "0" * 64 + observed = _recompute( + _load("01-position-established.json"), _load("proof-01.json"), relying_party + ) + assert observed["sequence_position_matches"] is False + assert observed["verdict"] == "contradiction-sequence-mismatch" + + +def test_the_record_signature_covers_the_entry() -> None: + record = copy.deepcopy(_load("01-position-established.json")) + commitment = record["references"][0]["id"] + record["references"][0]["id"] = commitment[:-1] + ("0" if commitment[-1] != "0" else "1") + with pytest.raises(InvalidSignature): + _record_verifies(record) + + +def test_the_relying_party_supplies_its_own_trust() -> None: + """The keys and the sequence come from relying-party.json, not from any proof.""" + for kid, jwk in RELYING_PARTY["committer_keys"].items(): + assert kid == _thumbprint(jwk) + proofs = [_load(p.name) for p in DIR.glob("proof-*.json")] + named = {p["slot"]["committer_key_id"] for p in proofs} + assert named - set(RELYING_PARTY["committer_keys"]), "no case exercises an untrusted key" + for p in proofs: + assert _thumbprint(p["committer_jwk"]) == p["slot"]["committer_key_id"] + + +def test_the_generator_reproduces_the_committed_bytes(tmp_path: Path) -> None: + subprocess.run( + [sys.executable, str(GENERATOR), "--out", str(tmp_path)], + check=True, + capture_output=True, + ) + produced = {p.name for p in tmp_path.iterdir()} + committed = {p.name for p in DIR.iterdir() if p.suffix == ".json"} + assert produced == committed + for name in committed: + assert (tmp_path / name).read_bytes() == (DIR / name).read_bytes(), name diff --git a/tests/test_references_block.py b/tests/test_references_block.py index e5ceecf9..b714d41a 100644 --- a/tests/test_references_block.py +++ b/tests/test_references_block.py @@ -83,6 +83,7 @@ def _with(references) -> dict: ("rel condition-appraisal", [_entry(rel="condition-appraisal")], True), ("rel observed-effect", [_entry(rel="observed-effect")], True), ("rel applicability-assessment", [_entry(rel="applicability-assessment")], True), + ("rel position-commitment", [_entry(rel="position-commitment")], True), ("sha384 digest", [_entry(digest="sha384:" + "b" * 96)], True), # rel is a registry rather than a closed set (spec 3.1.2, unlike 3.1.1 on kind), # so a value this schema has never heard of is accepted rather than rejected. @@ -102,6 +103,12 @@ def _with(references) -> dict: ("malformed digest", [_entry(digest="sha256:zz")], False), ("uppercase digest hex", [_entry(digest="sha256:" + "A" * 64)], False), ("unknown member in entry", [_entry(note="human comment")], False), + # `validation` is the v0.3 draft's member (#321). Under v0.2 it is an unknown + # member, which is what makes the draft a version gate: a v0.2 artifact rejects + # a record that uses it, with or without a resolver beside it. + ("v0.3 validation member", [_entry(validation="recompute")], False), + ("v0.3 recompute entry without resolver", + [{"rel": "position-commitment", "id": "x", "validation": "recompute"}], False), # Rule 4 tells a producer to omit the entry, not the block, and nothing in 3.1.2 # says the array must be non-empty. The schema does not get to assert what the # normative text does not, which is this PR's own argument pointed the other way. @@ -275,7 +282,7 @@ def test_the_registered_rel_values_stay_documented_in_all_three_places() -> None registered = ( "authorized-intent", "approval-outcome", "behavior-trace", "condition-appraisal", - "observed-effect", "applicability-assessment", + "observed-effect", "applicability-assessment", "position-commitment", ) rel = SCHEMA["properties"]["references"]["items"]["properties"]["rel"] assert "enum" not in rel, ( diff --git a/tests/test_references_recompute_v03_draft.py b/tests/test_references_recompute_v03_draft.py new file mode 100644 index 00000000..b52a0e15 --- /dev/null +++ b/tests/test_references_recompute_v03_draft.py @@ -0,0 +1,103 @@ +"""`references` entries validated by recomputation, in the v0.3 draft schema (#321). + +Spec section 3.1.2, "Entries validated by recomputation". An entry carrying +`validation: "recompute"` has no obliged party, so `resolver` and `retention` must be +absent; an entry without it is checked by resolution and still needs a `resolver`. +The v0.2 schema rejects the member outright, which `test_references_block.py` checks +from its side. Both schemas are run over the same table here so the version gate is +visible in one place: what the draft accepts for the new field, v0.2 refuses. + +What the schema cannot express are the rules that bind a verifier: that the +recomputation must bind the entry to this record, and that "could not recompute" and +"recomputed and disagreed" are different outcomes. The reference library does not +read `references[]` at all (`sign.py`, section 3.1.2 rule 3), so those stay +conformance-suite rules, as rules 3 and 4 already are. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import jsonschema +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +V03_PROFILE = "tag:agentrust-io.com,2026:trace-v0.3" + +RECOMPUTE = {"rel": "position-commitment", "id": "sha256:" + "c" * 64, "validation": "recompute"} +RESOLVED = {"rel": "behavior-trace", "id": "trace-7", "resolver": "https://trace.example.org"} + + +def _schema(name: str) -> dict: + return json.loads((REPO_ROOT / "schema" / name).read_text(encoding="utf-8")) + + +def _record(references: list, *, v03: bool) -> dict: + record = json.loads((REPO_ROOT / "examples" / "amd-sev-snp.json").read_text(encoding="utf-8")) + record["references"] = copy.deepcopy(references) + if v03: + record["eat_profile"] = V03_PROFILE + return record + + +# (label, references, accepted by the v0.3 draft) +CASES: list[tuple[str, list, bool]] = [ + ("recompute entry, no resolver", [RECOMPUTE], True), + ("recompute entry with a digest", [{**RECOMPUTE, "digest": "sha256:" + "d" * 64}], True), + ("recompute entry beside a resolved one", [RECOMPUTE, RESOLVED], True), + ("recompute on an unregistered rel", [{**RECOMPUTE, "rel": "slot-claim"}], True), + ("resolved entry, unchanged from v0.2", [RESOLVED], True), + # Rule 2 of the proposal: no obliged party, so naming one states an undertaking + # nobody made. + ("recompute entry naming a resolver", [{**RECOMPUTE, "resolver": "https://x.example"}], False), + ("recompute entry with retention", [{**RECOMPUTE, "retention": "P30D"}], False), + # Absent means resolution, so rule 4 still holds for an entry without the field. + ("no validation and no resolver", [{"rel": "behavior-trace", "id": "trace-7"}], False), + ("unknown validation value", [{**RECOMPUTE, "validation": "resolve"}], False), + ("empty validation value", [{**RECOMPUTE, "validation": ""}], False), + ( + "recompute entry without id", + [{"rel": "position-commitment", "validation": "recompute"}], + False, + ), +] + + +@pytest.mark.parametrize( + ("references", "accepted"), [(r, a) for _, r, a in CASES], ids=[label for label, _, _ in CASES] +) +def test_the_v03_draft_schema(references: list, accepted: bool) -> None: + validator = jsonschema.Draft202012Validator(_schema("trace-claim-v0.3-draft.json")) + errors = list(validator.iter_errors(_record(references, v03=True))) + assert (not errors) is accepted, [e.message for e in errors] + + +@pytest.mark.parametrize( + "references", + [r for _, r, a in CASES if a and "validation" in json.dumps(r)], + ids=[label for label, r, a in CASES if a and "validation" in json.dumps(r)], +) +def test_v02_rejects_every_shape_the_draft_newly_accepts(references: list) -> None: + """The version gate, from the draft's side: no accepted recompute shape is v0.2-valid.""" + validator = jsonschema.Draft202012Validator(_schema("trace-claim.json")) + assert list(validator.iter_errors(_record(references, v03=False))) + + +def test_a_resolved_entry_is_accepted_by_both() -> None: + """Existing records keep validating: the draft does not narrow what v0.2 accepts.""" + for name, v03 in (("trace-claim.json", False), ("trace-claim-v0.3-draft.json", True)): + validator = jsonschema.Draft202012Validator(_schema(name)) + assert not list(validator.iter_errors(_record([RESOLVED], v03=v03))), name + + +def test_v02_rejects_the_member_itself_not_only_the_missing_resolver() -> None: + """Every recompute shape also lacks a resolver, which v0.2 requires, so the test + above would pass even if v0.2 accepted `validation`. This entry keeps its + resolver, so the member alone is what v0.2 refuses.""" + validator = jsonschema.Draft202012Validator(_schema("trace-claim.json")) + errors = list( + validator.iter_errors(_record([{**RESOLVED, "validation": "recompute"}], v03=False)) + ) + assert any("validation" in e.message for e in errors), [e.message for e in errors] diff --git a/tests/test_v03_draft_schema.py b/tests/test_v03_draft_schema.py index ae4941c4..a9ac8560 100644 --- a/tests/test_v03_draft_schema.py +++ b/tests/test_v03_draft_schema.py @@ -53,6 +53,10 @@ def test_draft_differs_from_canonical_only_in_its_declared_members(): stripped["properties"]["eat_profile"] = canonical["properties"]["eat_profile"] del stripped["properties"]["runtime"]["properties"]["evidence"] del stripped["properties"]["manifest"] + entry = stripped["properties"]["references"]["items"] + del entry["properties"]["validation"] + del entry["allOf"] + entry["required"] = canonical["properties"]["references"]["items"]["required"] assert stripped == canonical