sandbox §5.3: an enforced grant field was outside the grant's content address #254
Loading…
Reference in a new issue
No description provided.
Delete branch "dcharlot/bind-egress-credentials"
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?
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]>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]>