diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 12b2efc34..8d3256cb7 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -10,7 +10,7 @@ use ed25519_dalek::SigningKey; use jcp_runtime::Decision; -use jcp_schema::{Frame, Grant, Verdict}; +use jcp_schema::{ContentId, Frame, Grant, Verdict}; use serde_json::{json, Value}; pub mod jcr1; @@ -81,6 +81,89 @@ fn to_jcr1_wire_v1(frame: &Frame, grant: &Grant, decision: &Decision, steward: & /// Project a decision to JCR-1 wire JSON — the [`PROFILE`] (`jcp/2`) core /// (SPEC §8). A flat uint/text map; fields that don't apply (a deny reason on an /// allow) are omitted. +/// The attested environment a decision was recorded in. +/// +/// # The hole this closes +/// +/// A JCP receipt proves that *this steward decided this, and signed it*. It says +/// nothing about the machine the steward was running on. Sandbox roots a +/// separate chain in hardware — a measured boot, a TEE quote, an append-only +/// audit head — and until now the two chains never referred to each other. Both +/// halves existed and neither named the other, so a compromised harness could +/// emit a perfectly valid, perfectly verifiable chain of decisions. +/// +/// This is the reference from the action chain to the attested one. +/// +/// # What carrying it does NOT establish +/// +/// It is a **pointer, not evidence**. A host that will misreport its decisions +/// can equally write an attestation id it did not earn. The binding becomes +/// worth something only when a verifier resolves that id, checks the +/// attestation verifies under a Supervisor key it trusts, and confirms the +/// attestation actually covers this host and this time window — all of which is +/// `sandbox-attest`'s job, not this crate's. `jcp-receipt` takes no dependency +/// on `sandbox-attest` and performs no resolution. +/// +/// So a receipt carrying an environment is not "attested"; it is *claiming an +/// environment a verifier can go and check*. The difference is the whole point, +/// and [`Decision`]-side code must not collapse it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EnvironmentBinding { + /// Content address of the sandbox attestation for the run this host is + /// inside. + pub attestation_id: ContentId, + /// The isolation tier that attestation claims, as its wire tag. Carried so + /// a reader can see the claim without resolving it — and so a mismatch + /// against the resolved attestation is detectable. + pub tier: String, +} + +impl EnvironmentBinding { + /// Name the attestation this host is running under. + pub fn new(attestation_id: ContentId, tier: impl Into) -> Self { + EnvironmentBinding { attestation_id, tier: tier.into() } + } +} + +/// The JCR-1 wire for a decision recorded inside an attested environment. +/// +/// Identical to [`to_jcr1_wire`] when `environment` is `None`, field for field — +/// so every receipt sealed before this existed, and every published vector, +/// canonicalizes to the same bytes and still verifies. +pub fn to_jcr1_wire_in_environment( + frame: &Frame, + grant: &Grant, + decision: &Decision, + steward: &str, + environment: Option<&EnvironmentBinding>, +) -> Value { + let mut v = to_jcr1_wire(frame, grant, decision, steward); + if let (Some(env), Some(obj)) = (environment, v.as_object_mut()) { + obj.insert( + "environment".into(), + json!({ "attestation": env.attestation_id.to_hex(), "tier": env.tier }), + ); + } + v +} + +/// A **relying-party** check: refuse a receipt that does not name the +/// environment it was recorded in. +/// +/// Passing does not mean the environment was verified — see +/// [`EnvironmentBinding`]. It means the receipt points at an attestation, which +/// the caller must now resolve. A caller that stops here has learned only that +/// the host was willing to name something. +pub fn environment_of(wire: &Value) -> Option { + let env = wire.get("environment")?; + let id = env.get("attestation").and_then(Value::as_str)?; + let tier = env.get("tier").and_then(Value::as_str)?; + Some(EnvironmentBinding { + attestation_id: ContentId::from_hex(id).ok()?, + tier: tier.to_string(), + }) +} + pub fn to_jcr1_wire(frame: &Frame, grant: &Grant, decision: &Decision, steward: &str) -> Value { let mut obj = serde_json::Map::new(); obj.insert("jcp_receipt".into(), json!(PROFILE)); @@ -718,6 +801,78 @@ mod tests { /// preimage — or the bump silently invalidated every receipt in the wild. + /// Absent, the field changes nothing: the same bytes, so every receipt + /// sealed before this existed and every published vector still verifies. + #[test] + fn an_absent_environment_leaves_the_bytes_untouched() { + let (frame, grant, d) = scenario(); + let steward = "did:example:steward"; + let plain = to_jcr1_wire(&frame, &grant, &d, steward); + let none = to_jcr1_wire_in_environment(&frame, &grant, &d, steward, None); + assert_eq!(plain, none, "None must be byte-for-byte the historical wire"); + assert!(environment_of(&plain).is_none()); + } + + /// Present, it names the attested run — and the claimed tier rides along so + /// a mismatch against the resolved attestation is detectable without + /// resolving it first. + #[test] + fn a_named_environment_reaches_the_wire_and_reads_back() { + let (frame, grant, d) = scenario(); + let att = ContentId::of_bytes(b"attestation"); + let env = EnvironmentBinding::new(att, "S5"); + let wire = to_jcr1_wire_in_environment(&frame, &grant, &d, "did:example:steward", Some(&env)); + + assert_ne!( + wire, + to_jcr1_wire(&frame, &grant, &d, "did:example:steward"), + "the binding must be inside the signed bytes, not beside them" + ); + let read = environment_of(&wire).expect("round-trips"); + assert_eq!(read, env); + assert_eq!(read.tier, "S5"); + } + + /// The distinction the type exists to preserve: carrying an attestation id + /// is a POINTER, not evidence. Nothing here resolves or appraises it, and a + /// host that will misreport a decision can equally name an attestation it + /// did not earn — so `environment_of` returning `Some` must never be read + /// as "attested". + #[test] + fn a_pointer_is_not_evidence() { + let (frame, grant, d) = scenario(); + // An id that resolves to nothing at all is still structurally valid. + let bogus = EnvironmentBinding::new(ContentId::of_bytes(b"never-existed"), "S7"); + let wire = + to_jcr1_wire_in_environment(&frame, &grant, &d, "did:example:steward", Some(&bogus)); + assert!( + environment_of(&wire).is_some(), + "the crate accepts the claim; it is the verifier's job to resolve it" + ); + // And the crate takes no dependency that could resolve it — the check + // that matters lives in sandbox-attest, deliberately not here. + assert_eq!(environment_of(&wire).unwrap().tier, "S7"); + } + + /// A malformed binding reads as absent rather than as a partial claim. + #[test] + fn a_malformed_environment_is_not_half_believed() { + let (frame, grant, d) = scenario(); + let base = to_jcr1_wire(&frame, &grant, &d, "s"); + + // Missing the attestation id entirely. + let mut wire = base.clone(); + wire.as_object_mut().unwrap().insert("environment".into(), json!({ "tier": "S5" })); + assert!(environment_of(&wire).is_none(), "a tier alone names nothing"); + + // Present but unparseable as a content id. + let mut wire = base; + wire.as_object_mut() + .unwrap() + .insert("environment".into(), json!({ "attestation": "zz", "tier": "S5" })); + assert!(environment_of(&wire).is_none(), "an unparseable id is not a claim"); + } + #[test] fn published_v1_receipts_still_verify() { const V1_ALLOW: &str = "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901d2ae65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a181e677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f38715642697948677665726469637465616c6c6f77686772616e745f6964784033623161356139636262373831353134636637353162613030376530373765383038333937376234663037623130346433366565393034643932383939386334687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a18646a6672616d655f6b696e6469746f6f6c2d63616c6c6b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a184658405a7be56b33956c069d4e23127baa7b090e5028b9704dff6ca5ac46da546534e667151f80d7d7a605bcc31ea2bb33ec971e3e8b8794f7248a18b34c33bc438509";