sandbox: injected egress credentials — the secret never enters the unit (+ sandbox-jcp regression fix) #33
4 changed files with 163 additions and 2 deletions
1
sandbox/sandbox-rs/Cargo.lock
generated
1
sandbox/sandbox-rs/Cargo.lock
generated
|
|
@ -1764,6 +1764,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"sandbox-schema",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -10,3 +10,6 @@ description = "Sandbox — the capability-grant model and deny-by-default enforc
|
|||
[dependencies]
|
||||
sandbox-schema.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -81,12 +81,82 @@ pub struct StorageGrant {
|
|||
pub max_read_bps: Option<u64>,
|
||||
}
|
||||
|
||||
/// Network grant: an outbound `host:port` allowlist and an inbound flag
|
||||
/// (SPEC §5.3).
|
||||
/// How the boundary applies an injected secret to an outbound request. The mode
|
||||
/// names the *shape* of the injection; it never carries the secret.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Injection {
|
||||
/// `Authorization: Bearer <secret>`.
|
||||
Bearer,
|
||||
/// A named request header, e.g. `X-Api-Key: <secret>`.
|
||||
Header(String),
|
||||
/// A query parameter, e.g. `?api_key=<secret>`.
|
||||
Query(String),
|
||||
/// HTTP Basic auth with this username and `<secret>` as the password.
|
||||
BasicAuth(String),
|
||||
}
|
||||
|
||||
/// A credential the boundary injects for one authorized egress endpoint — held
|
||||
/// by **reference**, never by value.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EgressCredential {
|
||||
/// The egress endpoint this credential is injected for. MUST also appear in
|
||||
/// the grant's `outbound` allowlist — injection can never reach further than
|
||||
/// egress itself (enforced by [`NetworkGrant::injection_within_egress`]).
|
||||
pub endpoint: String,
|
||||
/// An opaque **handle** the enforcement boundary resolves to secret material
|
||||
/// in its own keystore. This is the whole safety property: it is a name, not
|
||||
/// a key, so the grant (and any receipt over it) carries no secret.
|
||||
pub secret_ref: String,
|
||||
/// How the resolved secret is applied to the request.
|
||||
pub inject: Injection,
|
||||
}
|
||||
|
||||
impl EgressCredential {
|
||||
pub fn new(endpoint: impl Into<String>, secret_ref: impl Into<String>, inject: Injection) -> Self {
|
||||
EgressCredential { endpoint: endpoint.into(), secret_ref: secret_ref.into(), inject }
|
||||
}
|
||||
/// A content-addressed commitment to *which* credential was injected —
|
||||
/// binds the `(endpoint, secret_ref, mode)` decision into an attestation
|
||||
/// **without** exposing the secret, since `secret_ref` is a handle.
|
||||
pub fn commitment(&self) -> ContentId {
|
||||
ContentId::of_bytes(
|
||||
format!("egress-cred\u{1f}{}\u{1f}{}\u{1f}{:?}", self.endpoint, self.secret_ref, self.inject).as_bytes(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Network grant: an outbound `host:port` allowlist, an inbound flag, and any
|
||||
/// boundary-injected egress credentials (SPEC §5.3).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkGrant {
|
||||
pub outbound: Vec<String>,
|
||||
pub inbound: bool,
|
||||
/// **Injected egress credentials** (SPEC §5.x). Each entry says: for this
|
||||
/// authorized `endpoint`, the enforcement boundary applies the secret named
|
||||
/// by `secret_ref`, resolving the material from its own keystore **outside
|
||||
/// the sandbox** — so the secret never enters the executing unit.
|
||||
///
|
||||
/// The unit therefore cannot read, log, or exfiltrate a credential it was
|
||||
/// never given; a compromised or hallucinating agent inside the sandbox
|
||||
/// makes authenticated calls without ever holding the key. The grant carries
|
||||
/// only the *reference* (`secret_ref`) — never the bytes — so neither the
|
||||
/// grant nor any receipt over it exposes secret material.
|
||||
///
|
||||
/// Empty (the default) omits from the canonical encoding, so a grant with no
|
||||
/// injected credentials is byte-identical to one minted before this existed.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub credentials: Vec<EgressCredential>,
|
||||
}
|
||||
|
||||
impl NetworkGrant {
|
||||
/// The consistency invariant: every injected credential's endpoint is also on
|
||||
/// the `outbound` allowlist. A credential for an endpoint the unit may not
|
||||
/// even reach is a policy error — validate a grant with this at mint time.
|
||||
/// (The [`Enforcer`] is defensive regardless: it never injects for a
|
||||
/// non-allowlisted endpoint.)
|
||||
pub fn injection_within_egress(&self) -> bool {
|
||||
self.credentials.iter().all(|c| self.outbound.iter().any(|e| e == &c.endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
/// Key-value grant: direction flags plus a set of permitted key prefixes
|
||||
|
|
@ -278,6 +348,24 @@ impl<'a> Enforcer<'a> {
|
|||
self.grants.network.inbound
|
||||
}
|
||||
|
||||
/// The credential the boundary injects for an **authorized** egress to
|
||||
/// `endpoint`, or `None` (SPEC §5.x).
|
||||
///
|
||||
/// Called by the enforcement boundary, never by the executing unit: the unit
|
||||
/// asks to reach an endpoint (checked by [`may_egress`](Self::may_egress)),
|
||||
/// and the boundary — outside the sandbox — resolves and applies the secret.
|
||||
/// A credential is returned only when the endpoint is *both* on the egress
|
||||
/// allowlist and has a declared credential, so injection can never exceed
|
||||
/// egress even if the grant is malformed. The returned value carries only the
|
||||
/// `secret_ref` handle; the material lives in the boundary keystore, never in
|
||||
/// the unit.
|
||||
pub fn credential_for(&self, endpoint: &str) -> Option<&EgressCredential> {
|
||||
if !self.may_egress(endpoint) {
|
||||
return None; // never inject where egress itself is denied
|
||||
}
|
||||
self.grants.network.credentials.iter().find(|c| c.endpoint == endpoint)
|
||||
}
|
||||
|
||||
/// May the unit perform this kv access on this key? (SPEC §5.4)
|
||||
pub fn may_kv(&self, dir: KvDir, key: &[u8]) -> bool {
|
||||
let dir_ok = match dir {
|
||||
|
|
@ -414,6 +502,73 @@ mod tests {
|
|||
assert!(!e.may_egress("evil.example.com:443"));
|
||||
}
|
||||
|
||||
/// The boundary injects a credential only for an AUTHORIZED egress, and only
|
||||
/// as a reference — the secret material is never in the grant.
|
||||
#[test]
|
||||
fn credential_injected_only_for_authorized_egress() {
|
||||
let mut g = Grants::default();
|
||||
g.network.outbound = vec!["api.stripe.com:443".into()];
|
||||
g.network.credentials =
|
||||
vec![EgressCredential::new("api.stripe.com:443", "stripe/live-key", Injection::Bearer)];
|
||||
let e = Enforcer::new(&g);
|
||||
|
||||
// The boundary gets a credential for the allowlisted endpoint…
|
||||
let c = e.credential_for("api.stripe.com:443").expect("credential for authorized egress");
|
||||
assert_eq!(c.secret_ref, "stripe/live-key");
|
||||
assert_eq!(c.inject, Injection::Bearer);
|
||||
|
||||
// …and NONE for an endpoint egress does not permit — injection ⊆ egress,
|
||||
// enforced even though no credential is declared for it either.
|
||||
assert!(e.credential_for("evil.example.com:443").is_none());
|
||||
|
||||
// The grant holds only a reference. Its serialization contains the
|
||||
// handle, never anything resembling key material.
|
||||
let json = serde_json::to_string(&g.network).unwrap();
|
||||
assert!(json.contains("stripe/live-key"), "the reference is recorded");
|
||||
assert!(!json.contains("sk_live"), "no secret material is ever present");
|
||||
}
|
||||
|
||||
/// Defence in depth: even a MALFORMED grant (a credential for an endpoint not
|
||||
/// on the egress allowlist) cannot cause an out-of-policy injection.
|
||||
#[test]
|
||||
fn injection_can_never_exceed_egress() {
|
||||
let mut g = Grants::default();
|
||||
g.network.outbound = vec!["api.ok.com:443".into()];
|
||||
g.network.credentials = vec![
|
||||
EgressCredential::new("api.ok.com:443", "ok/key", Injection::Header("X-Api-Key".into())),
|
||||
EgressCredential::new("api.leak.com:443", "leak/key", Injection::Bearer), // NOT on allowlist
|
||||
];
|
||||
// The consistency check flags the malformed grant at mint time…
|
||||
assert!(!g.network.injection_within_egress());
|
||||
// …and the enforcer refuses to inject the stray credential regardless,
|
||||
// because credential_for gates on may_egress first.
|
||||
let e = Enforcer::new(&g);
|
||||
assert!(e.credential_for("api.leak.com:443").is_none(), "no egress ⇒ no injection");
|
||||
assert!(e.credential_for("api.ok.com:443").is_some());
|
||||
}
|
||||
|
||||
/// The commitment binds WHICH credential was injected, without the secret —
|
||||
/// the audit primitive an attestation seals.
|
||||
#[test]
|
||||
fn commitment_distinguishes_credentials_without_the_secret() {
|
||||
let a = EgressCredential::new("api.x.com:443", "team/key-a", Injection::Bearer);
|
||||
let b = EgressCredential::new("api.x.com:443", "team/key-b", Injection::Bearer);
|
||||
let same = EgressCredential::new("api.x.com:443", "team/key-a", Injection::Bearer);
|
||||
assert_ne!(a.commitment(), b.commitment(), "different secret_ref ⇒ different commitment");
|
||||
assert_eq!(a.commitment(), same.commitment(), "same decision ⇒ same commitment");
|
||||
}
|
||||
|
||||
/// Backward compatibility: a grant with no injected credentials serializes
|
||||
/// exactly as it did before the field existed — the published capability
|
||||
/// vectors are unaffected.
|
||||
#[test]
|
||||
fn empty_credentials_are_omitted_from_the_encoding() {
|
||||
let g = NetworkGrant { outbound: vec!["a:443".into()], inbound: false, credentials: vec![] };
|
||||
let json = serde_json::to_string(&g).unwrap();
|
||||
assert!(!json.contains("credentials"), "an empty credential set must not appear");
|
||||
assert_eq!(json, r#"{"outbound":["a:443"],"inbound":false}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_direction_and_prefix() {
|
||||
let mut g = Grants::default();
|
||||
|
|
|
|||
|
|
@ -207,6 +207,8 @@ mod tests {
|
|||
subject: did(&agent),
|
||||
capability: Capability::new(image_resource(image), [EXECUTE_ACTION]),
|
||||
budget_uj: budget,
|
||||
budget_funds: None,
|
||||
caveats: Vec::new(),
|
||||
expiry: 0,
|
||||
parent: None,
|
||||
delegate_depth: None,
|
||||
|
|
|
|||
Loading…
Reference in a new issue