sandbox: one report_data binding for a run, a relying-party challenge, and evidence a third party can verify #392
14 changed files with 366 additions and 47 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
1
sandbox/sandbox-rs/Cargo.lock
generated
1
sandbox/sandbox-rs/Cargo.lock
generated
|
|
@ -1878,6 +1878,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"blake3",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"p384",
|
||||
"sandbox-attest",
|
||||
"sandbox-capability",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ✓)",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 -----------
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ sandbox-tee.workspace = true
|
|||
ed25519-dalek.workspace = true
|
||||
p384.workspace = true
|
||||
blake3.workspace = true
|
||||
hex.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue