jcp: let an action receipt name the attested environment it was recorded in #73

Merged
dcharlot merged 1 commit from jcp/environment-binding into main 2026-09-03 15:11:38 -04:00

View file

@ -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<String>) -> 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<EnvironmentBinding> {
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";