Four things our signed artifacts sealed by name instead of by bytes #153
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/ap2-mandate-authority"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Four soundness holes of one shape: something sealed by name rather than by bytes, or not verified at all. Each was verified against the source before changing anything, and each fix was falsified after committing.
jcp-a2a— an unverified AP2 mandate set the ceiling, and could name any payeeingest_ap2_mandatewas a JSON field reader — no signature check, no issuer-key resolution, no revocation list — and its output became a ceiling the gate enforces viajoule_budget()/funds_ceiling(). The gate was sound; its limit came from unauthenticated input, so anyone who could hand us JSON set the budget.Parsing and trusting are now two types.
ingest_ap2_mandatereturnsUnverifiedAp2Mandate, which has no ceiling accessors at all; the only way to reach them isaccepted_as_verified(authority), which refuses a blank authority and keeps the name onAp2Mandate::authority(). We deliberately do not implement AP2's signature format — the mandate is a layered verifiable credential needing issuer-key resolution JCP has no authority over — so the honest boundary is that the host verifies and says what did. The provenance of an authority is the counterpart to the provenance of a measurement.That this is a real boundary and not a comment is what the compiler showed: every existing call site that treated parsed JSON as an enforceable ceiling stopped compiling.
Separately,
Ap2Mandate.payeeappeared exactly once outside parsing — in a test assertion. So a mandate authorizingattacker.exampleminted a grant spending againstplanner.example's work.jcp-x402already closes this by putting the merchant in the grant audience; the pattern simply was not applied here. Both constructors now refuse unlesscard.is_payee(), binding by agent id, name, or URL host (userinfo and port stripped, suffix match refused). Both real cards in the tree already named matching payees — only tests using placeholders needed real ones, which is the defect in miniature.jcp-policy— a receipt sealed the policy's namepolicy_wiresealed"policy": <&str>. Swap the ruleset, keep the name, and every past receipt still verifies while describing a decision the current rules would not make.Policy::content_id()now digests the name and the ordered rules — order is in the digest becauseevaluatereturns on the first matchingDeny— sealed aspolicy_digestand recovered byverify.One new test caught a bug in the commit before it shipped:
Match::Alwaysfirst rendered as a JSONtrue, and the JCR-1 signed core forbids bools, socontent_id()panicked on "require approval for every write" — the most ordinary policy there is. Every variant is now pinned.jouleclaw-transparency-log— the consistency proof proved nothingbuild_consistency_pathreturnedvec![old_root, new_root]andverifychecked exactly those two values: the operator's claim compared to itself. A split-view operator walked straight through, which is the one attack a consistency proof exists for. The verifier's comment also claimed it checked that "adjacent pairs hash correctly"; it never did.Now real RFC 6962 §2.1.2
SUBPROOFand §2.1.3 verification, rebuilding both roots from subtree hashes — the old root is recomputed, not re-read. Neither root travels in the path, so the forged shape is structurally refused. The existingMerkleTreealready had correct conventions (0x00/0x01prefixes,largest_power_of_two_lt) and a real inclusion proof, so it composed unchanged and all 17 pre-existing tests pass untouched.Three new tests the old code could not have passed: the forged two-root path is refused; a fork is refused as an extension (and accepted against its own history, so the refusal is about the relationship, not a broken prover); and all 153
(m, n)pairs with 1 ≤ m ≤ n ≤ 17 round-trip, each additionally checked not to verify against the neighbouring old root.⚠️ Note this crate still has zero consumers, so no JCP decision can yet be given an inclusion proof. That is the next piece, and it was worth fixing the proof before wiring anything to it.
jcp-gateway— two doors open in the chokepointThe enforcement point gated on
method != "tools/call", soresources/read(server bytes → model) andprompts/get(server text → model input) relayed with no grant, no debit, no receipt, and nothing admitted to the flow lattice. A data egress straight through the chokepoint.gated_actionnow classifies all three onto the same machinery — same grant map, same content-addressedmcp_resource, same flow labelling, same metering, same sealed denial. Keys carry the uri/name so a grant forresources/read:file:///ok.txtis not authority to readfile:///secret.txt. The enforcement body is unchanged; only the classification moved, which is why all 13 pre-existing tests pass untouched. Discovery (initialize, the*/listmethods) still relays untouched, pinned by a test.Verification
381 tests pass across the jcp workspace, 20 in
jouleclaw-transparency-log, 0 clippy findings in the crates touched. Rebased onto currentmain; no file here was modified onmainsince the branch point.Found by the 2026-10-04 pillar sweep, which flagged this family while verifying AP2/Verifiable-Intent under FIDO and
draft-nelson-agent-delegation-receipts-10. Still open from the same family: no grant binds a tool's parameter schema (toolSchemaHash), the MCPextensionscapability is unannounced, andCascadeLayer::FactLutis routed to but never constructed.🤖 Generated with Claude Code
Two soundness holes on the A2A path, both verified in the code before fixing. **1. A forged mandate minted a real grant.** `ingest_ap2_mandate` is a JSON field reader: it verifies no signature, resolves no issuer key and consults no revocation list. Its output then became a ceiling the gate enforces, via `joule_budget()` / `funds_ceiling()`. So the gate was sound and its *limit* came from unauthenticated input — anyone who could hand us JSON could set the budget. Parsing and trusting are now two types. `ingest_ap2_mandate` returns `UnverifiedAp2Mandate`, which has no ceiling accessors at all; the only way to reach them is `accepted_as_verified(authority)`, which refuses a blank authority and keeps the name on the mandate behind `Ap2Mandate::authority()`. This crate deliberately does not implement AP2's own signature format — the mandate is a layered verifiable credential whose verification needs issuer-key resolution JCP has no authority over — so the honest boundary is that the host verifies and says what did. The provenance of an *authority* is the counterpart to the provenance of a *measurement*: the receipt already says where its joules came from, and now a ceiling can say where its authority came from. That this is a real boundary and not a comment is what the compiler showed: every existing call site that treated parsed JSON as an enforceable ceiling stopped compiling and had to declare an authority. **2. The payee was read and then never consulted.** `Ap2Mandate.payee` appeared exactly once outside parsing — in a test assertion. So a mandate authorizing `attacker.example` minted a grant that spent against `planner.example`'s work: one party's authorization spent on another's. `jcp-x402` already closes this by putting the merchant in the grant's audience ("a charge for a different merchant ... still denies"); the pattern simply was not applied here. Both `from_mandate` and `from_mandate_priced` now refuse unless `card.is_payee()`, which binds the payee to the agent id, the name, or the host of the service URL — because an AP2 payee is a merchant identifier while an A2A agent id is a service URL, so `research.example` must bind to `https://research.example/a2a`. Userinfo and port do not identify the party and are stripped. An empty payee binds to nothing: a mandate that names no payee authorizes no one. Worth noting what did *not* have to change: both real cards in the tree already named matching payees (`planner.example`, `research.example`), so the binding was always the intent — it just was not enforced. Only tests using placeholder payees (`"p"`, `"m"`) had to be given real ones, which is the defect in miniature. 14 tests pass, 0 clippy findings in this crate (the two in the workspace are pre-existing in `jcp-schema::flow` on main). Three new invariants: a ceiling needs a non-blank authority, a mandate for another payee cannot mint through either constructor, and a payee binds by id/name/host but not by suffix (`planner.example.evil` is refused). Found by the 2026-10-04 pillar sweep, which flagged both against `ingest_ap2_mandate` while verifying AP2/Verifiable-Intent under FIDO; I confirmed each against the source before changing anything. Co-Authored-By: Claude Opus 5 <[email protected]>`policy_wire` sealed `"policy": <name>` — a `&str` label. The receipt committed to the action, the resource, the caller and a hash of the call's content, and to the deciding ruleset only by what the operator called it. So a sealed receipt attested "denied under `prod-egress`" while nothing committed to what `prod-egress` *was*: change the rules, keep the name, and every past receipt still verifies while describing a decision the current policy would not make. That is the load-bearing claim of a policy receipt, and it was the one thing unbound. `Policy::content_id()` digests the name **and the ordered rules**, and the wire now carries `policy_digest` beside `policy`, recovered by `verify` into `PolicyReceipt`. The name stays, relabelled in its doc comment as the operator's word rather than evidence. The rendering is written out rather than derived, so the digest's definition is readable and a second implementation can reproduce it: each rule contributes its name, its effect, and its match as a uniform `{kind, values}` pair. **Order is in the digest because order is in the meaning** — `evaluate` returns on the first matching `Deny` and takes the first matching `RequireApproval`, so reordering the same set of rules can change a verdict. One of the new tests caught a bug in this commit before it shipped: `Match::Always` first rendered as a JSON `true`, and the JCR-1 signed core forbids bools, so `content_id()` panicked on "require approval for every write" — the most ordinary policy there is. Every variant is now pinned to render, to be stable, and to be distinguishable from the others. No sealed `policy/1` vector exists in `jcp/conformance`, so nothing pinned changes; `jcp/SPEC.md` does not describe this profile, and jouleclaw's `PolicyReceipt` is an unrelated JSON-Lines sibling that is untouched. 376 tests pass across the jcp workspace, 0 clippy findings in the crates touched. Second of the family of "sealed by name, not by digest" holes the 2026-10-04 sweep named; the AP2 mandate pair is 845e6cc8. Still open from that family: `jouleclaw-transparency-log::build_consistency_path` returns `[old_root, new_root]` instead of an RFC 6962 proof, and no grant binds a tool's parameter schema. Co-Authored-By: Claude Opus 5 <[email protected]>`build_consistency_path` returned `vec![old_root, new_root]` — the two values the operator was already asserting — and `verify` checked that `audit_path[0] == old_root` and `audit_path.last() == new_root`. That compares a claim to itself. A log operator serving a split view, two histories sharing no prefix, produced a "proof" that verified, which is precisely the attack a consistency proof exists to catch. The verifier's own comment also claimed it checked that "adjacent pairs hash correctly"; it never did. The in-code note said a full RFC 6962 §2.1.2 path "is provable as well" — it is, so it is now what ships. `subproof` implements §2.1.2 `SUBPROOF(m, D[n], b)` and `verify` implements the §2.1.3 walk, rebuilding *both* roots from the subtree hashes and comparing. The old root is recomputed, not re-read, which is the whole difference. Neither root travels in the path any more, so the forged shape is structurally refused. The existing `MerkleTree` already used correct RFC 6962 conventions — `0x00` leaf and `0x01` node prefixes, `largest_power_of_two_lt` splits — and the inclusion proof was already real, so the proof composes with them unchanged; all 17 pre-existing tests pass untouched. Three new tests, each one the old code could not have passed: - **The forged proof is refused.** A hand-built `[old_root, new_root]` path, containing no evidence at all, used to verify; it now errors, while the honest proof for the same transition still verifies. - **A fork is refused as an extension.** An operator publishes a 3-leaf tree, then shows an auditor a 5-leaf tree built over a *different* third leaf. Every hash in that proof is real — only the relationship is a lie. It is rejected against the published root and accepted against its own, so the refusal is about the relationship and not a broken prover. - **Every shape.** All 153 `(m, n)` pairs with 1 ≤ m ≤ n ≤ 17 round-trip: perfect trees, unbalanced ones, and `m == n`. Each proof is additionally checked *not* to verify against the neighbouring old root, so a proof binds one transition rather than a range. The `audit_path` doc comment said verification "recomputes from the proof's referenced subtrees", which was aspirational; it is now true, and says which hashes are in there and that neither root is. Note this crate still has **zero consumers** — three grep hits, all its own files — so no JCP decision can yet be given an inclusion proof. That is the next piece, and it was worth fixing the proof before wiring anything to it. Third of the "sealed by name / verified not at all" family from the 2026-10-04 sweep: 845e6cc8 (AP2 mandate authority), aa74283e (policy digest), this. Co-Authored-By: Claude Opus 5 <[email protected]>The enforcement point gated on `method != "tools/call"`, so every other MCP method relayed untouched. Two of them move a payload: - **`resources/read`** returns server-held bytes to the model. It passed through with no grant, no debit, no receipt, and nothing admitted to the flow lattice — so a later call carrying those bytes had nothing to be folded against. A data egress straight through the chokepoint. - **`prompts/get`** returns server-authored text that becomes model input, on the same terms. `gated_action` now classifies a message into the action it gates, and both ride the machinery `tools/call` already had: the same grant map, the same content-addressed `mcp_resource`, the same flow labelling, the same metering and debit, and the same **sealed** denial — an ungranted read is recorded, not silently dropped. The enforcement body is unchanged; only the classification moved, which is why all 13 pre-existing tests pass untouched. Each is keyed so a grant is scoped to one resource rather than to the method: `resources/read:file:///ok.txt` is not authority to read `file:///secret.txt`, and there is a test for exactly that. For a read the uri *is* the payload, since it selects the bytes that come back. Discovery — `initialize`, `tools/list`, `resources/list`, `prompts/list`, notifications — still relays untouched, and a test pins that: those move no payload, and gating them would refuse a client the right to see what exists. What a client may see listed is a separate question from what it may read, and deriving the listing from the grant is the next piece (it is also what no incumbent does, because `verify_chain` already makes the filter verifiable offline rather than trusted). Five new tests: an ungranted read is denied and never reaches the server, a granted one is metered and debited, a grant for one uri does not cover another, an ungranted prompt fetch is denied, and discovery still passes through. Two doc comments that described the old behaviour as the design — the module header and `Relayed::PassedThrough` — now say what is actually gated. 381 tests pass across the jcp workspace, 0 clippy findings in the crate. `jcp/SPEC.md` names no MCP method, so there is no spec text to correct. Fourth from the 2026-10-04 sweep's partial set; the three before it are 845e6cc8, aa74283e and a29e65b9. Co-Authored-By: Claude Opus 5 <[email protected]>