sandbox: one report_data binding for a run, a relying-party challenge, and evidence a third party can verify #392

Merged
dcharlot merged 1 commit from feat/sandbox-run-binding into main 2026-10-21 10:20:27 -04:00
14 changed files with 366 additions and 47 deletions

View file

@ -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)

View file

@ -1878,6 +1878,7 @@ version = "0.1.0"
dependencies = [
"blake3",
"ed25519-dalek",
"hex",
"p384",
"sandbox-attest",
"sandbox-capability",

View file

@ -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<Vec<u8>, 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<EatClaims, AttestError> {
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());
}
}

View file

@ -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<String>,
/// 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<String>,
}
/// 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));

View file

@ -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(),

View file

@ -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 ✓)",

View file

@ -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(),

View file

@ -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 -----------

View file

@ -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<Value, String> {
.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<Value> = sealed
@ -294,7 +299,19 @@ fn verify_eat_tool(args: &Value) -> Result<Value, String> {
.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<Value, String> {
"attestation_id": claims.attestation_id,
"receipt_id": claims.receipt_id,
"iat_secs": claims.iat_secs,
"nonce_checked": expected.is_some(),
}))
}

View file

@ -16,4 +16,5 @@ sandbox-tee.workspace = true
ed25519-dalek.workspace = true
p384.workspace = true
blake3.workspace = true
hex.workspace = true
thiserror.workspace = true

View file

@ -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<SealedRun, RunError> {
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<Mechanism>,
challenge: Option<[u8; 32]>,
) -> Result<SealedRun, RunError> {
// 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<sandbox_tee::policy::VerifiedReport, RunError> {
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<RunReport, ExecError> {
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(

View file

@ -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,
}
}
}

View file

@ -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).

View file

@ -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