sandbox §5.3: an enforced grant field was outside the grant's content address #254

Merged
dcharlot merged 2 commits from dcharlot/bind-egress-credentials into main 2026-10-14 12:31:03 -04:00 AGit
Owner

grants_content_id builds a 27-key CBOR core and never covered g.network.credentials - yet Enforcer::credential_for injects them, so which secret is injected is part of the capability surface. Swapping a handle on an already-allowlisted endpoint left the grant id identical and the mutation undetectable, against SPEC §1.2 (a mutation to the granted capability set MUST invalidate its attestation). EgressCredential::commitment() was written for this, documents itself as binding the decision into an attestation, and had no caller outside its own test; this gives it one. Emitted only when non-empty, so every grant without credentials keeps its id - including the one attestation.json and negative.json pin; all 8 conformance classes pass with no skips. SPEC §5.3 and spec/capabilities.md declared NetworkGrant without the field at all, so both now carry it with its three rules. Second commit removes an assertion of mine that could not fail. Details in the commit messages.

grants_content_id builds a 27-key CBOR core and never covered g.network.credentials - yet Enforcer::credential_for injects them, so which secret is injected is part of the capability surface. Swapping a handle on an already-allowlisted endpoint left the grant id identical and the mutation undetectable, against SPEC §1.2 (a mutation to the granted capability set MUST invalidate its attestation). EgressCredential::commitment() was written for this, documents itself as binding the decision into an attestation, and had no caller outside its own test; this gives it one. Emitted only when non-empty, so every grant without credentials keeps its id - including the one attestation.json and negative.json pin; all 8 conformance classes pass with no skips. SPEC §5.3 and spec/capabilities.md declared NetworkGrant without the field at all, so both now carry it with its three rules. Second commit removes an assertion of mine that could not fail. Details in the commit messages.
`grants_content_id` is the only thing that puts the grant set into a signed
attestation (`Attestation.grants_id`). It hand-builds a CBOR core of 27 keys
and covers `net_outbound` and `net_inbound` — and never
`g.network.credentials`.

Those credentials are enforced. `Enforcer::credential_for` returns the
credential the boundary injects on the unit's behalf for an authorised egress,
so *which* secret is injected is part of the capability surface. Swapping a
handle — `example/test-key` for `example/live-key` on an endpoint already in
`outbound` — changes nothing else about the grant, so the id was identical and
the mutation undetectable. Every attestation sealed under the old set went on
verifying while describing a surface no longer in force.

SPEC §1.2 requires the opposite in as many words: "a mutation to the granted
capability set MUST terminate the run and invalidate its attestation." The
repo already knew: a test beside this one is named
`io_ceiling_is_bound_into_grants_content_id` and opens "SPEC §1.2: an enforced
grant field MUST be part of the grant's content address, so loosening it
mid-run is detectable."

And the binding had been written. `EgressCredential::commitment()` documents
itself as "binds the `(endpoint, secret_ref, mode)` decision into an
attestation", and its only two callers in the tree were in its own test. The
claim in that doc was not true of the code. It is now — this gives it its
first real caller.

The commitment, never the credential, so the handle does not travel in the
grant id; the test asserts the handle is absent from it. `secret_ref` being a
name rather than a key is the whole safety property, and committing to the
credential itself would have spent it.

**Emitted only when the set is non-empty**, which is deliberate and not the
convention two keys above it. `opt_u64` always emits a presence-distinguishing
array because it must tell `None` from `Some(0)`; an empty credential set is
the same capability surface as a grant from before the field existed. So
omitting keeps every such grant's id byte-identical — including the
`b3:ONvIj2bsKw-2Kqe43mNV_3c0rrHTA3Df8xyM6D4ybnY` that `attestation.json` and
`negative.json` pin. All eight conformance classes pass with no skips. Only
grants that actually inject credentials get a new id, which is correct: theirs
was the id that was wrong.

SPEC §5.3 and spec/capabilities.md declared `NetworkGrant { outbound, inbound }`
— the enforced field was absent from the normative type as well as from the
address. Both now carry it, with the three rules: the handle is a name and
never a key, a credential's endpoint MUST also be in `outbound` (already
enforced by `injection_within_egress`), and the set MUST be in the content
address, by commitment.

Co-Authored-By: Claude Opus 5 <[email protected]>
sandbox: drop an assertion of mine that could not fail
All checks were successful
lockfiles / every Cargo.lock is current (pull_request) Successful in 41s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 5s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 4s
no-vanity-metrics / No new orphan source files (pull_request) Successful in 5s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m58s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 3m23s
standards workspaces build + test / aac-rs (pull_request) Successful in 5m0s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 2m7s
standards workspaces build + test / map-rs (pull_request) Successful in 2m6s
standards workspaces build + test / mesh-rs (pull_request) Successful in 3m27s
tests in CI / every test file is run by a job or listed with its reason (pull_request) Successful in 1m20s
spec citations resolve / Code cites no section its SPEC lacks (pull_request) Successful in 5s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m10s
standards workspaces build + test / eoc-rs (pull_request) Successful in 2m3s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m33s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m15s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m15s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m15s
standards workspaces build + test / openpay (pull_request) Successful in 12m20s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m30s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m23s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 4m6s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 20m23s
a79fb5ca46
Falsifying the test above found a defect in the test. It asserted that the
secret handle does not appear in the grant id:

    let core = format!("{:?}", grants_content_id(&live_key));
    assert!(!core.contains("live-key"), ...);

`grants_content_id` returns a BLAKE3 address. No formatting of a hash can
contain its own preimage, whatever went into it — so that assertion passes
against any implementation, including one that commits the handle verbatim. It
checked the hash function, not this code, which is the definition of
decoration.

I only noticed because mutation C — committing `endpoint|secret_ref`, which
really does put the handle in the preimage — was caught by a *different*
assertion (Bearer versus a named header, since that mutation also drops
`inject`). The one written for it stayed green, which is what a vacuous
assertion looks like from the outside.

Removed, with the reason in its place so it is not reintroduced, and a pointer
to the property that is testable: `commitment()` distinguishing its three
fields, which the swap and injection-mode assertions cover, and
`commitment_distinguishes_credentials_without_the_secret` in sandbox-capability
for the shape.

Re-falsified after the change: all three mutations still caught — the binding
disabled, the commitment reduced to the endpoint, and the handle committed
verbatim.

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!254
No description provided.