Four things our signed artifacts sealed by name instead of by bytes #153

Merged
dcharlot merged 4 commits from fix/ap2-mandate-authority into main 2026-10-05 08:00:48 -04:00
Owner

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 payee

ingest_ap2_mandate was a JSON field reader — no signature check, no issuer-key resolution, no revocation list — and its output became a ceiling the gate enforces via joule_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_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 Ap2Mandate::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.payee appeared exactly once outside parsing — in a test assertion. So a mandate authorizing attacker.example minted a grant spending against planner.example's work. jcp-x402 already closes this by putting the merchant in the grant audience; the pattern simply was not applied here. Both constructors now refuse unless card.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 name

policy_wire sealed "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 because evaluate returns on the first matching Deny — sealed as policy_digest and recovered by verify.

One new test caught a bug in the 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.

jouleclaw-transparency-log — the consistency proof proved nothing

build_consistency_path returned vec![old_root, new_root] and verify checked 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 SUBPROOF and §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 existing MerkleTree already had correct conventions (0x00/0x01 prefixes, 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 chokepoint

The enforcement point gated on method != "tools/call", so resources/read (server bytes → model) and prompts/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_action now classifies all three onto the same machinery — same grant map, same content-addressed mcp_resource, same flow labelling, same metering, same sealed denial. Keys carry the uri/name so a grant for resources/read:file:///ok.txt is not authority to read file:///secret.txt. The enforcement body is unchanged; only the classification moved, which is why all 13 pre-existing tests pass untouched. Discovery (initialize, the */list methods) 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 current main; no file here was modified on main since 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 MCP extensions capability is unannounced, and CascadeLayer::FactLut is routed to but never constructed.

🤖 Generated with Claude Code

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 payee `ingest_ap2_mandate` was a JSON field reader — no signature check, no issuer-key resolution, no revocation list — and its output became a ceiling the gate enforces via `joule_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_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 `Ap2Mandate::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.payee` appeared exactly once outside parsing — in a test assertion. So a mandate authorizing `attacker.example` minted a grant spending against `planner.example`'s work. `jcp-x402` already closes this by putting the merchant in the grant audience; the pattern simply was not applied here. Both constructors now refuse unless `card.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 name `policy_wire` sealed `"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 because `evaluate` returns on the first matching `Deny` — sealed as `policy_digest` and recovered by `verify`. One new test caught a bug in the 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. ### `jouleclaw-transparency-log` — the consistency proof proved nothing `build_consistency_path` returned `vec![old_root, new_root]` and `verify` checked 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 `SUBPROOF` and §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 existing `MerkleTree` already had correct conventions (`0x00`/`0x01` prefixes, `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 chokepoint The enforcement point gated on `method != "tools/call"`, so `resources/read` (server bytes → model) and `prompts/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_action` now classifies all three onto the same machinery — same grant map, same content-addressed `mcp_resource`, same flow labelling, same metering, same **sealed** denial. Keys carry the uri/name so a grant for `resources/read:file:///ok.txt` is not authority to read `file:///secret.txt`. The enforcement body is unchanged; only the classification moved, which is why all 13 pre-existing tests pass untouched. Discovery (`initialize`, the `*/list` methods) 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 current `main`; no file here was modified on `main` since 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 MCP `extensions` capability is unannounced, and `CascadeLayer::FactLut` is routed to but never constructed. 🤖 Generated with [Claude Code](https://claude.com/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]>
jcp-gateway: two doors were open in the chokepoint
All checks were successful
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m4s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m58s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m29s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m46s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 9m48s
standards workspaces build + test / aac-rs (pull_request) Successful in 4m19s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 3m19s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m43s
standards workspaces build + test / map-rs (pull_request) Successful in 2m0s
standards workspaces build + test / mesh-rs (pull_request) Successful in 1m28s
standards workspaces build + test / openpay (pull_request) Successful in 9m13s
standards workspaces build + test / arl-rs (pull_request) Successful in 5m9s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m4s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 5m5s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m42s
standards workspaces build + test / sense-rs (pull_request) Successful in 5m4s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m56s
d943957278
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]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!153
No description provided.