diff --git a/sandbox/SPEC.md b/sandbox/SPEC.md index 5f88f93ce..9c1f06343 100644 --- a/sandbox/SPEC.md +++ b/sandbox/SPEC.md @@ -275,7 +275,27 @@ The host **MUST** normalize hostnames, paths, process tree, time, timezone, and ### 7.6 TEE evidence (S6+) -At S6 and above, `tee` **MUST** carry a summary of the hardware attestation report (`kind ∈ { SevSnp, Tdx, ArmCca }`, the launch measurement, and the platform TCB) so a verifier can establish the boundary was hardware-enforced. A host **MUST** obtain the report from the platform's attestation interface, bind it to the run by placing a value derived from the run identity (the unit and receipt content addresses) in the report's 64-byte `report_data` field, and **MUST** verify the report — its signature against the platform's VCEK and its measurement/TCB/debug-state/report-data against policy — before sealing the attestation. A host **MUST NOT** present a `tee` summary it did not verify. +At S6 and above, `tee` **MUST** carry a summary of the hardware attestation report (`kind ∈ { SevSnp, Tdx, ArmCca }`, the launch measurement, and the platform TCB) so a verifier can establish the boundary was hardware-enforced. A host **MUST** obtain the report from the platform's attestation interface, bind it to the run through the report's 64-byte `report_data` field, and **MUST** verify the report — its signature against the platform's VCEK and its measurement/TCB/debug-state/report-data against policy — before sealing the attestation. A host **MUST NOT** present a `tee` summary it did not verify. + +**The binding.** `report_data` is one preimage over everything a report vouches for: + +```text +report_data = BLAKE3-XOF-64(derive_key = "sandbox/1 run report_data v1", + len‖unit_id ‖ len‖receipt_id ‖ len‖JCS(outcome) ‖ challenge_tag) +challenge_tag = 0x01 ‖ challenge[32] when the relying party issued one + = 0x00 otherwise +``` + +- `unit_id` and `receipt_id` are the content addresses as text, and `JCS(outcome)` is the outcome as the attestation core carries it (§7.1). Each is preceded by its length as a little-endian `u64`. +- The receipt binds the metered energy (§6.1), so the report vouches for the joules through it. +- The `challenge` is 32 fresh bytes from the party that asked for the run. With it, that party can tell a report made for its request from one made for another. Without it, a report shows which run it is for, but not that it is fresh. A challenge's presence is part of the preimage, so no challenge is not a challenge of zeros. + +**Carried for a third party.** The `tee` object also carries `report_hex`, the report itself, and `challenge_hex` where there was a challenge. Both are omitted when absent, so evidence sealed before they existed keeps its bytes. A verifier other than the host then checks: +- the report's signature and policy; +- that its `report_data` is the preimage above, for the attestation's unit, receipt and outcome and **the challenge the verifier itself issued**, never the one the attestation records; +- that the summary is the report's. + +Evidence without its report **MUST NOT** be taken as verified by anyone but the host that sealed it. The reference splits this honestly (§9): *verification* — parsing the SEV-SNP report, the policy checks, and the ECDSA-P384/SHA-384 signature — is implemented and vectored (`sandbox-tee`). *Generation* of a genuine vendor-signed report requires the CPU's security processor: the reference `configfs-tsm` provider requests one on Linux confidential hardware and reports `Unavailable` elsewhere, and a software-grounded provider (explicitly tagged, never claiming a hardware root) covers wiring and tests. @@ -311,7 +331,7 @@ Locality evidence is **timing-derived**, and this specification already holds th The native attestation (§7.3) is the standard's own signed form. A host **SHOULD** additionally emit the attestation as an **EAT** (Entity Attestation Token, RFC 9711) — a CWT (RFC 8392) signed as a COSE_Sign1/EdDSA token — so a generic RATS (RFC 9334) verifier can appraise it without understanding this format. The EAT is a *view* of the attestation, signed by the same Supervisor key; it is not a second source of truth. -The claims-set carries the IANA-registered EAT/CWT claims `iat` (6), `nonce` (10) — bound to the run as `BLAKE3(unit_id ‖ receipt_id)`, `ueid` (256), `eat_profile` (265) = `https://transaction.science/sandbox/eat/v1`, and `measurements` (273) — plus a profile-scoped private claim referencing the canonical attestation by content address and naming the tier and receipt. A verifier checks the COSE_Sign1 signature and `alg = EdDSA`, the `eat_profile`, and (for this profile) resolves the referenced attestation. See `spec/eat.md`; the reference implements both directions (`sandbox-attest::{seal_eat, verify_eat}`) and every sealed run carries the EAT alongside the native attestation. +The claims-set carries the IANA-registered EAT/CWT claims `iat` (6), `nonce` (10) — bound to the run as `BLAKE3(unit_id ‖ receipt_id)`, `ueid` (256), `eat_profile` (265) = `https://transaction.science/sandbox/eat/v1`, and `measurements` (273) — plus a profile-scoped private claim referencing the canonical attestation by content address and naming the tier and receipt. A verifier checks the COSE_Sign1 signature and `alg = EdDSA`, the `eat_profile`, and (for this profile) resolves the referenced attestation. A verifier that holds the run's receipt **MUST** also check that `nonce` is that run's binding: without it, any token the Supervisor key signed verifies, whichever run it is for. See `spec/eat.md`; the reference implements both directions (`sandbox-attest::{seal_eat, verify_eat}`) and every sealed run carries the EAT alongside the native attestation. ### 7.9 Enforcement mechanism (S1–S5; RECOMMENDED) diff --git a/sandbox/sandbox-rs/Cargo.lock b/sandbox/sandbox-rs/Cargo.lock index 93c46159e..f8976c5f9 100644 --- a/sandbox/sandbox-rs/Cargo.lock +++ b/sandbox/sandbox-rs/Cargo.lock @@ -1878,6 +1878,7 @@ version = "0.1.0" dependencies = [ "blake3", "ed25519-dalek", + "hex", "p384", "sandbox-attest", "sandbox-capability", diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs index ca6c3501c..7b0c5e82a 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs @@ -20,6 +20,7 @@ //! - `PROFILE_CLAIM` (private) — `{ attestation_id, tier, receipt_id }` use crate::{AttestError, Attestation}; +use sandbox_schema::ContentId; use ed25519_dalek::{SigningKey, VerifyingKey}; /// The EAT profile URI for a Sandbox attestation (RFC 9711 `eat_profile`). @@ -203,10 +204,7 @@ impl Attestation { pub fn eat_claims_cbor(&self) -> Result, AttestError> { let att_id = self.content_id()?; let ueid = blake3::hash(self.host_id.as_bytes()); - let mut nonce_in = Vec::new(); - nonce_in.extend_from_slice(self.unit_id.as_str().as_bytes()); - nonce_in.extend_from_slice(self.receipt_id.as_str().as_bytes()); - let nonce = blake3::hash(&nonce_in); + let nonce = run_nonce(&self.unit_id, &self.receipt_id); let tier = self.tier.wire_tag().to_string(); let mut measurement = vec![(1i64, Val::T(tier.clone()))]; @@ -217,7 +215,7 @@ impl Attestation { let claims = Val::Map(vec![ (CLAIM_IAT, Val::U(self.finished_at_us / 1_000_000)), - (CLAIM_NONCE, Val::B(nonce.as_bytes().to_vec())), + (CLAIM_NONCE, Val::B(nonce.to_vec())), (CLAIM_UEID, Val::B(ueid.as_bytes()[..16].to_vec())), (CLAIM_EAT_PROFILE, Val::T(EAT_PROFILE.to_string())), (CLAIM_MEASUREMENTS, Val::Arr(vec![Val::Map(measurement)])), @@ -246,10 +244,26 @@ impl Attestation { /// Verify a Sandbox EAT: check the COSE_Sign1 signature and `alg = EdDSA`, /// optionally the `kid`, confirm the profile, and return the recovered claims. +/// The nonce an EAT carries for a run (SPEC §7.8): the BLAKE3 of the unit's +/// content address followed by its receipt's. A verifier that holds the run's +/// receipt computes it from there and passes it to [`verify_eat`]. +pub fn run_nonce(unit_id: &ContentId, receipt_id: &ContentId) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(unit_id.as_str().as_bytes()); + h.update(receipt_id.as_str().as_bytes()); + *h.finalize().as_bytes() +} + +/// Verify an EAT: its COSE signature under `vk`, its `kid` where one is +/// expected, its profile, and, where the verifier holds the run it attests, +/// that its nonce is that run's ([`run_nonce`]). Without `expect_nonce` +/// nothing ties the token to a particular run: any token the key signed +/// verifies. pub fn verify_eat( cwt: &[u8], vk: &VerifyingKey, expect_kid: Option<&str>, + expect_nonce: Option<&[u8; 32]>, ) -> Result { let decoded = sandbox_meter::jcr1::open(cwt).map_err(|e| AttestError::Eat(format!("open: {e}")))?; @@ -263,6 +277,15 @@ pub fn verify_eat( claims.profile ))); } + if let Some(n) = expect_nonce { + if claims.nonce_hex != hex(n) { + return Err(AttestError::Eat(format!( + "nonce {} is not the run's {}", + claims.nonce_hex, + hex(n) + ))); + } + } Ok(claims) } @@ -356,7 +379,7 @@ mod tests { let a = att(); let cwt = a.seal_eat("steward.eat", &sk).unwrap(); - let claims = verify_eat(&cwt, &vk, Some("steward.eat")).unwrap(); + let claims = verify_eat(&cwt, &vk, Some("steward.eat"), None).unwrap(); assert_eq!(claims.profile, EAT_PROFILE); assert_eq!(claims.tier, "S2"); assert_eq!(claims.iat_secs, 2); @@ -373,7 +396,7 @@ mod tests { let vk = sk.verifying_key(); let mut cwt = att().seal_eat("k", &sk).unwrap(); *cwt.last_mut().unwrap() ^= 1; // flip a signature bit - assert!(verify_eat(&cwt, &vk, None).is_err()); + assert!(verify_eat(&cwt, &vk, None, None).is_err()); } #[test] @@ -381,6 +404,24 @@ mod tests { let sk = SigningKey::from_bytes(&[5u8; 32]); let other = SigningKey::from_bytes(&[6u8; 32]).verifying_key(); let cwt = att().seal_eat("k", &sk).unwrap(); - assert!(verify_eat(&cwt, &other, None).is_err()); + assert!(verify_eat(&cwt, &other, None, None).is_err()); + } + + /// A verifier that holds the run checks the token is that run's: the + /// nonce is the run's binding, and a token for another run, signed by the + /// same key, is refused. Before, any token the key signed verified. + #[test] + fn an_eat_is_checked_against_the_run_the_verifier_holds() { + let sk = SigningKey::from_bytes(&[5u8; 32]); + let vk = sk.verifying_key(); + let a = att(); + let cwt = a.seal_eat("k", &sk).unwrap(); + let ours = run_nonce(&a.unit_id, &a.receipt_id); + verify_eat(&cwt, &vk, Some("k"), Some(&ours)).unwrap(); + + let other_receipt = run_nonce(&a.unit_id, &ContentId::of_bytes(b"another receipt")); + assert!(verify_eat(&cwt, &vk, Some("k"), Some(&other_receipt)).is_err()); + let other_unit = run_nonce(&ContentId::of_bytes(b"another unit"), &a.receipt_id); + assert!(verify_eat(&cwt, &vk, Some("k"), Some(&other_unit)).is_err()); } } diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs index d282f9c43..b1b21c507 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs @@ -22,7 +22,7 @@ pub mod runrecord; pub mod warmpool; pub use audit::AuditLog; -pub use eat::{verify_eat, EatClaims, EAT_PROFILE}; +pub use eat::{run_nonce, verify_eat, EatClaims, EAT_PROFILE}; pub use locality::{AnchorObservation, LocalityEvidence, LocalityVerdict}; pub use journal::{compare as replay_compare, Journal, JournalEntry, Replay}; @@ -183,6 +183,18 @@ pub enum Outcome { Terminated { breach: Breach }, } +impl Outcome { + /// The outcome as the attestation core carries it (SPEC §7.1), and as the + /// TEE report_data binds it (SPEC §7.6). + pub fn core(&self) -> Value { + match self { + Outcome::Completed => json!({ "state": "Completed" }), + Outcome::Failed => json!({ "state": "Failed" }), + Outcome::Terminated { breach } => json!({ "state": "Terminated", "breach": breach.label() }), + } + } +} + /// The kind of trusted execution environment for S6+ evidence (SPEC §7.6). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum TeeKind { @@ -208,6 +220,16 @@ pub struct TeeEvidence { /// The launch measurement, hex-encoded for the JSON core. pub measurement_hex: String, pub tcb: String, + /// The hardware report itself, hex-encoded, so that a party other than + /// the host can verify it, and the summary above against it. Omitted from + /// the core when absent, so evidence sealed before it was carried keeps + /// its bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub report_hex: Option, + /// The relying party's challenge the report binds, hex-encoded, where the + /// run was sealed against one (SPEC §7.6). Omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub challenge_hex: Option, } /// Where a run executed (SPEC §2.2 S7): a region and the jurisdiction it is @@ -396,13 +418,7 @@ impl Attestation { "driver": d.driver, })); } - let outcome = match self.outcome { - Outcome::Completed => json!({ "state": "Completed" }), - Outcome::Failed => json!({ "state": "Failed" }), - Outcome::Terminated { breach } => { - json!({ "state": "Terminated", "breach": breach.label() }) - } - }; + let outcome = self.outcome.core(); let mut core = serde_json::Map::new(); // The profile is `/2` exactly when a mechanism is carried, and the // field is omitted when absent — so a mechanism-free attestation @@ -421,14 +437,20 @@ impl Attestation { } core.insert("outcome".into(), outcome); if let Some(t) = &self.tee { - core.insert( - "tee".into(), - json!({ - "kind": t.kind.label(), - "measurement_hex": t.measurement_hex, - "tcb": t.tcb, - }), - ); + let mut tee = json!({ + "kind": t.kind.label(), + "measurement_hex": t.measurement_hex, + "tcb": t.tcb, + }); + // Carried only when present, so evidence sealed before they existed + // canonicalizes to the bytes it always did. + if let Some(r) = &t.report_hex { + tee["report_hex"] = json!(r); + } + if let Some(c) = &t.challenge_hex { + tee["challenge_hex"] = json!(c); + } + core.insert("tee".into(), tee); } if let Some(l) = &self.locality { core.insert( @@ -507,7 +529,13 @@ mod tests { use super::*; fn tee_ev() -> TeeEvidence { - TeeEvidence { kind: TeeKind::SevSnp, measurement_hex: "00".into(), tcb: "tcb".into() } + TeeEvidence { + kind: TeeKind::SevSnp, + measurement_hex: "00".into(), + tcb: "tcb".into(), + report_hex: None, + challenge_hex: None, + } } fn mech() -> Mechanism { @@ -749,6 +777,8 @@ mod tests { kind: TeeKind::SevSnp, measurement_hex: "00".into(), tcb: "tcb".into(), + report_hex: None, + challenge_hex: None, }; assert!(att(Tier::S5, Some(ev.clone())).validate_structure().is_err()); // S6 with TEE evidence is fine. @@ -761,6 +791,8 @@ mod tests { kind: TeeKind::SevSnp, measurement_hex: "00".into(), tcb: "tcb".into(), + report_hex: None, + challenge_hex: None, }; // S7 with TEE evidence but no locality/audit ⇒ invalid. let mut a = att(Tier::S7, Some(ev)); diff --git a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs index 55359fef1..cf5c90f26 100644 --- a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs @@ -447,7 +447,7 @@ fn resolve_candidate( .attestation .verify(&sealed.attestation_sig, supervisor_vk) .map_err(|e| BuildError::Crypto(format!("attestation: {e}")))?; - sandbox_attest::verify_eat(&sealed.eat, supervisor_vk, Some(steward_kid))?; + sandbox_attest::verify_eat(&sealed.eat, supervisor_vk, Some(steward_kid), Some(&sandbox_attest::run_nonce(&sealed.attestation.unit_id, &sealed.receipt.content_id())))?; let rec = StepRecord { id: label.to_string(), @@ -545,6 +545,7 @@ fn resolve_command( started, started + 1, mechanism, + None, )?; sealed .receipt @@ -554,7 +555,7 @@ fn resolve_command( .attestation .verify(&sealed.attestation_sig, supervisor_vk) .map_err(|e| BuildError::Crypto(format!("attestation: {e}")))?; - sandbox_attest::verify_eat(&sealed.eat, supervisor_vk, Some(steward_kid))?; + sandbox_attest::verify_eat(&sealed.eat, supervisor_vk, Some(steward_kid), Some(&sandbox_attest::run_nonce(&sealed.attestation.unit_id, &sealed.receipt.content_id())))?; let rec = StepRecord { id: label.to_string(), diff --git a/sandbox/sandbox-rs/crates/sandbox-cli/src/main.rs b/sandbox/sandbox-rs/crates/sandbox-cli/src/main.rs index 450557300..d015dd66d 100644 --- a/sandbox/sandbox-rs/crates/sandbox-cli/src/main.rs +++ b/sandbox/sandbox-rs/crates/sandbox-cli/src/main.rs @@ -273,7 +273,7 @@ fn run_wasm() { sealed.attestation.content_id().expect("attestation id") ); // RATS interop: the same attestation as a signed EAT (CWT), verified here. - let eat = sandbox_attest::verify_eat(&sealed.eat, &supervisor_vk, Some("steward.demo")) + let eat = sandbox_attest::verify_eat(&sealed.eat, &supervisor_vk, Some("steward.demo"), Some(&sandbox_attest::run_nonce(&sealed.attestation.unit_id, &sealed.receipt.content_id()))) .expect("EAT verifies"); println!( " eat (RATS): {} bytes, profile {}, tier {} (CWT/COSE_Sign1 verified ✓)", diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs index a44ef5b26..531b6b5a7 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs @@ -533,6 +533,8 @@ fn c12_locality() -> Result<(), String> { kind: sandbox_attest::TeeKind::SevSnp, measurement_hex: "00".into(), tcb: "tcb".into(), + report_hex: None, + challenge_hex: None, }); a.locality = Some(sandbox_attest::Locality { region: "eu-central-1".into(), diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs index 2a04e5418..c4e148b84 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs @@ -368,6 +368,7 @@ fn tier_case( confidential, sovereign, contention_sensitive: false, + challenge: None, }; let expect = required_tier(&req).wire_tag().to_string(); TierVector { @@ -423,6 +424,7 @@ pub fn check_tier(v: &[TierVector]) -> Result<(), String> { confidential: tv.confidential, sovereign: tv.sovereign, contention_sensitive: false, + challenge: None, }; let got = required_tier(&req).wire_tag(); if got != tv.expect_tier { @@ -764,7 +766,7 @@ pub fn check_confidential(pack: &ConfidentialPack) -> Result<(), String> { // ---- the directory of vector files ----------------------------------------- -/// The `conformance/v1` directory, located relative to this crate. +// The `conformance/v1` directory, located relative to this crate. // ---- C6/C7/C10: durable-run, manifest, and fleet wire artifacts ----------- diff --git a/sandbox/sandbox-rs/crates/sandbox-mcp/src/main.rs b/sandbox/sandbox-rs/crates/sandbox-mcp/src/main.rs index bc8396ed3..5e6cda57f 100644 --- a/sandbox/sandbox-rs/crates/sandbox-mcp/src/main.rs +++ b/sandbox/sandbox-rs/crates/sandbox-mcp/src/main.rs @@ -191,7 +191,12 @@ fn tool_defs() -> Value { "type": "object", "properties": { "eat_hex": { "type": "string" }, - "pubkey_hex": { "type": "string" } + "pubkey_hex": { "type": "string" }, + "expected_nonce_hex": { + "type": "string", + "description": "The run's nonce, when the caller holds the run: \ + the token is then refused unless it is that run's." + } }, "required": ["eat_hex", "pubkey_hex"] } @@ -234,7 +239,7 @@ fn run_unit(args: &Value) -> Result { .attestation .verify(&sealed.attestation_sig, &supervisor_vk) .map_err(|e| format!("attestation verify: {e}"))?; - let eat = sandbox_attest::verify_eat(&sealed.eat, &supervisor_vk, Some("steward.mcp")) + let eat = sandbox_attest::verify_eat(&sealed.eat, &supervisor_vk, Some("steward.mcp"), Some(&sandbox_attest::run_nonce(&sealed.attestation.unit_id, &sealed.receipt.content_id()))) .map_err(|e| format!("eat verify: {e}"))?; let devices: Vec = sealed @@ -294,7 +299,19 @@ fn verify_eat_tool(args: &Value) -> Result { .try_into() .map_err(|_| "pubkey must be 32 bytes")?; let vk = ed25519_dalek::VerifyingKey::from_bytes(&pk).map_err(|_| "bad pubkey")?; - let claims = sandbox_attest::verify_eat(&eat, &vk, None).map_err(|e| e.to_string())?; + // A caller that holds the run passes its nonce (`run_nonce` of the unit and + // receipt ids), so a token for another run signed by the same key is + // refused. Without it, only the signature and profile are checked. + let expected: Option<[u8; 32]> = match args.get("expected_nonce_hex").and_then(Value::as_str) { + None => None, + Some(h) => Some( + hex::decode(h) + .map_err(|_| "bad expected_nonce_hex")? + .try_into() + .map_err(|_| "expected_nonce_hex must be 32 bytes")?, + ), + }; + let claims = sandbox_attest::verify_eat(&eat, &vk, None, expected.as_ref()).map_err(|e| e.to_string())?; Ok(json!({ "verified": true, "profile": claims.profile, @@ -302,6 +319,7 @@ fn verify_eat_tool(args: &Value) -> Result { "attestation_id": claims.attestation_id, "receipt_id": claims.receipt_id, "iat_secs": claims.iat_secs, + "nonce_checked": expected.is_some(), })) } diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/Cargo.toml b/sandbox/sandbox-rs/crates/sandbox-runtime/Cargo.toml index 7a64536b3..873107a46 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/Cargo.toml +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/Cargo.toml @@ -16,4 +16,5 @@ sandbox-tee.workspace = true ed25519-dalek.workspace = true p384.workspace = true blake3.workspace = true +hex.workspace = true thiserror.workspace = true diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index 31608c29a..2c755cb2f 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -44,6 +44,11 @@ pub struct TierRequest<'a> { pub direct_device_assignment: bool, /// The unit requires confidential compute against the host (requires S6). pub confidential: bool, + /// The relying party's challenge, for a confidential run (S6+): 32 fresh + /// bytes the TEE report binds (SPEC §7.6), so the party that asked for the + /// run can tell its report from one made for another request. `None`: the + /// report binds no challenge, and shows which run it is for, not when. + pub challenge: Option<[u8; 32]>, /// The run is jurisdiction-bound and audited (requires S7). pub sovereign: bool, /// The unit is sensitive to — or, as an untrusted neighbor, capable of — @@ -64,6 +69,7 @@ impl<'a> TierRequest<'a> { confidential: false, sovereign: false, contention_sensitive: false, + challenge: None, } } } @@ -375,7 +381,7 @@ impl Host { ) -> Result { self.seal_run_with_mechanism( unit_id, image_id, grants, tier, report, sequence, nonce, started_at_us, - finished_at_us, None, + finished_at_us, None, None, ) } @@ -400,6 +406,7 @@ impl Host { started_at_us: u64, finished_at_us: u64, mechanism: Option, + challenge: Option<[u8; 32]>, ) -> Result { // Build and seal the energy receipt (validates per-device provenance, // attribution, and the total). @@ -410,19 +417,25 @@ impl Host { let grants_id = grants_content_id(grants); let journal_root = report.journal.as_ref().map(|j| j.root_id()); - // S6+: obtain and verify a TEE report bound to this run's identity. + // S6+: obtain and verify a TEE report bound to this run: its unit, + // receipt and outcome, and the relying party's challenge. let mut tee = None; if tier >= Tier::S6 { let cfg = self .tee .as_ref() .ok_or(RunError::TeeProviderMissing(tier.wire_tag()))?; - let report_data = tee_report_data(&unit_id, &receipt_id); + let report_data = run_report_data(&unit_id, &receipt_id, &report.outcome, challenge.as_ref()); let report_bytes = cfg.provider.report(&report_data)?; // Pin the report-data binding for this run, then verify. let policy = cfg.policy.clone().with_report_data(report_data); let verified = verify_snp(&report_bytes, &policy, &cfg.vcek)?; - tee = Some(verified.to_evidence()); + // The report travels with its summary, so that a party other than + // this host can verify it, and the summary against it. + let mut evidence = verified.to_evidence(); + evidence.report_hex = Some(hex::encode(&report_bytes)); + evidence.challenge_hex = challenge.map(hex::encode); + tee = Some(evidence); } // S7: bind the run to a locality and link it into the audit chain. @@ -524,20 +537,77 @@ impl Host { started_at_us, finished_at_us, mechanism, + req.challenge, ) } } -/// The 64-byte TEE report-data challenge for a run (SPEC §7.6): it binds the -/// report to this run's identity so a report cannot be replayed onto another -/// run. `report_data[0..32] = BLAKE3(unit_id)`, `[32..64] = BLAKE3(receipt_id)`. -pub fn tee_report_data(unit_id: &ContentId, receipt_id: &ContentId) -> [u8; 64] { +/// The 64-byte TEE `report_data` for a sealed run (SPEC §7.6): one preimage +/// binding the unit, its receipt (and through it the metered energy), how the +/// run ended, and the relying party's challenge, so a report made for one of +/// these cannot be presented for another. +/// +/// It is BLAKE3 in key-derivation mode under [`RUN_REPORT_DATA_CONTEXT`], +/// extended to 64 bytes, over: +/// - the unit's content address, the receipt's, and the JCS encoding of the +/// outcome as the attestation core carries it, each preceded by its length +/// as a little-endian `u64`; +/// - then `0x01` and the 32-byte challenge, or `0x00` alone where there is +/// none. +pub fn run_report_data( + unit_id: &ContentId, + receipt_id: &ContentId, + outcome: &Outcome, + challenge: Option<&[u8; 32]>, +) -> [u8; 64] { + let outcome = sandbox_attest::jcs::canonicalize(&outcome.core()).expect("an outcome canonicalizes"); + let mut h = blake3::Hasher::new_derive_key(RUN_REPORT_DATA_CONTEXT); + for part in [unit_id.0.as_bytes(), receipt_id.0.as_bytes(), &outcome] { + h.update(&(part.len() as u64).to_le_bytes()); + h.update(part); + } + match challenge { + Some(c) => h.update(&[1]).update(c), + None => h.update(&[0]), + }; let mut rd = [0u8; 64]; - rd[0..32].copy_from_slice(blake3::hash(unit_id.0.as_bytes()).as_bytes()); - rd[32..64].copy_from_slice(blake3::hash(receipt_id.0.as_bytes()).as_bytes()); + h.finalize_xof().fill(&mut rd); rd } +/// The BLAKE3 key-derivation context of [`run_report_data`]. +pub const RUN_REPORT_DATA_CONTEXT: &str = "sandbox/1 run report_data v1"; + +/// Verify a sealed run's TEE evidence as a party other than the host +/// (SPEC §7.6). The checks: +/// - the report the attestation carries is signed under `vcek` and satisfies +/// `policy`; +/// - it binds this attestation's unit, receipt and outcome, and the relying +/// party's `challenge`; +/// - it is the report the attestation's summary was taken from. +/// +/// `challenge` is the one the verifier issued, not the one the attestation +/// records, so a report sealed for another request is refused. Evidence that +/// carries no report cannot be verified this way and is refused. +pub fn verify_run_evidence( + attestation: &Attestation, + challenge: Option<&[u8; 32]>, + policy: &TeePolicy, + vcek: &VcekKey, +) -> Result { + let evidence = |why: &str| RunError::Tee(sandbox_tee::TeeError::Policy(why.into())); + let tee = attestation.tee.as_ref().ok_or_else(|| evidence("the attestation carries no TEE evidence"))?; + let report_hex = tee.report_hex.as_ref().ok_or_else(|| evidence("the evidence carries no report"))?; + let report = hex::decode(report_hex).map_err(|_| evidence("the report is not hex"))?; + let expected = run_report_data(&attestation.unit_id, &attestation.receipt_id, &attestation.outcome, challenge); + let verified = verify_snp(&report, &policy.clone().with_report_data(expected), vcek)?; + let summary = verified.to_evidence(); + if (summary.kind, &summary.measurement_hex, &summary.tcb) != (tee.kind, &tee.measurement_hex, &tee.tcb) { + return Err(evidence("the summary is not the report's")); + } + Ok(verified) +} + /// The unit content address (SPEC §3): the address of the unit descriptor, /// which binds the image and the declared grant request. pub fn unit_content_id(image_id: &ContentId, grants_id: &ContentId) -> ContentId { @@ -872,6 +942,131 @@ mod tests { .unwrap(); } + /// A confidential run sealed against a relying party's challenge. + fn sealed_s6(host: &Host, challenge: Option<[u8; 32]>) -> SealedRun { + host.seal_run_with_mechanism( + ContentId::of_bytes(b"u"), + ContentId::of_bytes(b"i"), + &Grants::default(), + Tier::S6, + cpu_report(), + 0, + [0; 8], + 1_000_000, + 2_000_000, + None, + challenge, + ) + .unwrap() + } + + fn verifier() -> (sandbox_tee::policy::TeePolicy, VcekKey) { + let provider = sandbox_tee::provider::LocalSnpProvider::new([0x11; 48], 7); + let policy = sandbox_tee::policy::TeePolicy::sev_snp_baseline().with_measurement([0x11; 48]); + (policy, provider.vcek()) + } + + /// The report a confidential run carries binds the run's unit, receipt and + /// outcome and the relying party's challenge in one preimage, and a party + /// other than the host verifies it from the attestation alone. Before, the + /// report bound the unit and receipt only, under a host-chosen nonce, and + /// was dropped once the host had checked it. + #[test] + fn a_confidential_run_carries_a_report_that_binds_it_and_the_challenge() { + let host = confidential_host(); + let (policy, vcek) = verifier(); + let (ours, theirs) = ([0xC1; 32], [0xC2; 32]); + let sealed = sealed_s6(&host, Some(ours)); + let a = &sealed.attestation; + let tee = a.tee.as_ref().unwrap(); + assert!(tee.report_hex.is_some(), "the report travels with its summary"); + assert_eq!(tee.challenge_hex.as_deref(), Some(hex::encode(ours).as_str())); + verify_run_evidence(a, Some(&ours), &policy, &vcek).unwrap(); + + let refused = |att: &Attestation, c: Option<&[u8; 32]>| verify_run_evidence(att, c, &policy, &vcek).is_err(); + assert!(refused(a, Some(&theirs)), "a report sealed for another request"); + assert!(refused(a, None), "a challenged report presented as unchallenged"); + let mut other_outcome = a.clone(); + other_outcome.outcome = Outcome::Failed; + assert!(refused(&other_outcome, Some(&ours)), "the outcome is bound"); + let mut other_receipt = a.clone(); + other_receipt.receipt_id = ContentId::of_bytes(b"another receipt"); + assert!(refused(&other_receipt, Some(&ours)), "the receipt is bound"); + let mut other_unit = a.clone(); + other_unit.unit_id = ContentId::of_bytes(b"another unit"); + assert!(refused(&other_unit, Some(&ours)), "the unit is bound"); + let mut relabeled = a.clone(); + relabeled.tee.as_mut().unwrap().measurement_hex = hex::encode([0x22; 48]); + assert!(refused(&relabeled, Some(&ours)), "the summary is checked against the report"); + let mut bare = a.clone(); + bare.tee.as_mut().unwrap().report_hex = None; + assert!(refused(&bare, Some(&ours)), "evidence without its report cannot be re-verified"); + + // The report does not verify under another key. + let other = p384::ecdsa::SigningKey::from_slice(&[0x07; 48]).unwrap(); + let stranger = VcekKey::from(&other); + assert!(verify_run_evidence(a, Some(&ours), &policy, &stranger).is_err()); + } + + /// A run sealed without a challenge verifies without one, and is refused + /// to a verifier that issued one: it cannot be passed off as fresh. + #[test] + fn an_unchallenged_report_is_not_taken_for_a_challenged_one() { + let host = confidential_host(); + let (policy, vcek) = verifier(); + let sealed = sealed_s6(&host, None); + assert_eq!(sealed.attestation.tee.as_ref().unwrap().challenge_hex, None); + verify_run_evidence(&sealed.attestation, None, &policy, &vcek).unwrap(); + assert!(verify_run_evidence(&sealed.attestation, Some(&[0xC1; 32]), &policy, &vcek).is_err()); + } + + /// The preimage is fixed: each input moves it, and the challenge's + /// presence is part of it, so no challenge is not a challenge of zeros. + #[test] + fn the_report_data_preimage_separates_its_inputs() { + let (u, r) = (ContentId::of_bytes(b"u"), ContentId::of_bytes(b"r")); + let base = run_report_data(&u, &r, &Outcome::Completed, None); + assert_ne!(base, run_report_data(&r, &u, &Outcome::Completed, None), "unit and receipt are not interchangeable"); + assert_ne!(base, run_report_data(&u, &r, &Outcome::Failed, None)); + assert_ne!(base, run_report_data(&u, &r, &Outcome::Completed, Some(&[0; 32]))); + assert_ne!( + run_report_data(&u, &r, &Outcome::Completed, Some(&[1; 32])), + run_report_data(&u, &r, &Outcome::Completed, Some(&[2; 32])) + ); + assert_eq!(base, run_report_data(&u, &r, &Outcome::Completed, None)); + // Lengths delimit the fields: "ab" then "c" is not "a" then "bc". + let (ab, c) = (ContentId("ab".into()), ContentId("c".into())); + let (a, bc) = (ContentId("a".into()), ContentId("bc".into())); + assert_ne!( + run_report_data(&ab, &c, &Outcome::Completed, None), + run_report_data(&a, &bc, &Outcome::Completed, None) + ); + } + + /// The relying party's challenge reaches the report through the request + /// that asked for the run. + #[test] + fn a_requested_challenge_reaches_the_report() { + struct Confidential; + impl Executor for Confidential { + fn tier(&self) -> Tier { + Tier::S6 + } + fn run(&self, _: &Unit) -> Result { + Ok(cpu_report()) + } + } + let host = confidential_host(); + let (policy, vcek) = verifier(); + let g = Grants::default(); + let unit = Unit::new(b"image", &g); + let challenge = [0xC3; 32]; + let req = TierRequest { confidential: true, challenge: Some(challenge), ..TierRequest::minimal(&g) }; + let sealed = host.run_and_seal(&Confidential, &unit, &req, 0, [0; 8], 1, 2).unwrap(); + verify_run_evidence(&sealed.attestation, Some(&challenge), &policy, &vcek).unwrap(); + assert!(verify_run_evidence(&sealed.attestation, None, &policy, &vcek).is_err()); + } + #[test] fn s6_without_provider_is_rejected() { let host = Host::new( diff --git a/sandbox/sandbox-rs/crates/sandbox-tee/src/policy.rs b/sandbox/sandbox-rs/crates/sandbox-tee/src/policy.rs index f0dc35d1e..ebdb7b055 100644 --- a/sandbox/sandbox-rs/crates/sandbox-tee/src/policy.rs +++ b/sandbox/sandbox-rs/crates/sandbox-tee/src/policy.rs @@ -80,6 +80,8 @@ impl VerifiedReport { kind: self.kind, measurement_hex: hex::encode(self.measurement), tcb: format!("{:#018x}", self.current_tcb), + report_hex: None, + challenge_hex: None, } } } diff --git a/sandbox/spec/attestation.md b/sandbox/spec/attestation.md index 60c7200ed..7628799cf 100644 --- a/sandbox/spec/attestation.md +++ b/sandbox/spec/attestation.md @@ -36,14 +36,15 @@ Outcome = Completed | Failed | Terminated { breach : Breach } // EnergyBudget | CpuTime | Memory | Tamper | GrantMutation -TeeEvidence { kind : SevSnp | Tdx | ArmCca, measurement_hex : String, tcb : String } +TeeEvidence { kind : SevSnp | Tdx | ArmCca, measurement_hex : String, tcb : String, + report_hex : String?, challenge_hex : String? } // omitted when absent Locality { region : String, jurisdiction : String } ``` ## Confidential and sovereign binding (S6/S7) -At **S6**, the host obtains a TEE report from the platform's attestation interface, binds it to the run by placing a value derived from the run identity in the report's `report_data`, and **verifies** it — signature against the VCEK, and measurement / TCB / debug-state / report-data against policy — before sealing the `TeeEvidence` summary (SPEC §7.6). The reference `sandbox-tee` crate implements the full SEV-SNP verification path (parse, policy, ECDSA-P384/SHA-384); report *generation* is hardware-bound (`configfs-tsm` on real confidential hardware), with a tagged software-grounded provider for wiring. +At **S6**, the host obtains a TEE report from the platform's attestation interface, binds it to the run through the report's `report_data` (one preimage over the unit, the receipt, the outcome and the relying party's challenge, SPEC §7.6), and **verifies** it — signature against the VCEK, and measurement / TCB / debug-state / report-data against policy — before sealing the `TeeEvidence` summary (SPEC §7.6). The reference `sandbox-tee` crate implements the full SEV-SNP verification path (parse, policy, ECDSA-P384/SHA-384); report *generation* is hardware-bound (`configfs-tsm` on real confidential hardware), with a tagged software-grounded provider for wiring. An attestation carrying `mechanism` uses the profile tag `sandbox.attestation/2`; one without it remains `sandbox.attestation/1` and canonicalizes to byte-identical JSON, so attestations sealed before §7.9 verify unchanged. A `mechanism` **MUST NOT** be present at S0 (unconfined by definition), and is **not** proof of isolation — a host that will misstate its tier can misstate its profile. It makes the claim checkable against a known-good profile rather than unfalsifiable. A relying party needing proof rather than assertion still requires S6+ (SPEC §7.5, §7.9). diff --git a/sandbox/spec/eat.md b/sandbox/spec/eat.md index b87408ee9..24d225f14 100644 --- a/sandbox/spec/eat.md +++ b/sandbox/spec/eat.md @@ -46,7 +46,10 @@ A verifier **MUST**: 2. confirm `eat_profile` is the Sandbox profile; 3. for this profile, resolve the referenced `attestation_id` and re-verify the native attestation if it requires the full §7 guarantees (the EAT alone - conveys the standard claims, not the entire attestation core). + conveys the standard claims, not the entire attestation core); +4. where it holds the run's receipt, confirm `nonce` is that run's binding, + `BLAKE3(unit_id ‖ receipt_id)`. Without this check any token the + Supervisor key signed verifies, whichever run it is for. ## Reference