diff --git a/sandbox/SPEC.md b/sandbox/SPEC.md index 8a3c98b86..3875c42b9 100644 --- a/sandbox/SPEC.md +++ b/sandbox/SPEC.md @@ -274,14 +274,55 @@ The reference splits this honestly (§9): *verification* — parsing the SEV-SNP At S7, the attestation **MUST** additionally carry: - `locality` — the `{ region, jurisdiction }` the run was pinned to. A host **MUST NOT** seal an S7 run whose execution it cannot bind to the stated locality. +- `locality_evidence` (RECOMMENDED) — observations bounding where the run could **not** have been. See below. - `audit_prev` — the head of the host's append-only, hash-chained audit log **before** this run. After signing, the host **MUST** append the attestation's content address to the log, advancing the head: `head' = BLAKE3(head ‖ attestation_id)`. The chain is tamper-evident — reordering or removing any entry breaks it — so the sequence of sovereign runs is itself attestable. +**Locality evidence.** Left as two free-text strings, `locality` is the weakest normative claim in the ladder: an assertion by the party under audit, checked for presence and nothing else, at the one tier whose entire purpose is jurisdictional. The obligation above is real but had no mechanism behind it, and a data-residency auditor attacks exactly this. + +`locality_evidence` supplies a partial one. Round-trip time to an anchor of known position bounds distance — a signal cannot outrun light, so `rtt/2 × c` is the farthest the host can be from that anchor. The asymmetry is what makes it useful: **latency is trivial to add and impossible to remove**, so a host can always appear farther away than it is and never closer than physics permits. + +That gives a test that **falsifies but does not verify**: + +- It can **refute** a claim. If the claimed position is farther from an anchor than light could travel in the observed time, the claim is impossible, and that conclusion does not rest on trusting anyone. +- It can **never confirm** one. Surviving every bound means "not excluded" and nothing more: the feasible region is an intersection of discs that routinely spans several jurisdictions, and being somewhere inside it is not evidence of being at any particular point in it. + +Accordingly: + +- An implementation **MUST NOT** report a surviving claim as proven, verified, or attested. The reference names the passing verdict `NotRefuted` for this reason. +- Only observations signed by an **independent anchor** count. A round-trip time the host measured about itself is the same unchecked assertion as the region string it was meant to improve on, and **MUST** be reported as unsubstantiated rather than blended into a verdict. +- Observations **MUST** be bound to the run by a challenge, or an observation taken in a real datacentre can be replayed onto a later run somewhere else. +- A host **MUST NOT** seal an attestation whose own evidence refutes its own locality claim. +- `locality_evidence` **MUST** be absent below S7, where no locality is claimed. +- The bound **SHOULD** be computed with the vacuum speed of light rather than a fibre constant. This costs sensitivity and buys certainty in the only direction that matters: a refutation under vacuum-c cannot be argued away by an unusual path. + +Locality evidence is **timing-derived**, and this specification already holds that a timing-derived measurement may not claim a hardware-rooted provenance (§6.3). It is therefore its own evidence class: it never reaches the standing of the verified `tee` evidence at S6, and carries no `Provenance` of any kind. + ### 7.8 EAT interoperability (RECOMMENDED) 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. +### 7.9 Enforcement mechanism (S1–S5; RECOMMENDED) + +Below S6 the tier is host-asserted (§7.5): the standard offers no in-band evidence that the named boundary was ever established, so `tier = "S2"` is the host's word with a signature around it. An attestation at S1–S5 **SHOULD** therefore carry `mechanism`, a commitment to *how* confinement was established: + +- `runtime` — the enforcement runtime that established the boundary, name **and** version (e.g. `"seatbelt/14.5"`, `"bubblewrap/0.11"`, `"wasmtime/28"`). A bare name is insufficient: the same profile under a different runtime version is a different enforcement. +- `profile_id` — a BLAKE3 commitment over the **length-prefixed** encoding of the runtime identity, the policy profile actually applied, and the egress allowlist actually installed. Fields are length-prefixed rather than concatenated because bare concatenation is not injective: two distinct configurations whose fields split differently at the same boundary would share a commitment. + +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 this section existed verify unchanged. + +**What a mechanism does and does not establish.** It is **not** proof of isolation, and a verifier **MUST NOT** treat it as such. A host willing to misstate its tier is equally able to misstate its profile; a commitment made by the party under scrutiny cannot bootstrap its own trustworthiness. What changes is the *shape* of the claim. An unadorned tier label is unfalsifiable — there is nothing to check it against. A `profile_id` is checkable against a known-good profile the relying party already trusts, and a false one is a specific, attributable falsehood about a named artifact rather than a vague assertion. This narrows the gap at S1–S5; it does not close it. A relying party that requires proof of isolation rather than assertion still **MUST** require S6+ (§7.5). + +Normative rules: + +- A host **MUST NOT** seal a `mechanism` at S0. S0 is "process only; trusted code" — there is no boundary, so a mechanism there is incoherent rather than merely redundant. +- A host **MUST NOT** publish a `profile_id` it did not derive from the configuration it actually applied. +- A `mechanism` **MUST** name a runtime version; a runtime name alone is non-conformant. +- A verifier **MUST NOT** accept `mechanism` in place of the `tee` evidence required at S6+ (§7.6). + +Requiring a mechanism is a **relying-party** decision, not a producer obligation: making it mandatory for all producers would retroactively invalidate every attestation sealed before this section, and the standard does not make honest historical records non-conformant. A consumer that needs the guarantee demands it at appraisal time — the same posture §7.5 already takes for isolation proof. The reference exposes this as `require_mechanism_evidence()`, under which S0 passes trivially (it claims no confinement) and S6+ passes on its `tee` evidence (strictly stronger). + --- ## 8. Determinism and replay diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs index 747888e76..ca6c3501c 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/eat.rs @@ -341,6 +341,8 @@ mod tests { tee: None, locality: None, audit_prev: None, + mechanism: None, + locality_evidence: None, host_id: "host-1".into(), started_at_us: 1_000_000, finished_at_us: 2_000_000, diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs index e0039f224..de1dfda67 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/lib.rs @@ -17,16 +17,25 @@ pub mod fleet; pub mod jcs; pub mod journal; pub mod manifest; +pub mod locality; pub mod runrecord; pub mod warmpool; pub use audit::AuditLog; pub use eat::{verify_eat, EatClaims, EAT_PROFILE}; +pub use locality::{AnchorObservation, LocalityEvidence, LocalityVerdict}; pub use journal::{compare as replay_compare, Journal, JournalEntry, Replay}; -/// The attestation's profile tag. +/// The attestation's profile tag when it carries no enforcement mechanism +/// (SPEC §7.9) — the historical form, still valid. pub const PROFILE: &str = "sandbox.attestation/1"; +/// The profile tag for an attestation that commits to the enforcement +/// mechanism (SPEC §7.9). An attestation is `/2` exactly when `mechanism` is +/// present; absent it is byte-identical to `/1`, so every previously sealed +/// attestation and published vector verifies unchanged. +pub const PROFILE_V2: &str = "sandbox.attestation/2"; + /// Errors from building, signing, or verifying an attestation. #[derive(Debug, thiserror::Error)] pub enum AttestError { @@ -44,6 +53,18 @@ pub enum AttestError { LocalityRequired, #[error("tier S7 requires an audit_prev link (SPEC §2.2 S7)")] AuditPrevRequired, + #[error("tier S0 is unconfined, so it cannot carry an enforcement mechanism (SPEC §7.9)")] + MechanismUnexpected, + #[error("mechanism is missing its {0} (SPEC §7.9)")] + MechanismIncomplete(&'static str), + #[error("relying party requires mechanism evidence, and tier {0} carries none (SPEC §7.9)")] + MechanismRequired(&'static str), + #[error("locality evidence present below tier S7, where there is no locality to evidence")] + LocalityEvidenceUnexpected, + #[error("the host's own locality evidence refutes its locality claim: {0}")] + LocalitySelfRefuted(String), + #[error("relying party requires S7 locality evidence, and this attestation carries none")] + LocalityEvidenceRequired, #[error("attestation signature did not verify")] BadSignature, #[error("EAT encoding/verification: {0}")] @@ -54,6 +75,71 @@ pub enum AttestError { Crypto(String), } +/// A commitment to **how** confinement was established for an S1–S5 run +/// (SPEC §7.9). +/// +/// Below S6 the tier is host-asserted: the standard offers no in-band proof +/// that the named boundary was ever established (§7.5). A `Mechanism` narrows +/// that gap without pretending to close it. It does **not** prove the boundary +/// held — a host that lies about its tier can equally lie about its profile. +/// What it changes is the *shape* of the lie: instead of an unfalsifiable tier +/// label, the host commits to a specific, named, reproducible configuration +/// that a relying party can compare against a known-good profile it already +/// trusts. The claim becomes checkable against a reference, and a false one +/// becomes a specific, attributable falsehood about a named artifact. +/// +/// Verifiers **MUST NOT** treat a mechanism as proof of isolation. A relying +/// party that needs proof rather than assertion still requires S6+ (§7.5). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mechanism { + /// The enforcement runtime that established the boundary, name **and** + /// version — e.g. `"seatbelt/14.5"`, `"bubblewrap/0.11"`, `"wasmtime/28"`. + /// A bare name is not enough: the same profile under a different runtime + /// version is a different enforcement. + pub runtime: String, + /// A commitment to the enforcement inputs: the policy profile actually + /// applied, the egress allowlist actually installed, and the runtime + /// identity. Built by [`Mechanism::commit`]. + pub profile_id: ContentId, +} + +impl Mechanism { + /// Commit to the enforcement inputs. + /// + /// Each field is **length-prefixed** before hashing rather than simply + /// concatenated. Bare concatenation is ambiguous — a profile ending in + /// bytes that could equally begin the allowlist would hash identically + /// under a different split, so distinct enforcement configurations could + /// share a commitment. Length-prefixing makes the encoding injective. + pub fn commit(runtime: impl Into, profile: &[u8], egress: &[String]) -> Self { + let runtime = runtime.into(); + let mut buf = Vec::new(); + let field = |b: &[u8], out: &mut Vec| { + out.extend_from_slice(&(b.len() as u64).to_be_bytes()); + out.extend_from_slice(b); + }; + field(runtime.as_bytes(), &mut buf); + field(profile, &mut buf); + buf.extend_from_slice(&(egress.len() as u64).to_be_bytes()); + for e in egress { + field(e.as_bytes(), &mut buf); + } + Mechanism { runtime, profile_id: ContentId::of_bytes(&buf) } + } + + /// Structural well-formedness: an empty runtime or profile id is a + /// mechanism that commits to nothing. + pub fn validate(&self) -> Result<(), AttestError> { + if self.runtime.trim().is_empty() { + return Err(AttestError::MechanismIncomplete("runtime name and version")); + } + if self.profile_id.0.is_empty() { + return Err(AttestError::MechanismIncomplete("profile commitment")); + } + Ok(()) + } +} + /// The identity of a device class the unit used (SPEC §7.2). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceIdentity { @@ -143,6 +229,17 @@ pub struct Attestation { pub locality: Option, /// The audit-chain head before this run was appended (REQUIRED at S7). pub audit_prev: Option, + /// How confinement was established (SPEC §7.9). Meaningful at S1–S5, where + /// the tier is otherwise host-asserted with no in-band evidence; MUST be + /// absent at S0, which is unconfined by definition. + pub mechanism: Option, + /// Evidence bounding where the run could **not** have executed (SPEC §7.7). + /// + /// Meaningful only at S7, the tier that claims a locality. It falsifies + /// rather than verifies — see [`locality`] — so it never upgrades the claim + /// to proven; it only removes the possibility that the claim is impossible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locality_evidence: Option, pub host_id: String, pub started_at_us: u64, pub finished_at_us: u64, @@ -167,9 +264,98 @@ impl Attestation { return Err(AttestError::AuditPrevRequired); } } + // A mechanism is a claim about an enforced boundary. S0 is "process + // only; trusted code" — there is no boundary, so a mechanism there is + // not merely redundant but incoherent, and is rejected rather than + // silently accepted as decoration (SPEC §7.9). + match &self.mechanism { + Some(_) if self.tier == Tier::S0 => return Err(AttestError::MechanismUnexpected), + Some(m) => m.validate()?, + None => {} + } + // Locality evidence below S7 evidences a claim that is not being made. + // And a host whose OWN evidence refutes its OWN claim must not be able + // to seal it: that is not a marginal call about trust, it is a physical + // impossibility the host itself just recorded. + if let Some(le) = &self.locality_evidence { + if self.tier < Tier::S7 { + return Err(AttestError::LocalityEvidenceUnexpected); + } + if let locality::LocalityVerdict::Refuted { anchor, claimed_km, max_km } = le.check() { + return Err(AttestError::LocalitySelfRefuted(format!( + "anchor {anchor}: claim is {claimed_km:.0} km away but light allows at most {max_km:.0} km" + ))); + } + } Ok(()) } + /// Whether this attestation commits to how confinement was established + /// (SPEC §7.9) — i.e. whether it is a `/2` attestation. + pub fn has_mechanism_evidence(&self) -> bool { + self.mechanism.is_some() + } + + /// The profile tag this attestation canonicalizes under. + pub fn profile(&self) -> &'static str { + if self.mechanism.is_some() { + PROFILE_V2 + } else { + PROFILE + } + } + + /// A **relying-party** check: demand that an S7 locality claim carry + /// evidence that survived falsification. + /// + /// Passing does **not** establish where the run executed — no available + /// evidence can (see [`locality`]). It establishes only that the claim is + /// not physically impossible and that an independent anchor, not the host, + /// said so. A data-residency auditor wanting more than that needs a + /// mechanism this standard does not have. + pub fn require_locality_evidence(&self) -> Result { + if self.tier < Tier::S7 { + return Err(AttestError::LocalityEvidenceRequired); + } + match &self.locality_evidence { + None => Err(AttestError::LocalityEvidenceRequired), + Some(le) => match le.check() { + v @ locality::LocalityVerdict::NotRefuted { .. } => Ok(v), + locality::LocalityVerdict::Refuted { anchor, claimed_km, max_km } => { + Err(AttestError::LocalitySelfRefuted(format!( + "anchor {anchor}: {claimed_km:.0} km claimed, {max_km:.0} km possible" + ))) + } + locality::LocalityVerdict::Unsubstantiated { .. } => { + Err(AttestError::LocalityEvidenceRequired) + } + }, + } + } + + /// A **relying-party** check: demand that an S1–S5 run say how it was + /// confined, rejecting a bare tier label. + /// + /// This is deliberately not part of [`Attestation::validate_structure`]. + /// Requiring a mechanism of every producer would invalidate every + /// attestation sealed before §7.9 existed, and the standard cannot + /// retroactively make honest historical records non-conformant. So the + /// requirement lives where the trust decision does: with the consumer, in + /// the same posture §7.5 already takes for isolation proof ("a relying + /// party that requires proof rather than assertion MUST require S6+"). + /// + /// S0 passes trivially — it claims no confinement and so has nothing to + /// evidence. S6+ passes on its `tee` evidence, which is strictly stronger. + pub fn require_mechanism_evidence(&self) -> Result<(), AttestError> { + if self.tier == Tier::S0 || self.tier >= Tier::S6 { + return Ok(()); + } + match &self.mechanism { + Some(m) => m.validate(), + None => Err(AttestError::MechanismRequired(self.tier.wire_tag())), + } + } + /// Check that every device class named here also appears in the receipt's /// device classes, and vice versa (SPEC §7.2): the metered silicon is the /// named silicon. @@ -213,7 +399,12 @@ impl Attestation { } }; let mut core = serde_json::Map::new(); - core.insert("sb_attestation".into(), json!(PROFILE)); + // The profile is `/2` exactly when a mechanism is carried, and the + // field is omitted when absent — so a mechanism-free attestation + // canonicalizes to the same bytes it always did and its signature + // still verifies (SPEC §7.9). + let profile = if self.mechanism.is_some() { PROFILE_V2 } else { PROFILE }; + core.insert("sb_attestation".into(), json!(profile)); core.insert("unit_id".into(), json!(self.unit_id.0)); core.insert("image_id".into(), json!(self.image_id.0)); core.insert("grants_id".into(), json!(self.grants_id.0)); @@ -243,6 +434,36 @@ impl Attestation { if let Some(ap) = &self.audit_prev { core.insert("audit_prev".into(), json!(ap.0)); } + if let Some(le) = &self.locality_evidence { + let obs: Vec = le + .observations + .iter() + .map(|o| { + json!({ + "anchor_id": o.anchor_id, + "lat_micro": o.lat_micro, + "lon_micro": o.lon_micro, + "rtt_us": o.rtt_us, + "anchor_sig": o.anchor_sig, + }) + }) + .collect(); + core.insert( + "locality_evidence".into(), + json!({ + "challenge_hex": le.challenge_hex, + "claimed_lat_micro": le.claimed_lat_micro, + "claimed_lon_micro": le.claimed_lon_micro, + "observations": Value::Array(obs), + }), + ); + } + if let Some(m) = &self.mechanism { + core.insert( + "mechanism".into(), + json!({ "runtime": m.runtime, "profile_id": m.profile_id.0 }), + ); + } core.insert("host_id".into(), json!(self.host_id)); core.insert("started_at_us".into(), json!(self.started_at_us)); core.insert("finished_at_us".into(), json!(self.finished_at_us)); @@ -280,6 +501,188 @@ impl Attestation { mod tests { use super::*; + fn tee_ev() -> TeeEvidence { + TeeEvidence { kind: TeeKind::SevSnp, measurement_hex: "00".into(), tcb: "tcb".into() } + } + + fn mech() -> Mechanism { + Mechanism::commit("wasmtime/43.0.2", b"consume_fuel=true;default_fuel=50000000", &[]) + } + + /// The whole backward-compatibility claim of §7.9 in one assertion: an + /// attestation without a mechanism must canonicalize to exactly the bytes + /// it did before the field existed, or every previously sealed attestation + /// and every published vector breaks. + #[test] + fn absent_mechanism_leaves_the_signed_bytes_untouched() { + let a = att(Tier::S2, None); + let bytes = a.canonical_bytes().unwrap(); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(!text.contains("mechanism"), "absent field must be omitted, not null"); + assert!(text.contains("\"sb_attestation\":\"sandbox.attestation/1\"")); + assert_eq!(a.profile(), PROFILE); + assert!(!a.has_mechanism_evidence()); + + // And the mechanism-carrying form is a distinct profile, so a verifier + // can tell which contract it is appraising. + let mut b = att(Tier::S2, None); + b.mechanism = Some(mech()); + let btext = String::from_utf8(b.canonical_bytes().unwrap()).unwrap(); + assert!(btext.contains("\"sb_attestation\":\"sandbox.attestation/2\"")); + assert_eq!(b.profile(), PROFILE_V2); + assert_ne!(bytes, b.canonical_bytes().unwrap()); + } + + fn loc_ev(rtt_us: u64, signed: bool) -> LocalityEvidence { + // Claim: Frankfurt. Anchor: Ashburn (~6,900 km away). + LocalityEvidence { + challenge_hex: "bb".repeat(32), + claimed_lat_micro: 50_110_924, + claimed_lon_micro: 8_682_127, + observations: vec![AnchorObservation { + anchor_id: "ashburn".into(), + lat_micro: 39_043_757, + lon_micro: -77_487_442, + rtt_us, + anchor_sig: signed.then(|| "sig".to_string()), + }], + } + } + + /// A host cannot seal a claim its own evidence declares impossible. + #[test] + fn a_self_refuting_locality_cannot_be_sealed() { + let mut a = att(Tier::S7, Some(tee_ev())); + a.locality = Some(Locality { region: "eu-central-1".into(), jurisdiction: "DE".into() }); + a.audit_prev = Some(ContentId::of_bytes(b"prev")); + + // 5 ms cannot reach Frankfurt from Ashburn. + a.locality_evidence = Some(loc_ev(5_000, true)); + assert!(matches!(a.validate_structure(), Err(AttestError::LocalitySelfRefuted(_)))); + + // A generous RTT admits it — and the check says NotRefuted, never proven. + a.locality_evidence = Some(loc_ev(200_000, true)); + a.validate_structure().unwrap(); + let v = a.require_locality_evidence().unwrap(); + assert!(matches!(v, locality::LocalityVerdict::NotRefuted { .. })); + assert!(!v.is_refuted()); + } + + /// Below S7 there is no locality claim, so evidence for one is incoherent. + #[test] + fn locality_evidence_below_s7_is_rejected() { + let mut a = att(Tier::S2, None); + a.locality_evidence = Some(loc_ev(200_000, true)); + assert!(matches!( + a.validate_structure(), + Err(AttestError::LocalityEvidenceUnexpected) + )); + // And a relying party asking a non-S7 run for locality evidence is + // asking for something the tier never claimed. + assert!(att(Tier::S2, None).require_locality_evidence().is_err()); + } + + /// Self-measured latency is not evidence, so it cannot satisfy the demand — + /// the host asserting its own RTT is the same unchecked claim as the region + /// string it was meant to improve on. + #[test] + fn unsigned_locality_evidence_does_not_satisfy_a_relying_party() { + let mut a = att(Tier::S7, Some(tee_ev())); + a.locality = Some(Locality { region: "eu-central-1".into(), jurisdiction: "DE".into() }); + a.audit_prev = Some(ContentId::of_bytes(b"prev")); + a.locality_evidence = Some(loc_ev(200_000, false)); + // Structurally fine: unsubstantiated is not self-refuting. + a.validate_structure().unwrap(); + // But it does not meet a relying party's bar. + assert!(matches!( + a.require_locality_evidence(), + Err(AttestError::LocalityEvidenceRequired) + )); + } + + /// Absence leaves the signed bytes untouched, so S7 attestations sealed + /// before §7.7 gained evidence still verify. + #[test] + fn absent_locality_evidence_leaves_the_signed_bytes_untouched() { + let mut a = att(Tier::S7, Some(tee_ev())); + a.locality = Some(Locality { region: "eu-central-1".into(), jurisdiction: "DE".into() }); + a.audit_prev = Some(ContentId::of_bytes(b"prev")); + let text = String::from_utf8(a.canonical_bytes().unwrap()).unwrap(); + assert!(!text.contains("locality_evidence")); + // ... and present, it is inside the signature. + let before = a.canonical_bytes().unwrap(); + a.locality_evidence = Some(loc_ev(200_000, true)); + assert_ne!(before, a.canonical_bytes().unwrap()); + } + + /// S0 is unconfined by definition, so a mechanism there describes a + /// boundary that does not exist. + #[test] + fn s0_cannot_carry_a_mechanism() { + let mut a = att(Tier::S0, None); + a.mechanism = Some(mech()); + assert!(matches!(a.validate_structure(), Err(AttestError::MechanismUnexpected))); + + a.mechanism = None; + a.validate_structure().unwrap(); + // S0 claims no confinement, so it has nothing to evidence. + a.require_mechanism_evidence().unwrap(); + } + + /// The relying-party requirement: a bare tier label between S1 and S5 is + /// refused, while S6+ passes on its strictly stronger TEE evidence. + #[test] + fn a_relying_party_can_demand_the_mechanism() { + let bare = att(Tier::S2, None); + bare.validate_structure().unwrap(); // still structurally valid + assert!(matches!( + bare.require_mechanism_evidence(), + Err(AttestError::MechanismRequired("S2")) + )); + + let mut evidenced = att(Tier::S2, None); + evidenced.mechanism = Some(mech()); + evidenced.validate_structure().unwrap(); + evidenced.require_mechanism_evidence().unwrap(); + + // S6 carries verified TEE evidence; a mechanism adds nothing it needs. + let s6 = att(Tier::S6, Some(tee_ev())); + s6.require_mechanism_evidence().unwrap(); + } + + /// A mechanism that commits to nothing is not a mechanism. + #[test] + fn a_mechanism_must_actually_name_its_runtime() { + let mut a = att(Tier::S2, None); + a.mechanism = Some(Mechanism { runtime: " ".into(), profile_id: ContentId::of_bytes(b"x") }); + assert!(matches!( + a.validate_structure(), + Err(AttestError::MechanismIncomplete("runtime name and version")) + )); + } + + /// Length-prefixing makes the commitment injective. Under bare + /// concatenation these two configurations would hash identically, so a host + /// could swap the runtime for part of its profile and keep the digest. + #[test] + fn the_commitment_is_injective_across_field_boundaries() { + let a = Mechanism::commit("seatbelt/14", b"5;deny-net", &[]); + let b = Mechanism::commit("seatbelt/145", b";deny-net", &[]); + assert_ne!(a.profile_id, b.profile_id, "field split must change the commitment"); + + // The egress allowlist is part of what is committed, and its shape + // matters: one entry "a\u{1}b" is not the same as two entries a and b. + let one = Mechanism::commit("rt/1", b"p", &["a.example:443".into()]); + let two = Mechanism::commit("rt/1", b"p", &["a.example".into(), "443".into()]); + assert_ne!(one.profile_id, two.profile_id); + + // Same inputs, same commitment. + assert_eq!( + Mechanism::commit("rt/1", b"p", &["a".into()]).profile_id, + Mechanism::commit("rt/1", b"p", &["a".into()]).profile_id + ); + } + fn att(tier: Tier, tee: Option) -> Attestation { Attestation { unit_id: ContentId::of_bytes(b"unit"), @@ -304,6 +707,8 @@ mod tests { tee, locality: None, audit_prev: None, + mechanism: None, + locality_evidence: None, host_id: "host-1".into(), started_at_us: 1000, finished_at_us: 2000, diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/locality.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/locality.rs new file mode 100644 index 000000000..8d6851f22 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/locality.rs @@ -0,0 +1,309 @@ +//! Locality evidence for S7 (SPEC §7.7) — bounding *where a run could not have +//! been*. +//! +//! S7 is the sovereign tier, and its distinguishing claim is `Locality +//! { region, jurisdiction }`: two free-text strings supplied by the party under +//! audit and checked, until now, only for presence. SPEC §7.7 says a host "MUST +//! NOT seal an S7 run whose execution it cannot bind to the stated locality" — +//! an obligation with no mechanism behind it. +//! +//! # What this can and cannot establish +//! +//! Round-trip time to an anchor of known position bounds distance: a signal +//! cannot outrun light, so `rtt/2 × c` is an upper bound on how far the host can +//! be from that anchor. The asymmetry is the whole point — **latency is trivial +//! to add and impossible to remove**. A host can always look *farther* away than +//! it is; it can never look *closer* than physics permits. +//! +//! So this evidence is **falsifying, not verifying**: +//! +//! - It can **refute** a claim: if the claimed position is farther from an +//! anchor than light could travel in the observed time, the claim is +//! physically impossible, and that conclusion is not a matter of trust. +//! - It can **never confirm** one. Passing every bound means only "not +//! excluded". The feasible region is an intersection of discs that routinely +//! spans several jurisdictions, and being somewhere inside it is not evidence +//! of being at any particular point in it. +//! +//! A verdict of [`LocalityVerdict::NotRefuted`] therefore says exactly that, and +//! is deliberately not named anything stronger. +//! +//! # Who measures matters more than what is measured +//! +//! An RTT the host measured about itself is worth precisely what the free-text +//! string was worth — it is the same party making the same unchecked assertion, +//! now with a number attached. Evidence counts only where an **independent +//! anchor** signed its own observation over a challenge bound to the run. +//! Unsigned observations are reported as [`LocalityVerdict::Unsubstantiated`] +//! rather than quietly averaged in. +//! +//! # Why this is its own evidence class +//! +//! It is timing-derived, and this codebase already holds that a timing-derived +//! measurement may not claim a hardware-rooted provenance +//! (`sandbox_meter::contention::StorageMeterError::TimingIsNotAShunt`). The same +//! rule applies here: locality evidence never reaches the standing of the `tee` +//! evidence at S6, and nothing in this module produces a `Provenance`. + +use serde::{Deserialize, Serialize}; + +/// Speed of light in vacuum, km per microsecond. +/// +/// Deliberately the **vacuum** figure rather than the ~2/3 c of fibre. This +/// bound exists to refute, so it must be the most permissive one physics +/// allows: a refutation under vacuum-c is unassailable, whereas a refutation +/// under a fibre constant could be argued away by an unusual path. Using the +/// generous constant costs some sensitivity and buys certainty in the only +/// direction that matters. +const C_KM_PER_US: f64 = 0.299_792_458; + +/// Mean Earth radius (km), for great-circle distance. +const EARTH_RADIUS_KM: f64 = 6_371.008_8; + +/// One anchor's observation of the host. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnchorObservation { + /// Who made this observation. + pub anchor_id: String, + /// The anchor's own position, in microdegrees (1e-6°) to keep the wire + /// integral — floats have no place in a signed artifact. + pub lat_micro: i32, + pub lon_micro: i32, + /// Round-trip time the anchor measured, in microseconds. + pub rtt_us: u64, + /// The anchor's signature over the run-bound challenge and this + /// observation. `None` means the host is vouching for itself, which is not + /// evidence — see [`LocalityVerdict::Unsubstantiated`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor_sig: Option, +} + +impl AnchorObservation { + fn lat(&self) -> f64 { + f64::from(self.lat_micro) / 1e6 + } + fn lon(&self) -> f64 { + f64::from(self.lon_micro) / 1e6 + } + + /// The farthest the host can be from this anchor, given the observed RTT. + /// + /// One-way time is half the round trip; anything beyond `c × t` would + /// require the signal to outrun light. + pub fn max_distance_km(&self) -> f64 { + (self.rtt_us as f64 / 2.0) * C_KM_PER_US + } +} + +/// What the observations permit. +#[derive(Clone, Debug, PartialEq)] +pub enum LocalityVerdict { + /// No anchor's bound excludes the claim. + /// + /// **This is not proof of location.** It means the claim survived every + /// falsification attempt available, and nothing more. `slack_km` is the + /// tightest remaining margin — how much closer the claim sits to its + /// binding anchor than physics required. A large slack means the evidence + /// barely constrained anything. + NotRefuted { tightest_anchor: String, slack_km: f64 }, + /// The claim is physically impossible against at least one anchor. This + /// conclusion does not rest on trusting the host. + Refuted { anchor: String, claimed_km: f64, max_km: f64 }, + /// Nothing here counts as evidence: no observations, or none that an + /// independent anchor signed. The host asserting its own latency is the + /// same unchecked claim as the free-text region string. + Unsubstantiated { reason: &'static str }, +} + +impl LocalityVerdict { + /// Whether the claim survived falsification. Note the deliberate absence of + /// an `is_proven` — there is no such thing here. + pub fn is_refuted(&self) -> bool { + matches!(self, LocalityVerdict::Refuted { .. }) + } +} + +/// The evidence bundle carried alongside an S7 `Locality`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalityEvidence { + /// A challenge bound to the run (e.g. `BLAKE3(unit_id ‖ receipt_id)`), so an + /// observation cannot be replayed from an earlier run in a real datacentre + /// onto a later one somewhere else. + pub challenge_hex: String, + /// The claimed position the observations are tested against, microdegrees. + pub claimed_lat_micro: i32, + pub claimed_lon_micro: i32, + pub observations: Vec, +} + +/// Great-circle distance between two points, km. +fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + let (p1, p2) = (lat1.to_radians(), lat2.to_radians()); + let dp = (lat2 - lat1).to_radians(); + let dl = (lon2 - lon1).to_radians(); + let a = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_KM * a.sqrt().asin() +} + +impl LocalityEvidence { + fn claimed_lat(&self) -> f64 { + f64::from(self.claimed_lat_micro) / 1e6 + } + fn claimed_lon(&self) -> f64 { + f64::from(self.claimed_lon_micro) / 1e6 + } + + /// Test the claimed position against every signed observation. + /// + /// Only signed observations are considered. An unsigned one is not weighed + /// less — it is not evidence at all, because the host produced it about + /// itself. + pub fn check(&self) -> LocalityVerdict { + if self.observations.is_empty() { + return LocalityVerdict::Unsubstantiated { reason: "no anchor observations" }; + } + let signed: Vec<&AnchorObservation> = + self.observations.iter().filter(|o| o.anchor_sig.is_some()).collect(); + if signed.is_empty() { + return LocalityVerdict::Unsubstantiated { + reason: "no independently signed observations; the host vouched for itself", + }; + } + + let (clat, clon) = (self.claimed_lat(), self.claimed_lon()); + let mut tightest: Option<(&str, f64)> = None; + for o in signed { + let claimed_km = haversine_km(clat, clon, o.lat(), o.lon()); + let max_km = o.max_distance_km(); + if claimed_km > max_km { + return LocalityVerdict::Refuted { + anchor: o.anchor_id.clone(), + claimed_km, + max_km, + }; + } + let slack = max_km - claimed_km; + if tightest.is_none_or(|(_, s)| slack < s) { + tightest = Some((&o.anchor_id, slack)); + } + } + let (anchor, slack_km) = tightest.expect("at least one signed observation"); + LocalityVerdict::NotRefuted { tightest_anchor: anchor.to_string(), slack_km } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Real positions, microdegrees. + const FRANKFURT: (i32, i32) = (50_110_924, 8_682_127); + const ASHBURN: (i32, i32) = (39_043_757, -77_487_442); + const SINGAPORE: (i32, i32) = (1_352_083, 103_819_836); + + fn obs(id: &str, pos: (i32, i32), rtt_us: u64, signed: bool) -> AnchorObservation { + AnchorObservation { + anchor_id: id.into(), + lat_micro: pos.0, + lon_micro: pos.1, + rtt_us, + anchor_sig: signed.then(|| "sig".to_string()), + } + } + + fn ev(claim: (i32, i32), observations: Vec) -> LocalityEvidence { + LocalityEvidence { + challenge_hex: "aa".repeat(32), + claimed_lat_micro: claim.0, + claimed_lon_micro: claim.1, + observations, + } + } + + /// The direction that actually works: a claim can be shown IMPOSSIBLE. + /// Frankfurt is ~6,900 km from Ashburn, so light needs ~46 ms round trip. + /// An anchor in Ashburn observing 5 ms refutes a Frankfurt claim outright, + /// and no amount of trusting the host changes that. + #[test] + fn a_claim_light_could_not_reach_is_refuted() { + let v = ev(FRANKFURT, vec![obs("ashburn", ASHBURN, 5_000, true)]).check(); + match v { + LocalityVerdict::Refuted { ref anchor, claimed_km, max_km } => { + assert_eq!(anchor, "ashburn"); + assert!(claimed_km > 6_000.0, "Frankfurt-Ashburn is thousands of km"); + assert!(max_km < 800.0, "5 ms of light is under 800 km one way"); + } + other => panic!("expected refutation, got {other:?}"), + } + assert!(ev(FRANKFURT, vec![obs("ashburn", ASHBURN, 5_000, true)]).check().is_refuted()); + } + + /// The direction that does NOT work, stated as such. A generous RTT admits + /// the claim, and the verdict is `NotRefuted` — never "proven". The large + /// slack is the tell: this evidence barely constrained anything. + #[test] + fn surviving_the_bound_is_not_proof_of_location() { + let v = ev(FRANKFURT, vec![obs("ashburn", ASHBURN, 200_000, true)]).check(); + match v { + LocalityVerdict::NotRefuted { slack_km, .. } => { + // 200 ms of light reaches ~30,000 km — most of the planet. The + // claim survives, and that tells a verifier almost nothing. + assert!(slack_km > 20_000.0, "slack {slack_km} should expose a useless bound"); + } + other => panic!("expected NotRefuted, got {other:?}"), + } + } + + /// Who measured decides whether it is evidence. A host timing itself is the + /// same unchecked assertion as the region string it was meant to improve on. + #[test] + fn unsigned_observations_are_not_evidence() { + // Unsigned observations that WOULD refute are still not evidence — the + // rule is about provenance, not about which answer they give. + let v = ev(FRANKFURT, vec![obs("self", ASHBURN, 5_000, false)]).check(); + assert!( + matches!(v, LocalityVerdict::Unsubstantiated { .. }), + "unsigned observation must not produce a verdict, got {v:?}" + ); + assert!(!v.is_refuted()); + + assert!(matches!( + ev(FRANKFURT, vec![]).check(), + LocalityVerdict::Unsubstantiated { .. } + )); + + // One signed observation among unsigned ones is enough to decide; the + // unsigned ones are simply ignored rather than blended in. + let mixed = ev(FRANKFURT, vec![obs("self", SINGAPORE, 1, false), obs("fra", FRANKFURT, 2_000, true)]); + assert!(matches!(mixed.check(), LocalityVerdict::NotRefuted { .. })); + } + + /// Tightening comes from multiple anchors: any one of them can refute. + #[test] + fn any_single_anchor_can_refute_a_claim_the_others_admit() { + let e = ev( + FRANKFURT, + vec![ + obs("fra", FRANKFURT, 2_000, true), // admits it + obs("sin", SINGAPORE, 1_000, true), // 1 ms cannot reach Singapore + ], + ); + match e.check() { + LocalityVerdict::Refuted { anchor, .. } => assert_eq!(anchor, "sin"), + other => panic!("expected the Singapore anchor to refute, got {other:?}"), + } + } + + /// The bound uses vacuum c on purpose: a refutation must not be arguable. + /// Fibre light is ~2/3 c, so a fibre-based bound would refute claims that + /// vacuum-c admits — sensitivity traded for certainty. + #[test] + fn the_bound_is_the_permissive_one() { + let o = obs("a", FRANKFURT, 1_000, true); + // 1 ms round trip -> 0.5 ms one way -> ~149.9 km at vacuum c. + let km = o.max_distance_km(); + assert!((km - 149.896).abs() < 0.01, "got {km}"); + // A fibre constant would have given ~100 km, refuting more claims. + assert!(km > 100.0); + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs index 410476338..d16adf8a9 100644 --- a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs @@ -19,7 +19,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use sandbox_capability::Grants; -use sandbox_exec_microvm::firecracker::{FirecrackerConfig, ProcessVmm}; +use sandbox_exec_microvm::firecracker::{FirecrackerConfig, ProcessVmm, Vmm}; use sandbox_exec_microvm::power::NoMeter; use sandbox_exec_microvm::MicroVmExecutor; use sandbox_runtime::{ @@ -503,17 +503,23 @@ fn resolve_command( } let vmm = ProcessVmm::new(program.to_string(), args.to_vec()); + // The tier is read off the executor, never asserted here. `ProcessVmm::new` + // supervises the build command as an ordinary child process on the host, so + // on a host without hardware virtualization this is S0 — and the receipt + // will say S0. Sealing a rung the run did not reach would make the receipt + // worthless precisely where it is load-bearing. + let attained = vmm.attained_tier(); let exec = MicroVmExecutor::new(Box::new(vmm), template.clone(), Box::new(NoMeter)); let unit = Unit::new(image, grants); let report = exec.run(&unit).map_err(|e| BuildError::Run(e.into()))?; let unit_id = unit_content_id(&image_id, &grants_id); - // S5 is an escalation above the grant floor; the host attests the microVM run. + // The host attests the run at the tier the executor reports reaching. let sealed = host.seal_run( unit_id, image_id.clone(), grants, - Tier::S5, + attained, report, seq, [0; 8], @@ -1061,15 +1067,19 @@ mod tests { } #[test] - fn a_command_step_runs_in_the_microvm_and_seals_at_s5() { + fn a_command_step_seals_the_tier_it_actually_reached() { use sandbox_exec_wasm::WasmExecutor; let host = host(); let exec = WasmExecutor::with_defaults().unwrap(); let key = SigningKey::from_bytes(&[88u8; 32]); let mut cache = BuildCache::new(); - // A real command (`true`) runs in the microVM (ProcessVmm) — sealed at S5, - // attested, with op provenance — the path for non-wasm source builds. + // A real command (`true`) is supervised by `ProcessVmm::new` — an + // ordinary child process on the host, not a guest under a VMM. It is + // attested and carries op provenance (the path for non-wasm source + // builds), but the rung it seals is the floor, S0. This crate never + // constructs `supervising_vmm`, so the tier here does not vary with the + // host: a build receipt from this path claims S0 everywhere. let task = BuildTask { intent: "run the test suite".into(), steps: vec![BuildStep::command("test", "true", vec![], cpu_grants()) @@ -1078,12 +1088,12 @@ mod tests { let signed = run_build(&host, &exec, "steward.build", &task, &mut cache, &key, zero_id(), 1_700_000).unwrap(); let s = &signed.receipt.steps[0]; - assert_eq!(s.tier, Tier::S5); + assert_eq!(s.tier, Tier::S0, "a supervised child process is S0, never S5"); assert_eq!(s.outcome, "Completed"); assert_eq!(s.resolved_by, ResolvedTier::L1Deterministic); assert!(!s.eat_hex.is_empty()); assert_eq!(s.op.as_ref().unwrap().author, "agent-zed"); - assert_eq!(signed.receipt.tier_max, Tier::S5); + assert_eq!(signed.receipt.tier_max, Tier::S0); assert!(verify_build_receipt(&signed)); // An identical command resolves from cache (L0) with no new execution. @@ -1092,14 +1102,14 @@ mod tests { assert_eq!(again.receipt.steps[0].resolved_by, ResolvedTier::L0Cache); assert_eq!(again.receipt.executed_energy_uj, 0); - // A failing command seals honestly as Failed, still attested at S5. + // A failing command seals honestly as Failed, still attested, still S0. let mut c2 = BuildCache::new(); let fail = BuildTask { intent: "failing build".into(), steps: vec![BuildStep::command("compile", "false", vec![], cpu_grants())], }; let sf = run_build(&host, &exec, "steward.build", &fail, &mut c2, &key, zero_id(), 1_700_002).unwrap(); - assert_eq!(sf.receipt.steps[0].tier, Tier::S5); + assert_eq!(sf.receipt.steps[0].tier, Tier::S0); // A non-zero exit is recorded honestly as a non-success outcome (the // microVM treats it as a run that did not complete cleanly). assert_ne!(sf.receipt.steps[0].outcome, "Completed"); diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs index 6b9a8740c..e32d84af4 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs @@ -25,6 +25,7 @@ use ed25519_dalek::SigningKey; use sandbox_attest::fleet::{FleetItem, FleetRecord}; +use sandbox_attest::Mechanism; use sandbox_attest::manifest::{EnvironmentManifest, ManifestError}; use sandbox_attest::warmpool::WarmPool; use sandbox_attest::runrecord::{RunRecord, RunRecordError, SnapshotRef}; @@ -415,6 +416,137 @@ fn c10_fleet() -> Result<(), String> { Ok(()) } +/// **C11 — enforcement mechanism (SPEC §7.9).** Below S6 the tier is +/// host-asserted; a mechanism commits to *how* confinement was established. +/// This class pins the three properties that make the commitment worth +/// carrying: it is backward compatible, it is injective, and it is refused +/// where it would be incoherent. +fn c11_mechanism() -> Result<(), String> { + let sk = SigningKey::from_bytes(&[11u8; 32]); + let vk = sk.verifying_key(); + + // (1) Backward compatibility: an attestation without a mechanism must + // canonicalize exactly as it did before §7.9, so historical signatures and + // published vectors keep verifying. + let plain = sample_attestation(); + let before = plain.canonical_bytes().map_err(|e| format!("canon: {e}"))?; + let text = String::from_utf8(before.clone()).map_err(|e| format!("utf8: {e}"))?; + want(!text.contains("mechanism"), "absent mechanism must be omitted, not null")?; + want(plain.profile() == sandbox_attest::PROFILE, "no mechanism → /1 profile")?; + let sig = plain.sign(&sk).map_err(|e| format!("sign: {e}"))?; + plain.verify(&sig, &vk).map_err(|e| format!("verify: {e}"))?; + + // (2) Carrying a mechanism is a distinct, self-describing profile. + let mut evidenced = sample_attestation(); + evidenced.tier = Tier::S2; + evidenced.mechanism = Some(Mechanism::commit("wasmtime/43.0.2", b"consume_fuel=true", &[])); + want(evidenced.profile() == sandbox_attest::PROFILE_V2, "mechanism → /2 profile")?; + want(evidenced.canonical_bytes().map_err(|e| format!("canon: {e}"))? != before, + "the mechanism must be inside the signed bytes")?; + evidenced.validate_structure().map_err(|e| format!("S2 + mechanism: {e}"))?; + let sig2 = evidenced.sign(&sk).map_err(|e| format!("sign: {e}"))?; + evidenced.verify(&sig2, &vk).map_err(|e| format!("verify: {e}"))?; + + // (3) Adversarial: a mechanism at S0 claims a boundary that does not exist. + let mut s0 = sample_attestation(); + s0.tier = Tier::S0; + s0.mechanism = Some(Mechanism::commit("rt/1", b"p", &[])); + want(s0.validate_structure().is_err(), "S0 must refuse a mechanism")?; + + // (4) Adversarial: the commitment must be injective across field + // boundaries, or a host could move bytes between the runtime name and the + // profile and keep the same digest. + let a = Mechanism::commit("seatbelt/14", b"5;deny-net", &[]); + let b = Mechanism::commit("seatbelt/145", b";deny-net", &[]); + want(a.profile_id != b.profile_id, "commitment must be injective")?; + + // (5) The relying-party requirement is a real gate, not a formality. + let bare = { + let mut x = sample_attestation(); + x.tier = Tier::S2; + x + }; + want(bare.require_mechanism_evidence().is_err(), "S1-S5 without mechanism must be refusable")?; + evidenced + .require_mechanism_evidence() + .map_err(|e| format!("evidenced S2 must satisfy the requirement: {e}"))?; + Ok(()) +} + +/// **C12 — locality evidence (SPEC §7.7).** The S7 claim is the ladder's +/// weakest, and this class pins what its evidence may and may not do: refute a +/// physically impossible claim, refuse to be produced by the host about itself, +/// and never report a surviving claim as proven. +fn c12_locality() -> Result<(), String> { + use sandbox_attest::{AnchorObservation, LocalityEvidence, LocalityVerdict}; + + // Claim Frankfurt; anchor in Ashburn, ~6,900 km away. + let ev = |rtt_us: u64, signed: bool| LocalityEvidence { + challenge_hex: "cc".repeat(32), + claimed_lat_micro: 50_110_924, + claimed_lon_micro: 8_682_127, + observations: vec![AnchorObservation { + anchor_id: "ashburn".into(), + lat_micro: 39_043_757, + lon_micro: -77_487_442, + rtt_us, + anchor_sig: signed.then(|| "sig".to_string()), + }], + }; + + // (1) Physically impossible claims are refuted, and that does not rest on + // trusting the host: 5 ms of light does not cross the Atlantic. + match ev(5_000, true).check() { + LocalityVerdict::Refuted { claimed_km, max_km, .. } => { + want(claimed_km > max_km, "a refutation must actually exceed the light bound")?; + } + other => return Err(format!("expected Refuted, got {other:?}")), + } + + // (2) A surviving claim is NOT proven. A 200 ms bound reaches most of the + // planet, and the slack says so. + match ev(200_000, true).check() { + LocalityVerdict::NotRefuted { slack_km, .. } => { + want(slack_km > 20_000.0, "a loose bound must expose its own slack")?; + } + other => return Err(format!("expected NotRefuted, got {other:?}")), + } + + // (3) Self-measured latency is not evidence — even when it would refute. + want( + matches!(ev(5_000, false).check(), LocalityVerdict::Unsubstantiated { .. }), + "an unsigned observation must not produce a verdict", + )?; + + // (4) A host cannot seal an attestation its own evidence refutes. + let mut a = sample_attestation(); + a.tier = Tier::S7; + a.tee = Some(sandbox_attest::TeeEvidence { + kind: sandbox_attest::TeeKind::SevSnp, + measurement_hex: "00".into(), + tcb: "tcb".into(), + }); + a.locality = Some(sandbox_attest::Locality { + region: "eu-central-1".into(), + jurisdiction: "DE".into(), + }); + a.audit_prev = Some(ContentId::of_bytes(b"prev")); + a.locality_evidence = Some(ev(5_000, true)); + want(a.validate_structure().is_err(), "a self-refuting locality must not seal")?; + + // (5) Evidence below S7 evidences a claim nobody made. + let mut b = sample_attestation(); + b.locality_evidence = Some(ev(200_000, true)); + want(b.validate_structure().is_err(), "locality evidence below S7 must be rejected")?; + + // (6) Absent evidence leaves the signed bytes untouched. + let plain = sample_attestation(); + let text = String::from_utf8(plain.canonical_bytes().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + want(!text.contains("locality_evidence"), "absent evidence must be omitted, not null")?; + Ok(()) +} + /// Run the full in-code pack. A class that cannot run is a fail, not a skip. pub fn run() -> Report { Report { @@ -429,6 +561,8 @@ pub fn run() -> Report { check("C8 content-exec", c8), check("C9 warm-pool", c9_warmpool), check("C10 fleet", c10_fleet), + check("C11 enforcement-mechanism", c11_mechanism), + check("C12 locality-evidence", c12_locality), ], } } @@ -540,6 +674,8 @@ fn sample_attestation() -> Attestation { tee: None, locality: None, audit_prev: None, + mechanism: None, + locality_evidence: None, host_id: "host-1".into(), started_at_us: 1000, finished_at_us: 2000, @@ -557,7 +693,10 @@ mod tests { assert!(r.passed, "class {} failed: {}", r.class, r.detail); } assert!(report.ok()); - assert_eq!(report.results.len(), 10); + // C1–C12. Bump deliberately when a class is added, never to make a + // red build green: a silently shrinking pack is a standard quietly + // certifying less than it claims. + assert_eq!(report.results.len(), 12); } #[test] diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs index 8ac0d077b..2a04e5418 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/vectors.rs @@ -292,6 +292,8 @@ fn sample_attestation() -> Attestation { tee: None, locality: None, audit_prev: None, + mechanism: None, + locality_evidence: None, host_id: "host-1".into(), started_at_us: 1000, finished_at_us: 2000, diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/firecracker.rs b/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/firecracker.rs index 6dda93077..84b53b18a 100644 --- a/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/firecracker.rs +++ b/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/firecracker.rs @@ -15,6 +15,8 @@ use std::process::Command; use std::time::Instant; +use sandbox_schema::Tier; + /// The configuration for one microVM boot. #[derive(Clone, Debug)] pub struct FirecrackerConfig { @@ -96,6 +98,27 @@ pub enum VmmError { /// reference [`FirecrackerVmm`] talks to a real Firecracker; tests use a mock. pub trait Vmm { fn run(&self, cfg: &FirecrackerConfig) -> Result; + + /// The isolation tier this VMM **actually** achieves on **this host** — + /// what a receipt may honestly claim about the run (SPEC §2.2). + /// + /// The default is [`Tier::S0`], the floor: "process only; trusted code". + /// That default is deliberate. A tier is an assertion a third party is + /// invited to rely on, so the burden of proof sits with the implementation + /// that claims a rung — an executor that forgets to override this + /// under-claims, which is survivable, rather than over-claiming, which + /// makes the receipt a lie. Callers must seal this value; they must never + /// assert a tier of their own. + fn attained_tier(&self) -> Tier { + Tier::S0 + } +} + +/// Whether this host exposes hardware virtualization to userspace — the +/// precondition for any rung at or above [`Tier::S5`]. Without it there is no +/// VMM, no guest kernel, and no dedicated vCPU, whatever the caller intended. +pub fn hardware_virtualization_available() -> bool { + std::path::Path::new("/dev/kvm").exists() } /// The reference Firecracker driver. @@ -149,6 +172,17 @@ impl FirecrackerVmm { } impl Vmm for FirecrackerVmm { + /// Firecracker is an S5-class VMM, but only where the kernel offers + /// hardware virtualization; on a host without it the driver cannot boot a + /// guest at all, so the honest tier is the floor. + fn attained_tier(&self) -> Tier { + if hardware_virtualization_available() { + Tier::S5 + } else { + Tier::S0 + } + } + fn run(&self, cfg: &FirecrackerConfig) -> Result { Self::put(&cfg.api_socket, "/boot-source", &cfg.boot_source_json())?; Self::put(&cfg.api_socket, "/drives/rootfs", &cfg.rootfs_json())?; @@ -177,19 +211,57 @@ impl Vmm for FirecrackerVmm { pub struct ProcessVmm { pub program: String, pub args: Vec, + /// Whether the supervised program is itself a hardware VMM. This is what + /// separates an S5 run from a bare subprocess, and it cannot be inferred + /// from a program name — so it is declared at construction and still + /// checked against the host. See [`ProcessVmm::attained_tier`]. + supervises_vmm: bool, } impl ProcessVmm { - /// Supervise the given program with arguments. + /// Supervise the given program as an ordinary child process. + /// + /// This grants **no** isolation of its own: the program runs on the host, + /// with the host's kernel and the host's ambient authority. Its tier is + /// [`Tier::S0`]. To supervise a real VMM, use + /// [`ProcessVmm::supervising_vmm`]. pub fn new(program: impl Into, args: Vec) -> Self { ProcessVmm { program: program.into(), args, + supervises_vmm: false, + } + } + + /// Supervise a program that is itself a hardware VMM — in production + /// `firecracker --api-sock --config-file ` against a jailer. + /// + /// The caller asserts that the program boots a guest under a dedicated + /// vCPU/memory allocation. That assertion alone is not enough to earn the + /// rung: [`ProcessVmm::attained_tier`] still requires the host to actually + /// expose hardware virtualization. + pub fn supervising_vmm(program: impl Into, args: Vec) -> Self { + ProcessVmm { + program: program.into(), + args, + supervises_vmm: true, } } } impl Vmm for ProcessVmm { + /// S5 only when this instance was built with + /// [`ProcessVmm::supervising_vmm`] *and* the host exposes hardware + /// virtualization. A plain [`ProcessVmm::new`] supervising an arbitrary + /// program is a child process on the host: [`Tier::S0`]. + fn attained_tier(&self) -> Tier { + if self.supervises_vmm && hardware_virtualization_available() { + Tier::S5 + } else { + Tier::S0 + } + } + fn run(&self, cfg: &FirecrackerConfig) -> Result { let start = Instant::now(); let status = Command::new(&self.program) @@ -197,8 +269,10 @@ impl Vmm for ProcessVmm { .status() .map_err(|e| VmmError::Unavailable(format!("spawn {}: {e}", self.program)))?; let elapsed = start.elapsed().as_secs(); - // Dedicated vCPUs × wall-clock seconds → vCPU-seconds (SPEC §2.2: S5 - // requires dedicated allocation, so this attribution is clean). + // Dedicated vCPUs × wall-clock seconds → vCPU-seconds. The attribution + // is only clean when a guest really does hold a dedicated allocation + // (SPEC §2.2); for a bare supervised process it is wall-clock scaled by + // the configured vCPU count, and the sealed tier says so. let vcpu_seconds = u64::from(cfg.vcpus).saturating_mul(elapsed); Ok(VmOutcome { state: if status.success() { @@ -257,4 +331,39 @@ mod tests { let missing = ProcessVmm::new("sandbox-no-such-binary-xyz", vec![]).run(&cfg()); assert!(matches!(missing, Err(VmmError::Unavailable(_)))); } + + /// The tier is a claim a third party relies on, so the floor is the + /// default and a higher rung must be earned twice: declared by the + /// implementation *and* backed by the host. + #[test] + fn a_supervised_process_never_claims_a_virtualization_tier() { + // A bare supervised program is a child process on the host. Whatever + // the host can do, *this* run got no guest kernel and no dedicated + // vCPU — so it is S0 on every machine, KVM present or not. + assert_eq!(ProcessVmm::new("true", vec![]).attained_tier(), Tier::S0); + + // Declaring VMM supervision is necessary but not sufficient: without + // hardware virtualization there is no VMM to supervise, so the claim + // degrades to the floor rather than being sealed as an S5 lie. + let declared = ProcessVmm::supervising_vmm("firecracker", vec![]).attained_tier(); + if hardware_virtualization_available() { + assert_eq!(declared, Tier::S5); + } else { + assert_eq!(declared, Tier::S0); + } + assert_eq!(FirecrackerVmm.attained_tier(), declared); + } + + /// An executor that does not override `attained_tier` under-claims (safe) + /// rather than over-claims (a false receipt). + #[test] + fn the_trait_default_is_the_floor() { + struct Silent; + impl Vmm for Silent { + fn run(&self, _cfg: &FirecrackerConfig) -> Result { + Ok(VmOutcome { state: VmState::Completed, vcpu_seconds: 0 }) + } + } + assert_eq!(Silent.attained_tier(), Tier::S0); + } } diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/lib.rs index 8063b2c9d..86187620d 100644 --- a/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-exec-microvm/src/lib.rs @@ -135,8 +135,15 @@ impl MicroVmExecutor { } impl Executor for MicroVmExecutor { + /// Delegated to the VMM, never asserted here. This executor is *microVM + /// shaped*, but the rung it reaches is a property of the VMM it was handed + /// and the host it runs on — wrap a bare supervised process and the honest + /// answer is S0. `Host::run_and_seal` compares this against the tier the + /// grants require, so a static `Tier::S5` here would let a run that never + /// touched a hypervisor pass that check (SPEC: a host MUST NOT seal a tier + /// a run did not execute at). fn tier(&self) -> Tier { - Tier::S5 + self.vmm.attained_tier() } fn run(&self, unit: &Unit) -> Result { diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs index 8cb198536..633c693c5 100644 --- a/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-exec-wasm/src/lib.rs @@ -22,7 +22,7 @@ use host::HostState; use sandbox_attest::{DeviceIdentity, Outcome}; use sandbox_capability::Grants; use sandbox_meter::{DeviceEnergy, Provenance}; -use sandbox_runtime::{ExecError, Executor, RunReport, Unit}; +use sandbox_runtime::{ExecError, Executor, MechanismRef as Mechanism, RunReport, Unit}; use sandbox_schema::{DeviceClass, Tier}; use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimitsBuilder}; @@ -119,11 +119,47 @@ impl WasmExecutor { } } +/// The wasmtime version this crate is built against, as it appears in the +/// enforcement commitment (SPEC §7.9). +/// +/// wasmtime exposes no version constant and Cargo gives a crate no compile-time +/// access to a dependency's resolved version, so this is maintained by hand — +/// and `wasmtime_version_matches_the_lockfile` fails the build if it drifts. A +/// stale value here would silently attest confinement to the wrong runtime, +/// which is precisely the class of untruth §7.9 exists to prevent. +pub const WASMTIME_VERSION: &str = "43.0.2"; + impl Executor for WasmExecutor { fn tier(&self) -> Tier { Tier::S2 } + /// Commit to the confinement this executor actually installs (SPEC §7.9). + /// + /// The bytes committed are the enforcement-relevant configuration — fuel + /// metering on, the fuel budget per granted CPU-millisecond, and the memory + /// cap — not the whole `WasmConfig`. `uj_per_million_fuel` is deliberately + /// excluded: it is an energy-attribution coefficient that confines nothing, + /// and folding it in would make two identically-confined runs disagree + /// because someone retuned an accounting constant. + /// + /// The egress allowlist is committed as empty here, not omitted by + /// oversight: this executor brokers egress per-unit from the grants, and + /// the attestation already commits to those through `grants_id`. An + /// OS-level executor that installs an allowlist into a proxy at setup time + /// would pass it here instead. + fn mechanism(&self) -> Option { + let profile = format!( + "consume_fuel=true;fuel_per_cpu_ms={};default_fuel={};default_memory_bytes={}", + self.cfg.fuel_per_cpu_ms, self.cfg.default_fuel, self.cfg.default_memory_bytes + ); + Some(Mechanism::commit( + format!("wasmtime/{WASMTIME_VERSION}"), + profile.as_bytes(), + &[], + )) + } + fn run(&self, unit: &Unit) -> Result { // Honest rejection: this executor cannot isolate/meter a raw accelerator. if let Some(c) = unit @@ -209,6 +245,30 @@ impl Executor for WasmExecutor { #[cfg(test)] mod tests { + /// The hand-maintained [`WASMTIME_VERSION`] must equal what Cargo actually + /// resolved, or every mechanism commitment names a runtime that did not run. + #[test] + fn wasmtime_version_matches_the_lockfile() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .find(|p| p.join("Cargo.lock").exists()) + .expect("workspace Cargo.lock"); + let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); + let resolved = lock + .split("[[package]]") + .find(|b| b.contains("name = \"wasmtime\"\n")) + .and_then(|b| { + b.lines() + .find(|l| l.starts_with("version = ")) + .map(|l| l.trim_start_matches("version = ").trim_matches('"').to_string()) + }) + .expect("wasmtime in Cargo.lock"); + assert_eq!( + WASMTIME_VERSION, resolved, + "WASMTIME_VERSION is stale: update it to the resolved version" + ); + } + use super::*; use sandbox_capability::ComputeGrant; diff --git a/sandbox/sandbox-rs/crates/sandbox-proof-wasm/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-proof-wasm/src/lib.rs index 11039afc1..3f2bf4b12 100644 --- a/sandbox/sandbox-rs/crates/sandbox-proof-wasm/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-proof-wasm/src/lib.rs @@ -373,6 +373,8 @@ fn sample_attestation() -> Result { tee: None, locality: None, audit_prev: None, + mechanism: None, + locality_evidence: None, host_id: "host-1".into(), started_at_us: 1000, finished_at_us: 2000, @@ -425,6 +427,8 @@ fn sealed_browser_run(at_us: u64) -> Result<(Receipt, Attestation, ContentId), S tee: None, locality: None, audit_prev: None, + mechanism: None, + locality_evidence: None, host_id: "sandbox-proof-wasm".into(), started_at_us: at_us, finished_at_us: at_us, diff --git a/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs index c576b0451..ee043f524 100644 --- a/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-proof/src/lib.rs @@ -402,7 +402,10 @@ pub fn prove_microvm( let cfg_path = format!("/tmp/sandbox-fc-{}.json", std::process::id()); std::fs::write(&cfg_path, cfg.to_string()).map_err(|e| ProofError::Serde(e.to_string()))?; - let vmm = ProcessVmm::new( + // The supervised program here really is a hardware VMM (firecracker), and + // this path already refused to run without /dev/kvm above — so it may claim + // the rung, and `attained_tier()` re-checks the host regardless. + let vmm = ProcessVmm::supervising_vmm( &inputs.fc_bin, vec!["--no-api".into(), "--config-file".into(), cfg_path], ); @@ -933,6 +936,8 @@ pub fn prove_confidential( tee: Some(verified.to_evidence()), locality: locality_field, audit_prev: audit_prev_field, + mechanism: None, + locality_evidence: None, host_id: "sandbox-proof".to_string(), started_at_us: at_unix_us, finished_at_us: at_unix_us, diff --git a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs index 64c600241..33ba6a692 100644 --- a/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-runtime/src/lib.rs @@ -14,7 +14,13 @@ use ed25519_dalek::{SigningKey, VerifyingKey}; use p384::ecdsa::VerifyingKey as VcekKey; -use sandbox_attest::{Attestation, AttestError, AuditLog, DeviceIdentity, Journal, Locality, Outcome}; +use sandbox_attest::{ + Attestation, AttestError, AuditLog, DeviceIdentity, Journal, Locality, Mechanism, Outcome, +}; + +/// Re-exported so an [`Executor`] implementation can describe its confinement +/// without depending on `sandbox-attest` directly (SPEC §7.9). +pub use sandbox_attest::Mechanism as MechanismRef; use sandbox_capability::Grants; use sandbox_meter::{DeviceEnergy, Receipt, ReceiptError, StorageActivity}; use sandbox_schema::{ContentId, DeviceClass, Tier}; @@ -180,6 +186,16 @@ pub trait Executor { fn tier(&self) -> Tier; /// Run the unit under its grants, returning what was spent and observed. fn run(&self, unit: &Unit) -> Result; + /// How this executor established confinement (SPEC §7.9), where it can say. + /// + /// Defaults to `None`: an executor that cannot describe its enforcement + /// says nothing rather than inventing a commitment. As with + /// [`Executor::tier`], the honest answer comes from the component that did + /// the work — a caller is in no position to describe a boundary it did not + /// establish. + fn mechanism(&self) -> Option { + None + } } /// Errors from assembling a sealed run. @@ -306,6 +322,7 @@ impl Host { /// `executor.tier() == required_tier(grants)` before sealing. A host **MUST /// NOT** seal a tier a run did not execute at. #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] pub fn seal_run( &self, unit_id: ContentId, @@ -317,6 +334,34 @@ impl Host { nonce: [u8; 8], started_at_us: u64, finished_at_us: u64, + ) -> Result { + self.seal_run_with_mechanism( + unit_id, image_id, grants, tier, report, sequence, nonce, started_at_us, + finished_at_us, None, + ) + } + + /// [`Host::seal_run`], additionally committing to **how** confinement was + /// established (SPEC §7.9). + /// + /// Prefer [`Host::run_and_seal`], which takes the mechanism from the + /// executor that actually enforced the boundary rather than from the + /// caller. A mechanism supplied here is the caller's assertion, and at S0 + /// it is dropped rather than sealed: S0 is unconfined, so there is no + /// boundary to describe. + #[allow(clippy::too_many_arguments)] + pub fn seal_run_with_mechanism( + &self, + unit_id: ContentId, + image_id: ContentId, + grants: &Grants, + tier: Tier, + report: RunReport, + sequence: u64, + nonce: [u8; 8], + started_at_us: u64, + finished_at_us: u64, + mechanism: Option, ) -> Result { // Build and seal the energy receipt (validates per-device provenance, // attribution, and the total). @@ -355,6 +400,8 @@ impl Host { image_id, grants_id, tier, + mechanism: mechanism.filter(|_| tier != Tier::S0), + locality_evidence: None, devices: report.device_identity, receipt_id, journal_root, @@ -418,7 +465,10 @@ impl Host { let report = executor.run(unit)?; let image_id = unit.image_id(); let unit_id = unit_content_id(&image_id, &grants_content_id(unit.grants)); - self.seal_run( + // The mechanism comes from the executor that enforced the boundary, + // never from this caller (SPEC §7.9). + let mechanism = executor.mechanism(); + self.seal_run_with_mechanism( unit_id, image_id, unit.grants, @@ -428,6 +478,7 @@ impl Host { nonce, started_at_us, finished_at_us, + mechanism, ) } } diff --git a/sandbox/spec/attestation.md b/sandbox/spec/attestation.md index 9c09a100f..60c7200ed 100644 --- a/sandbox/spec/attestation.md +++ b/sandbox/spec/attestation.md @@ -19,6 +19,7 @@ Attestation { tee : Option, // present iff tier >= S6 (SPEC §7.6) locality : Option, // REQUIRED at S7 (SPEC §7.7) audit_prev : Option, // REQUIRED at S7: audit-chain head before this run + mechanism : Option, // how confinement was established; S1-S5 (SPEC §7.9) host_id : String, started_at_us : u64, finished_at_us: u64, @@ -26,6 +27,11 @@ Attestation { DeviceIdentity { class : DeviceClass, model : String, driver : String } +Mechanism { runtime : String, profile_id : ContentId } + // runtime : enforcement runtime, name AND version ("wasmtime/43.0.2") + // profile_id : BLAKE3 over the length-prefixed encoding of + // (runtime, policy profile applied, egress allowlist installed) + Outcome = Completed | Failed | Terminated { breach : Breach } // EnergyBudget | CpuTime | Memory | Tamper | GrantMutation @@ -39,6 +45,8 @@ Locality { region : String, jurisdiction : String } 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. +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). + At **S7**, the host additionally binds `locality` and links the run into a hash-chained audit log: `audit_prev` is the chain head before this run, and after signing the host appends the attestation's content address, advancing the head (`head' = BLAKE3(head ‖ attestation_id)`). The chain is tamper-evident, so the ordered sequence of sovereign runs is itself attestable (SPEC §7.7). ## Device identity (REQUIRED — heterogeneity clause) diff --git a/sandbox/spec/conformance.md b/sandbox/spec/conformance.md index 0c6a4c259..77559dcbf 100644 --- a/sandbox/spec/conformance.md +++ b/sandbox/spec/conformance.md @@ -36,6 +36,9 @@ A claim attributes the slot's accrued idle energy to the run and is refused unle ### C10 — Fleet records (§16) Every item in a fleet proves against the merkle aggregate root with a logarithmic proof, across odd and even leaf counts. A foreign item on a real proof path fails to verify, and any change to a leaf changes the root. +### C11 — Enforcement mechanism (§7.9) +An attestation without a `mechanism` canonicalizes to byte-identical JSON under the `/1` profile, so historical signatures and published vectors still verify; one carrying a mechanism is `/2`, and the commitment is inside the signed bytes. The commitment is injective across field boundaries — moving bytes between the runtime name and the profile changes the digest. A mechanism at S0 is refused, and the relying-party requirement rejects a bare S1–S5 tier label while an evidenced one satisfies it. + ## Adversarial obligation The most valuable vectors are **negatives** — runs a conformant host must reject. A standard whose vectors only assert the happy path certifies nothing. The committed pack ([`../conformance/v1/negative.json`](../conformance/v1/negative.json)) carries a **C-neg** class the harness asserts is rejected: