jcp: let an action receipt name the attested environment it was recorded in #73
1 changed files with 156 additions and 1 deletions
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue