diff --git a/docs/crosswalks/openproof-approvals.md b/docs/crosswalks/openproof-approvals.md new file mode 100644 index 00000000..1965a24c --- /dev/null +++ b/docs/crosswalks/openproof-approvals.md @@ -0,0 +1,83 @@ +# OpenProof Approvals Cross-walk + +OpenProof records what a document owner approved for an AI agent to change, and the receipt its receiver signs when the change is applied. This page shows how an OpenProof approval links to the TRACE record of the agent run that acted under it, so a reader can find the approval, check who signed it, and check what was executed under it. Neither specification changes. + +> **Non-normative.** This document is informative only. Nothing here changes TRACE v0.2 schema fields, wire formats, required claims, or conformance requirements. "TRACE" means the Trust Record defined in [`spec/trace-v0.2.md`](../../spec/trace-v0.2.md). "OpenProof" means [antagoninc/openproof](https://github.com/antagoninc/openproof) at commit `d85c24a`, version 0.9.0 of its `openproof-witness` binary. + +--- + +## Purpose + +An agent that changes a document under an owner's approval produces two records. OpenProof holds the approval and what the receiver did with it. TRACE holds what ran, where, and under which policy. A relying party reading the Trust Record needs to find the approval and check it. + +TRACE already has the slot. The `references` block (spec section 3.1.2) registers `rel: "approval-outcome"` for "an attributable human approval attached to a step-up or defer decision". This cross-walk shows an OpenProof approval filling it, as [`chap-review-decisions.md`](chap-review-decisions.md) does for a CHAP decision. + +## What an OpenProof approval is + +OpenProof has four roles: an owner who approves, an authority key that signs approvals, a receiver that applies changes, and a witness that keeps an ordered history of the receiver's receipts. A root key signs a *trust* object that names the authority, receiver and witness keys and, when one is set up, the owner's passkey. + +- **Approval.** A signed object that names the document, the expected current version, the SHA-256 of the exact new content, the receiver, a validity window and a nonce. The authority key signs it with Ed25519. +- **Request.** What the receiver is given: the signed approval, the exact content, and, when the trust names a passkey, the owner's WebAuthn assertion over the approval digest. +- **Receipt.** What the receiver signs when it applies a change. It names the approval digest, the document, the new version, the content hash and its own sequence number. +- **Checkpoint.** What the witness signs over the receiver's history: the receiver, the count of receipts and the head hash. + +OpenProof signs every object with Ed25519 over its own canonical binary form, OPEC-1, behind a domain-separation prefix such as `OpenProof/v1/document-approval/1`. The *approval digest* is the SHA-256 of those signing bytes. + +## Field mapping + +| TRACE `references` field | Value for an OpenProof approval | Notes | +|---|---|---| +| `rel` | `approval-outcome` | Registered value. Do not use an OpenProof-specific `rel`; the producer belongs in `resolver`. | +| `id` | `openproof/approval/` | Derived from the approval's own signing bytes. Step 4 below rejects an entry filed under the id of a different approval. | +| `resolver` | the owner's evidence store, as a URI | The party obliged to keep the entry resolvable. | +| `retention` | the store's retention undertaking | Optional. Nothing in either specification enforces it. | +| `digest` | `sha256:` over the RFC 8785 form of the request | The request is the signed approval, the exact content and the owner's assertion, so the digest covers all three. | + +OpenProof itself defines the approval, request, receipt, checkpoint and trust. How a resolver stores them for lookup by `id` is not defined by OpenProof: the fixtures use one JSON object per store, holding the signed trust and, per `id`, the request, the receipt and the checkpoint. Treat that layout as this cross-walk's, not as an OpenProof format. + +## Canonicalization + +Two canonical forms are in play, and they do different jobs. OPEC-1 is what OpenProof signs, and the `id` is derived from it. RFC 8785 is what the TRACE `digest` covers, computed over the request as stored. The producer and the fixture test both compute it with the `rfc8785` package, so these fixtures measure no agreement between two RFC 8785 implementations, unlike the CHAP fixtures. Every number in the requests here is a non-negative integer below 2^53, and every string is ASCII; the fixture test checks the numbers. A producer whose approved content is non-ASCII text depends on its RFC 8785 implementation agreeing with the verifier's on that text. + +## Verifier obligations for this profile + +Given a Trust Record, access to the store named in `resolver`, and an OpenProof root key obtained separately from that store: + +1. Verify the Trust Record as usual. This step does not touch the reference, and an unresolvable reference is not a reason to reject the record (section 3.1.2, rule 3). +2. Resolve `id` to an entry. If there is none, report the approval as unconfirmed. +3. Recompute the RFC 8785 SHA-256 of the entry's request and compare it with `digest`. A mismatch means the store no longer holds what the record pointed at. +4. Verify the trust under the root key. Then check that `id` is the approval digest, that the approval is signed by an authority the trust names and has not revoked, that the content hashes to the approved `content_sha256`, that the approval names the trust's receiver, and, when the trust names a passkey, that the assertion is a user-verified ES256 signature over the approval digest for that passkey's relying party and origin. +5. If the entry has a receipt, verify it under the trust's receiver key and check that it names this approval, this document, the next version and the same content hash. No receipt means approved but not executed. +6. Verify the checkpoint under the trust's witness key and check that it names the same receiver and that its count reaches the receipt's sequence number. + +Steps 2, 3 and 4 each have a committed fixture that fails exactly that step: `04`, `02` and `05`. Case `03` takes the no-receipt branch of step 5. The verdicts for a trust that the root did not sign, a receipt that contradicts the approval, and a missing checkpoint are reached in the fixture test by changing a committed store, not by a committed fixture. + +OpenProof requires a passkey assertion only for changes at or after the passkey's `from_sequence`. In these fixtures that is 1, so every approval carries one. Step 4 as written requires an assertion whenever the trust names a passkey, which is stricter for a store with approvals from before the passkey was enrolled. + +## Boundary + +Composed this way, a Trust Record and an OpenProof store establish that the record points at a specific request, that the request is unchanged, that a key the owner's root trusts signed the approval, that the owner's passkey signed its digest, and, when a receipt is present, that the receiver signed for executing that approval with that content. They do not establish: + +- **That the approval was checked before the action ran.** A Trust Record is issued per execution, so it cannot carry a commitment someone needs to check beforehand (section 3.1.2). In OpenProof the receiver is the component that enforces the approval: it applies a change only under a matching approval and signs a receipt that names it. The record then points back at the approval the run acted under. +- **That nothing else executed.** A receipt shows the receiver signed for this execution. That the receiver applies a change only for the exact content, version and receiver approved, once, before the approval expires, is OpenProof's guarantee G1, which holds under conditions its README states: a passkey on a key no host process can read, and a receiver under its own account. The fixtures meet neither condition (see the last bullet), and they do not exercise the refusal side. Case `03` is a second approval of the same version that the receiver refused because the first had executed, and all the store shows of it is the absence of a receipt. +- **That the receipt is in the witness's history.** Step 6 checks that a signed checkpoint covers the receipt's sequence number. It is not an inclusion proof. Detecting omission, reordering or a fork is OpenProof's separate Go verifier, run over an exported history. +- **The validity window, the trust's expiry, or that the nonce was used once.** Steps 4 and 5 do not compare times or replay the history. +- **Who the person is.** The passkey assertion shows the holder of an enrolled authenticator approved with user verification. Binding that authenticator to a named person is outside both specifications. +- **Independence of the roles.** The fixtures were produced with the receiver and witness on one computer under one host account, and the passkey is a software P-256 key standing in for a platform authenticator. + +A resolved approval is not attested evidence in TRACE's sense, and it does not change whether the Trust Record verifies (section 3.1.2, rule 3). + +## Conformance fixtures + +Five records and two stores in [`examples/openproof-approval-outcome/`](https://github.com/agentrust-io/trace-spec/tree/main/examples/openproof-approval-outcome/): an approval executed and witnessed, the same approval altered in the store after the record was issued, an approval that was never executed, an unresolvable reference, and an approval signed by a key the owner's trust does not name. The approvals, receipt, checkpoint and trust come from the OpenProof binary, all but case `05`'s approval from one OpenProof home; the generator makes the Trust Records, the passkey assertions, the store layout and the altered store. [`tests/test_openproof_approval_outcome_fixtures.py`](https://github.com/agentrust-io/trace-spec/blob/main/tests/test_openproof_approval_outcome_fixtures.py) re-verifies them in CI with this repository's own dependencies, including OpenProof's Ed25519 signatures over OPEC-1 and the passkey assertions of cases `01` and `03`. The generator is [`integrations/trace/generate_examples.py`](https://github.com/antagoninc/openproof/blob/d85c24a1ec9228de7c4876904f260a0093312b70/integrations/trace/generate_examples.py) in the OpenProof repository, which needs the OpenProof binary and is not run here. + +## References + +- TRACE v0.2 specification, section 3.1.2 (`references`): [`spec/trace-v0.2.md`](../../spec/trace-v0.2.md) +- OpenProof repository: +- OpenProof guarantees G1 to G3 and their conditions: +- OPEC-1 encoding, in OpenProof's Python reference: +- Signing domains `document-approval/1`, `receiver-receipt/1` and `witness-checkpoint/1`: ; `witness-trust/1`: +- Owner passkey and `from_sequence`: +- Web Authentication Level 2, assertion signature: +- RFC 8785, JSON Canonicalization Scheme: diff --git a/docs/references-registry.md b/docs/references-registry.md index 4abb3428..9c0bea84 100644 --- a/docs/references-registry.md +++ b/docs/references-registry.md @@ -44,7 +44,7 @@ The six labels agreed so far, what each one points at, and where it is defined: | `rel` | Referenced object | Defined in | Example | |---|---|---|---| | `authorized-intent` | An authorization decided before execution, held in another system | §3.1.2; one mapping in [`crosswalks/nkemba-institutional-reconstruction.md`](crosswalks/nkemba-institutional-reconstruction.md) | none committed | -| `approval-outcome` | An attributable human approval attached to a step-up or defer decision | §3.1.2; the CHAP mapping in [`crosswalks/chap-review-decisions.md`](crosswalks/chap-review-decisions.md) | [`examples/chap-approval-outcome/`](../examples/chap-approval-outcome/) | +| `approval-outcome` | An attributable human approval attached to a step-up or defer decision | §3.1.2; the CHAP mapping in [`crosswalks/chap-review-decisions.md`](crosswalks/chap-review-decisions.md), the OpenProof mapping in [`crosswalks/openproof-approvals.md`](crosswalks/openproof-approvals.md) | [`examples/chap-approval-outcome/`](../examples/chap-approval-outcome/), [`examples/openproof-approval-outcome/`](../examples/openproof-approval-outcome/) | | `behavior-trace` | A behavioural record of what the agent did, of which this record is the environment evidence | §3.1.2 | none committed | | `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/) | diff --git a/examples/README.md b/examples/README.md index 4586f085..5629220e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -75,6 +75,12 @@ 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`. +- `openproof-approval-outcome/`: five signed Trust Records, each citing an OpenProof + approval through a `references` entry with `rel: "approval-outcome"`, against two + OpenProof stores committed beside them: executed and witnessed, altered after issue, + approved but not executed, unresolvable, and signed by a key outside the owner's + trust. Every digest recomputes; see that directory's README and + `docs/crosswalks/openproof-approvals.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/openproof-approval-outcome/01-approval-executed.json b/examples/openproof-approval-outcome/01-approval-executed.json new file mode 100644 index 00000000..0afb3b52 --- /dev/null +++ b/examples/openproof-approval-outcome/01-approval-executed.json @@ -0,0 +1,42 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1794187732, + "subject": "spiffe://trust.example.org/agent/release-writer", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "references": [ + { + "rel": "approval-outcome", + "id": "openproof/approval/fbba245132a7b9c5ac4d51fc0cafd244f034765f8c60378b75242ad601b803e9", + "resolver": "https://openproof.example.org/owners/release-desk", + "digest": "sha256:49ceffcf90b75ba4841c0ed96a5dd059f242cc1bb357b20dd21d609aa130f801" + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_D-Cxu524uw5iMn5AzSQkr9C-gC4_Eejy7IMJuQxccE" + } + }, + "signature": "mpbG6sBaQZhuo7s42xhNfjEezLoKMZ4xWufinOwXIyBVrf4o-5KwhQCQ9819n6ttDsaRwMyh11vOBl2xeEvBCQ" +} diff --git a/examples/openproof-approval-outcome/02-approval-altered-after-issue.json b/examples/openproof-approval-outcome/02-approval-altered-after-issue.json new file mode 100644 index 00000000..0afb3b52 --- /dev/null +++ b/examples/openproof-approval-outcome/02-approval-altered-after-issue.json @@ -0,0 +1,42 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1794187732, + "subject": "spiffe://trust.example.org/agent/release-writer", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "references": [ + { + "rel": "approval-outcome", + "id": "openproof/approval/fbba245132a7b9c5ac4d51fc0cafd244f034765f8c60378b75242ad601b803e9", + "resolver": "https://openproof.example.org/owners/release-desk", + "digest": "sha256:49ceffcf90b75ba4841c0ed96a5dd059f242cc1bb357b20dd21d609aa130f801" + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_D-Cxu524uw5iMn5AzSQkr9C-gC4_Eejy7IMJuQxccE" + } + }, + "signature": "mpbG6sBaQZhuo7s42xhNfjEezLoKMZ4xWufinOwXIyBVrf4o-5KwhQCQ9819n6ttDsaRwMyh11vOBl2xeEvBCQ" +} diff --git a/examples/openproof-approval-outcome/03-approved-not-executed.json b/examples/openproof-approval-outcome/03-approved-not-executed.json new file mode 100644 index 00000000..831ab278 --- /dev/null +++ b/examples/openproof-approval-outcome/03-approved-not-executed.json @@ -0,0 +1,42 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1794187732, + "subject": "spiffe://trust.example.org/agent/release-writer", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "references": [ + { + "rel": "approval-outcome", + "id": "openproof/approval/133b69a4506a50e8d44694ce3d9641bdc198bc2d1c4d54e5d464afaee56736d7", + "resolver": "https://openproof.example.org/owners/release-desk", + "digest": "sha256:6e14d08f03a9585f87acac3f4feb4993a9dcd070b3a2bbd75ebe06f72547e6ac" + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_D-Cxu524uw5iMn5AzSQkr9C-gC4_Eejy7IMJuQxccE" + } + }, + "signature": "wKtrNfOes0hvXd0sdXJoWFQlHnEcgj-NaJzqeXfxKSGGMek6cnThSXyDCFyEuzZRsCflPDCWeeCGouWvTdLQCg" +} diff --git a/examples/openproof-approval-outcome/04-reference-unresolvable.json b/examples/openproof-approval-outcome/04-reference-unresolvable.json new file mode 100644 index 00000000..6eed1451 --- /dev/null +++ b/examples/openproof-approval-outcome/04-reference-unresolvable.json @@ -0,0 +1,42 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1794187732, + "subject": "spiffe://trust.example.org/agent/release-writer", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "references": [ + { + "rel": "approval-outcome", + "id": "openproof/approval/0000000000000000000000000000000000000000000000000000000000000000", + "resolver": "https://openproof.example.org/owners/release-desk", + "digest": "sha256:49ceffcf90b75ba4841c0ed96a5dd059f242cc1bb357b20dd21d609aa130f801" + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_D-Cxu524uw5iMn5AzSQkr9C-gC4_Eejy7IMJuQxccE" + } + }, + "signature": "Fi2M2fYOVTx8UbP6j1riw5yKYnyrcSXu8WE7vcCfDGjGAQKOwv3E8W0_GXL2u-_JJUS3wLlHdT9ijQ3WNwQSBA" +} diff --git a/examples/openproof-approval-outcome/05-approval-outside-trust.json b/examples/openproof-approval-outcome/05-approval-outside-trust.json new file mode 100644 index 00000000..0bcb3868 --- /dev/null +++ b/examples/openproof-approval-outcome/05-approval-outside-trust.json @@ -0,0 +1,42 @@ +{ + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1794187732, + "subject": "spiffe://trust.example.org/agent/release-writer", + "model": { + "provider": "example", + "model_id": "example-model" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 1, + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "appraisal": { + "status": "none", + "verifier": "https://verifier.example.org" + }, + "references": [ + { + "rel": "approval-outcome", + "id": "openproof/approval/9a2f7d5d0dfd6695b78d71085b9c95e4fa0c03327d5182b1d977c213ffc57457", + "resolver": "https://openproof.example.org/owners/release-desk", + "digest": "sha256:91070bf32225aff130b9d8a47fe1b4e4aa10514a05f6f9d17648a6d78b291037" + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_D-Cxu524uw5iMn5AzSQkr9C-gC4_Eejy7IMJuQxccE" + } + }, + "signature": "yL44Zusd5DXKjuKJ4pLp3G_G6air6Gr_SYowi4v0Q-yaRXa3S4PGxUjWl7xo72rNo-okLEfXNveewqeDEdNOAw" +} diff --git a/examples/openproof-approval-outcome/README.md b/examples/openproof-approval-outcome/README.md new file mode 100644 index 00000000..1c502bd5 --- /dev/null +++ b/examples/openproof-approval-outcome/README.md @@ -0,0 +1,70 @@ +# OpenProof approvals as `approval-outcome` references + +A TRACE Trust Record can point at the human approval a run was taken under. These +fixtures show that composition with approvals recorded by +[OpenProof](https://github.com/antagoninc/openproof): the record carries a `references` +entry with `rel: "approval-outcome"`, its `id` is derived from the approval's own +signing bytes, and its `digest` is the SHA-256 of the RFC 8785 canonical form of the +approved request (the signed approval, the exact content and the owner's passkey +assertion). + +Full mapping and rationale: [`docs/crosswalks/openproof-approvals.md`](../../docs/crosswalks/openproof-approvals.md). + +The OpenProof objects here (the signed trust, the approvals, the receipt and the +checkpoint) were made by version 0.9.0 of the `openproof-witness` binary. All but one +came from a single OpenProof home: an owner passkey, two approvals of the same document +version, the first executed and witnessed, the second refused by the receiver because +the document had moved on. The exception is case `05`'s approval, which the same binary +signed with a key outside the owner's trust. The generator made the rest in Python: the +Trust Records, the passkey assertions (from a software P-256 key standing in for a +platform authenticator), the store layout, and the altered store. + +The generator is +[`integrations/trace/generate_examples.py`](https://github.com/antagoninc/openproof/blob/d85c24a1ec9228de7c4876904f260a0093312b70/integrations/trace/generate_examples.py) +in the OpenProof repository, and the JSON files here are byte-identical to its output +committed there. It is not run here, because OpenProof is not a dependency of this +repository. +[`tests/test_openproof_approval_outcome_fixtures.py`](../../tests/test_openproof_approval_outcome_fixtures.py) +re-verifies all of it on every run without importing OpenProof: the TRACE signatures, +each reference digest recomputed with `rfc8785`, the Ed25519 signatures OpenProof made +over its OPEC-1 bytes, and the passkey assertions of cases `01` and `03`. + +## Fixture cases + +Expected results are machine-readable in [`expected.json`](expected.json). Every +record verifies as a TRACE record; what differs is what the reference resolves to. + +| Record | Store | Reference | Digest | Attributable | Executed | Witnessed | Verdict | +|---|---|---|---|---|---|---|---| +| `01-approval-executed.json` | `openproof-approvals.json` | resolves | matches | yes | yes | yes | approval confirmed and executed | +| `02-approval-altered-after-issue.json` | `openproof-approvals-altered.json` | resolves | **differs** | n/a | n/a | n/a | approval contradicted | +| `03-approved-not-executed.json` | `openproof-approvals.json` | resolves | matches | yes | **no receipt** | n/a | approved, not executed | +| `04-reference-unresolvable.json` | `openproof-approvals.json` | **no such entry** | n/a | n/a | n/a | n/a | approval unconfirmed | +| `05-approval-outside-trust.json` | `openproof-approvals.json` | resolves | matches | **no** | n/a | n/a | approval not attributable | + +`02` is the same approval with its content rewritten in the store after the record was +issued. The record's digest no longer matches, and the content would no longer hash to +the approved `content_sha256` either. + +`03` is a second approval of the same document version. The receiver refused it because +the first approval executed first, so the store holds no receipt for it. + +`04` is what spec section 3.1.2 rule 3 requires: a verifier must not reject a record +because a reference cannot be resolved. The record verifies, and the approval it +points at is reported as unconfirmed, which is a different answer from "no approval". + +`05` is an approval signed by a key the owner's trust does not name, carrying another +approval's passkey assertion. Its digest matches, because the store holds exactly what +the record cited. It fails on the authority check, before its passkey assertion is +read. + +## Running the checks + +``` +python -m pytest tests/test_openproof_approval_outcome_fixtures.py +``` + +To regenerate, run that script from a checkout of `antagoninc/openproof` with `--out` +pointing at an empty directory, then copy its JSON files here. The script deletes and +recreates its `--out` directory, so do not point it at this one, which also holds this +README. A new run makes new keys and identifiers, so it replaces every JSON file here. diff --git a/examples/openproof-approval-outcome/expected.json b/examples/openproof-approval-outcome/expected.json new file mode 100644 index 00000000..fd5c74d6 --- /dev/null +++ b/examples/openproof-approval-outcome/expected.json @@ -0,0 +1,61 @@ +{ + "trace_signer_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_D-Cxu524uw5iMn5AzSQkr9C-gC4_Eejy7IMJuQxccE" + }, + "openproof_root_public_key": "0717615b5d10b099fa18ee2064c2508cbc05f49e1427ace812dfa0c58376abe3", + "resolver": "https://openproof.example.org/owners/release-desk", + "cases": { + "01-approval-executed.json": { + "store": "openproof-approvals.json", + "trace_record_verifies": true, + "reference_resolves": true, + "digest_matches": true, + "approval_attributable": true, + "executed_as_approved": true, + "witnessed": true, + "verdict": "approval-confirmed-and-executed" + }, + "02-approval-altered-after-issue.json": { + "store": "openproof-approvals-altered.json", + "trace_record_verifies": true, + "reference_resolves": true, + "digest_matches": false, + "approval_attributable": null, + "executed_as_approved": null, + "witnessed": null, + "verdict": "approval-contradicted" + }, + "03-approved-not-executed.json": { + "store": "openproof-approvals.json", + "trace_record_verifies": true, + "reference_resolves": true, + "digest_matches": true, + "approval_attributable": true, + "executed_as_approved": false, + "witnessed": null, + "verdict": "approved-not-executed" + }, + "04-reference-unresolvable.json": { + "store": "openproof-approvals.json", + "trace_record_verifies": true, + "reference_resolves": false, + "digest_matches": null, + "approval_attributable": null, + "executed_as_approved": null, + "witnessed": null, + "verdict": "approval-unconfirmed" + }, + "05-approval-outside-trust.json": { + "store": "openproof-approvals.json", + "trace_record_verifies": true, + "reference_resolves": true, + "digest_matches": true, + "approval_attributable": false, + "executed_as_approved": null, + "witnessed": null, + "verdict": "approval-not-attributable" + } + } +} diff --git a/examples/openproof-approval-outcome/openproof-approvals-altered.json b/examples/openproof-approval-outcome/openproof-approvals-altered.json new file mode 100644 index 00000000..83b7564b --- /dev/null +++ b/examples/openproof-approval-outcome/openproof-approvals-altered.json @@ -0,0 +1,125 @@ +{ + "resolver": "https://openproof.example.org/owners/release-desk", + "signed_trust": { + "payload": { + "authority": "4af715c9ca07177711845af4082f817e1a24ecb7bc5c8aff6c0ced8cfb765317", + "epoch": 2, + "expires_at": 1794274131, + "owner_passkey": { + "from_sequence": 1, + "origin": "http://localhost:8765", + "public_key": "04db3783c283aeeddf4b9eaab42856a42c05f190657b2f1b8c5b8ceb89e8fc934a6055162afd736c01da3b3699929b3cca1a44963543703315b5cb06096833a943", + "rp_id": "localhost" + }, + "previous_authorities": [], + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "revoked_authorities": [], + "version": "openproof-witness/1", + "witness": "5b61c031656495ea5f4a465bb92de1da03e0fa27b6a78a711d5c9062fe8e2e76" + }, + "signature": "9de0111393bdeadfd33cdc203e9dad7335e6bd65ada05fcf92e177d0be3f926d39c3d9bbd27d4eab171d39d48c3b7e1ade5c504ad1e3bb6ecf7a79e23ead170d" + }, + "entries": { + "openproof/approval/fbba245132a7b9c5ac4d51fc0cafd244f034765f8c60378b75242ad601b803e9": { + "request": { + "approval": { + "payload": { + "authority": "4af715c9ca07177711845af4082f817e1a24ecb7bc5c8aff6c0ced8cfb765317", + "content_sha256": "2d1c5bf12b746246f14b293e129bac30667caac89faf2b45adcc7aff6ae02599", + "document": "release-notes", + "expected_version": 0, + "expires_at": 1794188031, + "nonce": "db49e7e884df0c27b4f3363dab826bbf67bb1b504f626e5c0fb8d84e3f543fd8", + "not_before": 1794187731, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "d333715a23caeae514a4a856a3720b144f72d799a7e308a0487fd0e98fa55c9b4da1988b3928dc54601c259d20eae820fcb72f2ac2cede251d85290301734a03" + }, + "content": "Release 1.4: rewritten after approval.", + "owner_assertion": { + "authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiLTdva1VUS251Y1dzVFZIOERLX1NSUEEwZGwtTVlEZUxkU1FxMWdHNEEtayIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODc2NSJ9", + "signature": "MEQCIBLm_nvmsUpFgO4JN1YA-_S_kTiieLM-TsfXTxTnLV0gAiBfgZ952cfaPjNAOtASr51vREG5hxw82fThcmy4J0Kphw" + } + }, + "receipt": { + "payload": { + "approval_sha256": "fbba245132a7b9c5ac4d51fc0cafd244f034765f8c60378b75242ad601b803e9", + "content_sha256": "2d1c5bf12b746246f14b293e129bac30667caac89faf2b45adcc7aff6ae02599", + "document": "release-notes", + "document_version": 1, + "kind": "update", + "observed_at": 1794187731, + "previous": "0000000000000000000000000000000000000000000000000000000000000000", + "reason": "", + "sequence": 1, + "version": "openproof-witness/1" + }, + "signature": "1f61a998c5cfddd89c27dba1abf3815817d766037e1e163115ea388152e35ef5f8f8fa7bd4c5bbaeefc2207caa5443112ff55e906ff1affa6613ede9fd0bf901" + }, + "checkpoint": { + "payload": { + "count": 1, + "head": "6dbbc6adc951b66f6c091f3a750f12a217efb904d0c6892925b78d8c15919c12", + "observed_at": 1794187732, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "26101dd740f21a808b15df265076e7b3ccb32ae4c3ad57b26163b399f2cd78b33400e9a6b6906f1ef46b146ce1de28dafe9260cb30a40c496873f5909e33780d" + } + }, + "openproof/approval/133b69a4506a50e8d44694ce3d9641bdc198bc2d1c4d54e5d464afaee56736d7": { + "request": { + "approval": { + "payload": { + "authority": "4af715c9ca07177711845af4082f817e1a24ecb7bc5c8aff6c0ced8cfb765317", + "content_sha256": "863925ab1b97a19c4a30b9e641ee0a647d112b56dee13ff675370350a115b835", + "document": "release-notes", + "expected_version": 0, + "expires_at": 1794188031, + "nonce": "30b579d5dbe7bc91e456085fd8f0bb286f0a33b030f5efea9b2f251134d3a103", + "not_before": 1794187731, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "53fb5cc819841530107475dd0f5770d6d0abb4c15540212dc7f4c66978c764dd01d5d01df1bfd9c3b07cfc42c6f647bf5601e9e621c37b8284c824322139ba04" + }, + "content": "Release 1.4: no changes.", + "owner_assertion": { + "authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRXp0cHBGQnFVT2pVUnBUT1BaWkJ2Y0dZdkMwY1RWVGwxR1N2cnVWbk50YyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODc2NSJ9", + "signature": "MEYCIQCm4gHnOJsFsnYLjczdY8qPjO7NONRc6-s9ZBsS3K7GsQIhAPIJLBJRbU5j6fnmRQdhNdyo8cHrMI5XiG103bScXIyg" + } + }, + "receipt": null, + "checkpoint": null + }, + "openproof/approval/9a2f7d5d0dfd6695b78d71085b9c95e4fa0c03327d5182b1d977c213ffc57457": { + "request": { + "approval": { + "payload": { + "authority": "4cfbe4058e3316829b2f0c16ce3c25fee55e3f574e6d6224a7a108625bc5470f", + "content_sha256": "2ebc2439f28a3d12d73b9a9b97c9a565e9ea2bef315823dcf98a131eeff7e1db", + "document": "release-notes", + "expected_version": 1, + "expires_at": 1794188032, + "nonce": "bd61e7d81b1cac255345a99aefc1d16ee79ccc2c2d49e102e4f70e4a7189563c", + "not_before": 1794187732, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "7ce93c6e15fea85a68115f25f6e9c9719408e5ff3db9ef902fdf926bd541b5d5173114932e650f18bbacc0ff8646265662a373bf161f2767702baa23f7e61706" + }, + "content": "Release 1.4: forged.", + "owner_assertion": { + "authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiLTdva1VUS251Y1dzVFZIOERLX1NSUEEwZGwtTVlEZUxkU1FxMWdHNEEtayIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODc2NSJ9", + "signature": "MEQCIBLm_nvmsUpFgO4JN1YA-_S_kTiieLM-TsfXTxTnLV0gAiBfgZ952cfaPjNAOtASr51vREG5hxw82fThcmy4J0Kphw" + } + }, + "receipt": null, + "checkpoint": null + } + } +} diff --git a/examples/openproof-approval-outcome/openproof-approvals.json b/examples/openproof-approval-outcome/openproof-approvals.json new file mode 100644 index 00000000..a47f3486 --- /dev/null +++ b/examples/openproof-approval-outcome/openproof-approvals.json @@ -0,0 +1,125 @@ +{ + "resolver": "https://openproof.example.org/owners/release-desk", + "signed_trust": { + "payload": { + "authority": "4af715c9ca07177711845af4082f817e1a24ecb7bc5c8aff6c0ced8cfb765317", + "epoch": 2, + "expires_at": 1794274131, + "owner_passkey": { + "from_sequence": 1, + "origin": "http://localhost:8765", + "public_key": "04db3783c283aeeddf4b9eaab42856a42c05f190657b2f1b8c5b8ceb89e8fc934a6055162afd736c01da3b3699929b3cca1a44963543703315b5cb06096833a943", + "rp_id": "localhost" + }, + "previous_authorities": [], + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "revoked_authorities": [], + "version": "openproof-witness/1", + "witness": "5b61c031656495ea5f4a465bb92de1da03e0fa27b6a78a711d5c9062fe8e2e76" + }, + "signature": "9de0111393bdeadfd33cdc203e9dad7335e6bd65ada05fcf92e177d0be3f926d39c3d9bbd27d4eab171d39d48c3b7e1ade5c504ad1e3bb6ecf7a79e23ead170d" + }, + "entries": { + "openproof/approval/fbba245132a7b9c5ac4d51fc0cafd244f034765f8c60378b75242ad601b803e9": { + "request": { + "approval": { + "payload": { + "authority": "4af715c9ca07177711845af4082f817e1a24ecb7bc5c8aff6c0ced8cfb765317", + "content_sha256": "2d1c5bf12b746246f14b293e129bac30667caac89faf2b45adcc7aff6ae02599", + "document": "release-notes", + "expected_version": 0, + "expires_at": 1794188031, + "nonce": "db49e7e884df0c27b4f3363dab826bbf67bb1b504f626e5c0fb8d84e3f543fd8", + "not_before": 1794187731, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "d333715a23caeae514a4a856a3720b144f72d799a7e308a0487fd0e98fa55c9b4da1988b3928dc54601c259d20eae820fcb72f2ac2cede251d85290301734a03" + }, + "content": "Release 1.4: fixes the export retry.", + "owner_assertion": { + "authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiLTdva1VUS251Y1dzVFZIOERLX1NSUEEwZGwtTVlEZUxkU1FxMWdHNEEtayIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODc2NSJ9", + "signature": "MEQCIBLm_nvmsUpFgO4JN1YA-_S_kTiieLM-TsfXTxTnLV0gAiBfgZ952cfaPjNAOtASr51vREG5hxw82fThcmy4J0Kphw" + } + }, + "receipt": { + "payload": { + "approval_sha256": "fbba245132a7b9c5ac4d51fc0cafd244f034765f8c60378b75242ad601b803e9", + "content_sha256": "2d1c5bf12b746246f14b293e129bac30667caac89faf2b45adcc7aff6ae02599", + "document": "release-notes", + "document_version": 1, + "kind": "update", + "observed_at": 1794187731, + "previous": "0000000000000000000000000000000000000000000000000000000000000000", + "reason": "", + "sequence": 1, + "version": "openproof-witness/1" + }, + "signature": "1f61a998c5cfddd89c27dba1abf3815817d766037e1e163115ea388152e35ef5f8f8fa7bd4c5bbaeefc2207caa5443112ff55e906ff1affa6613ede9fd0bf901" + }, + "checkpoint": { + "payload": { + "count": 1, + "head": "6dbbc6adc951b66f6c091f3a750f12a217efb904d0c6892925b78d8c15919c12", + "observed_at": 1794187732, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "26101dd740f21a808b15df265076e7b3ccb32ae4c3ad57b26163b399f2cd78b33400e9a6b6906f1ef46b146ce1de28dafe9260cb30a40c496873f5909e33780d" + } + }, + "openproof/approval/133b69a4506a50e8d44694ce3d9641bdc198bc2d1c4d54e5d464afaee56736d7": { + "request": { + "approval": { + "payload": { + "authority": "4af715c9ca07177711845af4082f817e1a24ecb7bc5c8aff6c0ced8cfb765317", + "content_sha256": "863925ab1b97a19c4a30b9e641ee0a647d112b56dee13ff675370350a115b835", + "document": "release-notes", + "expected_version": 0, + "expires_at": 1794188031, + "nonce": "30b579d5dbe7bc91e456085fd8f0bb286f0a33b030f5efea9b2f251134d3a103", + "not_before": 1794187731, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "53fb5cc819841530107475dd0f5770d6d0abb4c15540212dc7f4c66978c764dd01d5d01df1bfd9c3b07cfc42c6f647bf5601e9e621c37b8284c824322139ba04" + }, + "content": "Release 1.4: no changes.", + "owner_assertion": { + "authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRXp0cHBGQnFVT2pVUnBUT1BaWkJ2Y0dZdkMwY1RWVGwxR1N2cnVWbk50YyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODc2NSJ9", + "signature": "MEYCIQCm4gHnOJsFsnYLjczdY8qPjO7NONRc6-s9ZBsS3K7GsQIhAPIJLBJRbU5j6fnmRQdhNdyo8cHrMI5XiG103bScXIyg" + } + }, + "receipt": null, + "checkpoint": null + }, + "openproof/approval/9a2f7d5d0dfd6695b78d71085b9c95e4fa0c03327d5182b1d977c213ffc57457": { + "request": { + "approval": { + "payload": { + "authority": "4cfbe4058e3316829b2f0c16ce3c25fee55e3f574e6d6224a7a108625bc5470f", + "content_sha256": "2ebc2439f28a3d12d73b9a9b97c9a565e9ea2bef315823dcf98a131eeff7e1db", + "document": "release-notes", + "expected_version": 1, + "expires_at": 1794188032, + "nonce": "bd61e7d81b1cac255345a99aefc1d16ee79ccc2c2d49e102e4f70e4a7189563c", + "not_before": 1794187732, + "receiver": "689de07a032507e729a148cb7caf7a35e64bf6b55b7fee7ca9d21956b8f52f74", + "version": "openproof-witness/1" + }, + "signature": "7ce93c6e15fea85a68115f25f6e9c9719408e5ff3db9ef902fdf926bd541b5d5173114932e650f18bbacc0ff8646265662a373bf161f2767702baa23f7e61706" + }, + "content": "Release 1.4: forged.", + "owner_assertion": { + "authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA", + "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiLTdva1VUS251Y1dzVFZIOERLX1NSUEEwZGwtTVlEZUxkU1FxMWdHNEEtayIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODc2NSJ9", + "signature": "MEQCIBLm_nvmsUpFgO4JN1YA-_S_kTiieLM-TsfXTxTnLV0gAiBfgZ952cfaPjNAOtASr51vREG5hxw82fThcmy4J0Kphw" + } + }, + "receipt": null, + "checkpoint": null + } + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 35cb8b22..8c844975 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -246,6 +246,7 @@ nav: - MITRE ATLAS: docs/crosswalks/mitre-atlas.md - Acta decision receipts: docs/crosswalks/acta-decision-receipts.md - CHAP review decisions: docs/crosswalks/chap-review-decisions.md + - OpenProof approvals: docs/crosswalks/openproof-approvals.md - N'KEMBA institutional reconstruction: docs/crosswalks/nkemba-institutional-reconstruction.md - NIST SP 800-53 and DoD AI Tailoring Guide: docs/crosswalks/nist-sp800-53-dod-ai-tailoring.md - Guides: diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index b1276f9e..5ce41135 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -312,6 +312,12 @@ def test_the_loader_reads_a_different_set_for_each_name() -> None: "recomputes every verdict from the committed record and " "CHAP log instead of reading expected.json", # Not loadable here for the same reason: every record verifies, and the cases + # differ only in what the reference resolves to in the OpenProof store beside them. + "openproof-approval-outcome": "tests/test_openproof_approval_outcome_fixtures.py, " + "which recomputes every verdict from the committed " + "record and OpenProof store instead of reading " + "expected.json", + # Not loadable here for the same reason: every record verifies, and the cases # differ only in what the reference resolves to in the appraisal store beside them. "condition-appraisal": "tests/test_condition_appraisal_fixtures.py, which recomputes " "every verdict from the committed record and appraisal store " diff --git a/tests/test_openproof_approval_outcome_fixtures.py b/tests/test_openproof_approval_outcome_fixtures.py new file mode 100644 index 00000000..546402bb --- /dev/null +++ b/tests/test_openproof_approval_outcome_fixtures.py @@ -0,0 +1,368 @@ +"""OpenProof approvals as TRACE `approval-outcome` references. + +Re-verifies examples/openproof-approval-outcome/ without importing OpenProof. The +OpenProof objects were made by the `openproof-witness` binary, and the rest by the +generator in antagoninc/openproof, integrations/trace/generate_examples.py, which also +wrote expected.json. A generator's own summary of its output is a claim, so every +outcome here is recomputed from the committed bytes with this repository's +dependencies and compared with expected.json, never read from it. + +OpenProof signs its objects with Ed25519 over domain-separated OPEC-1 bytes, not over +RFC 8785. The encoder below is the one in OpenProof's Python reference, +reference/python/openproof_ref.py: a one-byte type tag, 64-bit big-endian lengths, and +object keys sorted by their UTF-8 bytes. The TRACE `digest` is separate from that: it +is the RFC 8785 SHA-256 of the stored request, as for every registered relation. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +from pathlib import Path + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from agentrust_trace import verify_record + +OP_DIR = Path(__file__).resolve().parents[1] / "examples" / "openproof-approval-outcome" +EXPECTED = json.loads((OP_DIR / "expected.json").read_text(encoding="utf-8")) +CASES = sorted(EXPECTED["cases"]) +ROOT_KEY = EXPECTED["openproof_root_public_key"] +ID_PREFIX = "openproof/approval/" +OUTCOME_KEYS = ( + "reference_resolves", + "digest_matches", + "approval_attributable", + "executed_as_approved", + "witnessed", + "verdict", +) + + +def _load(name: str) -> dict: + return json.loads((OP_DIR / name).read_text(encoding="utf-8")) + + +def _jcs_sha256(obj: object) -> str: + return "sha256:" + hashlib.sha256(rfc8785.dumps(obj)).hexdigest() + + +def _opec(value: object) -> bytes: + """OpenProof OPEC-1 canonical bytes.""" + + def frame(n: int) -> bytes: + return n.to_bytes(8, "big") + + def enc(v: object) -> bytes: + if v is None: + return b"\x00" + if v is False: + return b"\x01" + if v is True: + return b"\x02" + if type(v) is int: + if not 0 <= v < 2**64: + raise ValueError("OPEC-1 integers are unsigned 64-bit") + return b"\x03" + frame(v) + if type(v) is str: + raw = v.encode("utf-8") + return b"\x04" + frame(len(raw)) + raw + if type(v) is list: + return b"\x05" + frame(len(v)) + b"".join(enc(x) for x in v) + if type(v) is dict: + keys = sorted(v, key=lambda k: k.encode("utf-8")) + return b"\x06" + frame(len(v)) + b"".join(enc(k) + enc(v[k]) for k in keys) + raise ValueError(f"OPEC-1 has no encoding for {type(v).__name__}") + + return b"OPEC\x01" + enc(value) + + +def _preimage(domain: str, payload: object) -> bytes: + return b"OpenProof/v1/" + domain.encode("utf-8") + b"\x00" + _opec(payload) + + +def _signed_by(signed: dict, domain: str, key_hex: str) -> bool: + """An OpenProof Signed: Ed25519 over the domain-separated OPEC-1 bytes.""" + try: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(key_hex)).verify( + bytes.fromhex(signed["signature"]), _preimage(domain, signed["payload"]) + ) + except (InvalidSignature, ValueError, KeyError, TypeError): + return False + return True + + +def _approval_digest(request: dict) -> str: + preimage = _preimage("document-approval/1", request["approval"]["payload"]) + return hashlib.sha256(preimage).hexdigest() + + +def _unb64u(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _passkey_signed(request: dict, passkey: dict, digest_hex: str) -> bool: + """A WebAuthn ES256 assertion whose challenge is the approval digest, made with user + verification for the relying party and origin the owner's trust names.""" + try: + assertion = request["owner_assertion"] + auth = _unb64u(assertion["authenticator_data"]) + client = _unb64u(assertion["client_data_json"]) + data = json.loads(client) + challenge = base64.urlsafe_b64encode(bytes.fromhex(digest_hex)).rstrip(b"=").decode() + if ( + data.get("type") != "webauthn.get" + or data.get("challenge") != challenge + or data.get("origin") != passkey["origin"] + or data.get("crossOrigin", False) is not False + or len(auth) < 37 + or auth[:32] != hashlib.sha256(passkey["rp_id"].encode("utf-8")).digest() + or auth[32] & 0x05 != 0x05 + ): + return False + key = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256R1(), bytes.fromhex(passkey["public_key"]) + ) + key.verify( + _unb64u(assertion["signature"]), + auth + hashlib.sha256(client).digest(), + ec.ECDSA(hashes.SHA256()), + ) + except (InvalidSignature, ValueError, KeyError, TypeError): + return False + return True + + +def _assess(record: dict, store: dict, root_key: str = ROOT_KEY) -> dict: + """What a relying party concludes about the approval a record points at. + + The steps are those of docs/crosswalks/openproof-approvals.md, in order, and each + later finding is None when an earlier one already decided the verdict. + """ + out: dict = { + "reference_resolves": False, + "digest_matches": None, + "approval_attributable": None, + "executed_as_approved": None, + "witnessed": None, + } + + def done(verdict: str) -> dict: + return {**out, "verdict": verdict} + + (reference,) = record["references"] + assert reference["rel"] == "approval-outcome" + assert reference["resolver"] == EXPECTED["resolver"] + entry = store["entries"].get(reference["id"]) + if entry is None: + return done("approval-unconfirmed") + out["reference_resolves"] = True + request = entry["request"] + out["digest_matches"] = _jcs_sha256(request) == reference["digest"] + if not out["digest_matches"]: + return done("approval-contradicted") + if not _signed_by(store["signed_trust"], "witness-trust/1", root_key): + out["approval_attributable"] = False + return done("trust-unverified") + trust = store["signed_trust"]["payload"] + digest_hex = _approval_digest(request) + approval = request["approval"]["payload"] + attributable = ( + reference["id"] == ID_PREFIX + digest_hex + and approval["authority"] not in trust["revoked_authorities"] + and approval["authority"] in [trust["authority"], *trust["previous_authorities"]] + and _signed_by(request["approval"], "document-approval/1", approval["authority"]) + and approval["content_sha256"] + == hashlib.sha256(request["content"].encode("utf-8")).hexdigest() + and approval["receiver"] == trust["receiver"] + ) + if attributable and trust.get("owner_passkey") is not None: + attributable = _passkey_signed(request, trust["owner_passkey"], digest_hex) + out["approval_attributable"] = attributable + if not attributable: + return done("approval-not-attributable") + receipt = entry.get("receipt") + if receipt is None: + out["executed_as_approved"] = False + return done("approved-not-executed") + r = receipt["payload"] + out["executed_as_approved"] = ( + _signed_by(receipt, "receiver-receipt/1", trust["receiver"]) + and r["kind"] == "update" + and r["approval_sha256"] == digest_hex + and r["content_sha256"] == approval["content_sha256"] + and r["document"] == approval["document"] + and r["document_version"] == approval["expected_version"] + 1 + ) + if not out["executed_as_approved"]: + return done("execution-contradicted") + checkpoint = entry.get("checkpoint") + out["witnessed"] = bool( + checkpoint + and _signed_by(checkpoint, "witness-checkpoint/1", trust["witness"]) + and checkpoint["payload"]["receiver"] == trust["receiver"] + and checkpoint["payload"]["count"] >= r["sequence"] + ) + return done("approval-confirmed-and-executed" if out["witnessed"] else "executed-unwitnessed") + + +def _rereferenced(record: dict, store: dict) -> dict: + """An unsigned copy of a record whose digest follows a deliberately changed store. + + `_assess` never reads the record signature, so this isolates a later step from the + digest check that would otherwise stop at step 3. + """ + record = copy.deepcopy(record) + request = store["entries"][record["references"][0]["id"]]["request"] + record["references"][0]["digest"] = _jcs_sha256(request) + return record + + +def test_the_committed_records_are_exactly_the_declared_cases() -> None: + committed = {p.name for p in OP_DIR.glob("0*.json")} + assert committed == set(CASES) + for case in EXPECTED["cases"].values(): + assert (OP_DIR / case["store"]).is_file() + + +@pytest.mark.parametrize("name", CASES) +def test_every_case_verifies_as_a_trust_record(name: str) -> None: + # Section 3.1.2 rule 3: what a reference resolves to never decides whether the + # record verifies, so the unresolvable and contradicted cases verify too. + verify_record(_load(name), EXPECTED["trace_signer_jwk"], max_age_seconds=None) + assert EXPECTED["cases"][name]["trace_record_verifies"] is True + + +@pytest.mark.parametrize("name", CASES) +def test_the_outcome_is_recomputed_from_the_committed_bytes(name: str) -> None: + case = EXPECTED["cases"][name] + assert _assess(_load(name), _load(case["store"])) == {k: case[k] for k in OUTCOME_KEYS} + + +def test_the_record_signature_covers_the_reference() -> None: + record = copy.deepcopy(_load("01-approval-executed.json")) + digest = record["references"][0]["digest"] + record["references"][0]["digest"] = digest[:-1] + ("0" if digest[-1] != "0" else "1") + # verify_record documents InvalidSignature for a signature that does not verify. + with pytest.raises(InvalidSignature): + verify_record(record, EXPECTED["trace_signer_jwk"], max_age_seconds=None) + + +def test_each_resolvable_id_is_derived_from_the_approval_it_names() -> None: + """The id is the SHA-256 of the approval's OPEC-1 signing bytes, so an entry + cannot be filed under the id of a different approval.""" + store = _load("openproof-approvals.json") + for entry_id, entry in store["entries"].items(): + assert entry_id == ID_PREFIX + _approval_digest(entry["request"]) + + +def _differences(a: object, b: object, path: tuple = ()) -> list[tuple]: + """Every path at which two JSON values differ, keys present on either side included.""" + if isinstance(a, dict) and isinstance(b, dict): + return [ + d + for key in sorted(a.keys() | b.keys()) + for d in _differences(a.get(key, KeyError), b.get(key, KeyError), (*path, key)) + ] + if isinstance(a, list) and isinstance(b, list) and len(a) == len(b): + pairs = enumerate(zip(a, b, strict=True)) + return [d for i, (x, y) in pairs for d in _differences(x, y, (*path, i))] + return [] if a == b else [path] + + +def test_the_altered_store_differs_from_the_original_in_the_cited_content_only() -> None: + original = _load("openproof-approvals.json") + altered = _load("openproof-approvals-altered.json") + cited = _load("02-approval-altered-after-issue.json")["references"][0]["id"] + assert cited == _load("01-approval-executed.json")["references"][0]["id"] + assert _differences(original, altered) == [("entries", cited, "request", "content")] + + +def test_every_number_in_the_referenced_requests_is_a_safe_integer() -> None: + """RFC 8785 implementations disagree mostly on numbers. Every number a reference + digest here covers is a non-negative integer below 2**53.""" + + def numbers(v: object): + if isinstance(v, bool): + return + if isinstance(v, (int, float)): + yield v + elif isinstance(v, dict): + for x in v.values(): + yield from numbers(x) + elif isinstance(v, list): + for x in v: + yield from numbers(x) + + for name in ("openproof-approvals.json", "openproof-approvals-altered.json"): + for entry in _load(name)["entries"].values(): + found = list(numbers(entry["request"])) + assert found + assert all(type(n) is int and 0 <= n < 2**53 for n in found) + + +def test_each_later_step_changes_the_verdict() -> None: + """Steps 4 to 6 each turn the confirmed case into a different verdict. Steps 2, 3 and + 4 already have committed cases, 04, 02 and 05; case 05 fails on its authority. + + The receipt is changed after it was signed, so step 5 fails on its signature as well + as on the version; nothing here holds the receiver key to sign a wrong receipt. + """ + record = _load("01-approval-executed.json") + store = _load("openproof-approvals.json") + entry_id = record["references"][0]["id"] + assert _assess(record, store)["verdict"] == "approval-confirmed-and-executed" + + # Step 4, trust: a root the relying party holds that did not sign this trust. + assert _assess(record, store, root_key="00" * 32)["verdict"] == "trust-unverified" + + # Step 4, passkey: one bit of the assertion signature changed, and the record's + # digest made to follow it so that step 3 passes. + changed = copy.deepcopy(store) + assertion = changed["entries"][entry_id]["request"]["owner_assertion"] + raw = bytearray(_unb64u(assertion["signature"])) + raw[-1] ^= 0x01 + assertion["signature"] = base64.urlsafe_b64encode(bytes(raw)).rstrip(b"=").decode() + verdict = _assess(_rereferenced(record, changed), changed)["verdict"] + assert verdict == "approval-not-attributable" + + # Step 4, passkey challenge: the owner's valid assertion for the other approval of the + # same document, replayed onto this one. + changed = copy.deepcopy(store) + other = _load("03-approved-not-executed.json")["references"][0]["id"] + changed["entries"][entry_id]["request"]["owner_assertion"] = copy.deepcopy( + store["entries"][other]["request"]["owner_assertion"] + ) + verdict = _assess(_rereferenced(record, changed), changed)["verdict"] + assert verdict == "approval-not-attributable" + + # Step 4, content: the approval and its signature untouched, the content changed. + changed = copy.deepcopy(store) + changed["entries"][entry_id]["request"]["content"] += " " + verdict = _assess(_rereferenced(record, changed), changed)["verdict"] + assert verdict == "approval-not-attributable" + + # Step 4, id: the same request filed under the id of a different approval. + changed = copy.deepcopy(store) + moved = copy.deepcopy(record) + changed["entries"][other] = changed["entries"][entry_id] + moved["references"][0]["id"] = other + verdict = _assess(_rereferenced(moved, changed), changed)["verdict"] + assert verdict == "approval-not-attributable" + + # Step 5: the receipt names a version other than the next one. + changed = copy.deepcopy(store) + changed["entries"][entry_id]["receipt"]["payload"]["document_version"] += 1 + assert _assess(record, changed)["verdict"] == "execution-contradicted" + + # Step 6: no checkpoint. + changed = copy.deepcopy(store) + changed["entries"][entry_id]["checkpoint"] = None + assert _assess(record, changed)["verdict"] == "executed-unwitnessed"