diff --git a/sandbox/SPEC.md b/sandbox/SPEC.md index a7e7afc66..4f9e4025d 100644 --- a/sandbox/SPEC.md +++ b/sandbox/SPEC.md @@ -135,11 +135,21 @@ A path operation **MUST** match an allow-glob for its access mode or be denied. ### 5.3 Network grant ``` -NetworkGrant { outbound : [Endpoint], inbound : bool } +NetworkGrant { outbound : [Endpoint], + inbound : bool, + credentials : [EgressCredential] } + +EgressCredential { endpoint : Endpoint, secret_ref : Handle, inject : Injection } ``` `outbound` is an allowlist of `host:port` endpoints; an egress to an endpoint not on the list **MUST** be denied. The empty grant denies all network. Network access is never a precondition of the host's own operation (§1.4). +`credentials` names the secrets the **boundary** injects on the unit's behalf, so that a unit can reach an authorized service without ever holding the secret. Three rules, and the third is what makes the other two attestable: + +1. `secret_ref` is a **handle**, not a key — an opaque name the boundary resolves in its own keystore. The grant, and every receipt and attestation over it, therefore carries no secret material. A grant that carries a secret by value does not conform. +2. A credential's `endpoint` **MUST** also appear in `outbound`. Injection can never reach further than egress itself; a credential for an endpoint egress does not allow **MUST** be refused, not silently ignored. +3. The credential set **MUST** be part of the grant's content address (§1.2), committed as the ordered list of per-credential commitments — `BLAKE3` over `(endpoint, secret_ref, inject)` — never as the credentials themselves. Swapping a handle on an endpoint that is already allowlisted changes nothing else about the grant, so without this the mutation is undetectable and every attestation sealed under the old set goes on verifying while describing a surface no longer in force. An empty set is omitted from the address, so a grant that injects nothing addresses as it did before this field existed. + ### 5.4 Key-value and host grants ``` diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index 33ba6a692..4681d73b6 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -517,7 +517,7 @@ pub fn grants_content_id(g: &Grants) -> ContentId { let storage = |v: &Vec| Cbor::Array(v.iter().cloned().map(Cbor::Text).collect()); // Option as a presence-distinguishing array: None → [], Some(v) → [v]. let opt_u64 = |o: Option| Cbor::Array(o.map(|v| vec![Cbor::Uint(v)]).unwrap_or_default()); - let core = Cbor::Map(vec![ + let mut fields = vec![ ( "compute_devices".into(), Cbor::Array(devices), @@ -573,7 +573,46 @@ pub fn grants_content_id(g: &Grants) -> ContentId { "display_max_h".into(), Cbor::Uint(u64::from(g.display.max_height)), ), - ]); + ]; + // The injected egress credentials, by commitment (SPEC §5.3). + // + // These are an enforced grant field: `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. + // It was absent from this core, which meant swapping a `secret_ref` — + // `stripe/test-key` for `stripe/live-key` on an already-allowlisted + // endpoint — left the grant id unchanged, and every attestation sealed + // under it went on verifying while describing a surface no longer in + // force. SPEC §1.2 requires the opposite: "a mutation to the granted + // capability set MUST terminate the run and invalidate its attestation." + // + // `EgressCredential::commitment()` was written for exactly this and had no + // caller outside its own test; its doc already claimed it "binds the + // (endpoint, secret_ref, mode) decision into an attestation". This is that + // claim becoming true. The commitment, not the credential, so the secret + // handle does not travel in the grant id. + // + // Emitted only when the set is non-empty, unlike `opt_u64` above which + // always emits a presence-distinguishing array. The difference is + // deliberate: `opt_u64` must tell `None` from `Some(0)`, while an empty + // credential set is the same capability surface as a grant from before the + // field existed — so omitting it keeps every such grant's content id + // byte-identical, including the one the published pack pins. Only grants + // that actually inject credentials get a new id, which is correct: theirs + // was the id that was wrong. + if !g.network.credentials.is_empty() { + fields.push(( + "net_credentials".into(), + Cbor::Array( + g.network + .credentials + .iter() + .map(|c| Cbor::Text(c.commitment().as_str().to_string())) + .collect(), + ), + )); + } + let core = Cbor::Map(fields); ContentId::of_bytes(&core.encode()) } @@ -903,6 +942,65 @@ mod tests { assert_ne!(grants_content_id(&base), grants_content_id(&bps)); } + #[test] + fn injected_credentials_are_bound_into_grants_content_id() { + // Same rule as the test above, for the field that was missing from it. + // `Enforcer::credential_for` injects on the unit's behalf, so *which* + // secret is injected is part of the capability surface; SPEC §1.2 makes + // a mutation to that surface invalidate the attestation, which it can + // only do if the grant id moves. + use sandbox_capability::{EgressCredential, Injection}; + + let endpoint = "api.example.com:443"; + let with = |secret: &str, inject: Injection| { + let mut g = Grants::default(); + g.network.outbound = vec![endpoint.to_string()]; + g.network.credentials = vec![EgressCredential::new(endpoint, secret, inject)]; + g + }; + let mut none = Grants::default(); + none.network.outbound = vec![endpoint.to_string()]; + + let test_key = with("example/test-key", Injection::Bearer); + let live_key = with("example/live-key", Injection::Bearer); + let as_header = with("example/test-key", Injection::Header("X-Api-Key".into())); + + // Injecting at all is a different surface from not injecting. + assert_ne!(grants_content_id(&none), grants_content_id(&test_key)); + // The defect this test exists for: swapping the handle on an endpoint + // that is already allowlisted. Egress is unchanged, so every other + // field of the grant is identical — before this binding the two sealed + // to the same id and the swap was undetectable. + assert_ne!( + grants_content_id(&test_key), + grants_content_id(&live_key), + "a credential swap on an allowlisted endpoint must move the grant id" + ); + // So is how the secret is applied: Bearer and a named header are + // different requests leaving the boundary. + assert_ne!(grants_content_id(&test_key), grants_content_id(&as_header)); + + // Not asserted here, deliberately: that the handle does not travel in + // the grant id. `grants_content_id` returns a BLAKE3 address, so no + // formatting of it can contain its own preimage whatever went into the + // hash — a test of that shape passes against any implementation, + // including one that commits the secret handle verbatim. It would check + // the hash function, not this code. The property that is testable, and + // is tested above, is that the commitment distinguishes the three + // fields; that the boundary resolves `secret_ref` rather than carrying + // a key is a property of `EgressCredential`'s shape, covered by + // `commitment_distinguishes_credentials_without_the_secret` in + // sandbox-capability. + + // And an empty credential set is the surface a grant had before this + // field existed, so its id must not move — which is what keeps the + // published pack's pinned `grants_id` verifying. + let mut empty = Grants::default(); + empty.network.outbound = vec![endpoint.to_string()]; + empty.network.credentials = vec![]; + assert_eq!(grants_content_id(&none), grants_content_id(&empty)); + } + #[test] fn contention_sensitive_raises_floor_without_a_ceiling() { // SPEC §2.4 enforced in required_tier. diff --git a/sandbox/spec/capabilities.md b/sandbox/spec/capabilities.md index a55914cac..ebfafa6f3 100644 --- a/sandbox/spec/capabilities.md +++ b/sandbox/spec/capabilities.md @@ -57,7 +57,11 @@ The ceilings serialize **only when set** (`skip_serializing_if`): a grant that o ## NetworkGrant ``` -NetworkGrant { outbound : [Endpoint], inbound : bool } +NetworkGrant { outbound : [Endpoint], + inbound : bool, + credentials : [EgressCredential] } // SPEC §5.3 + +EgressCredential { endpoint : Endpoint, secret_ref : Handle, inject : Injection } ``` `outbound` is an allowlist of `host:port` strings; an egress to any endpoint not on the list **MUST** be denied. `inbound = false` denies all inbound connections. The empty grant denies all network. Per SPEC §1.4, the host's own operation never depends on network; a denied egress is a unit-level denial, not a host failure.