diff --git a/.github/workflows/attest-verifier.yml b/.github/workflows/attest-verifier.yml new file mode 100644 index 0000000..8b3c775 --- /dev/null +++ b/.github/workflows/attest-verifier.yml @@ -0,0 +1,51 @@ +name: attest-verifier + +# The L0 verifier core (boot attestation + validator quorum) is pure Rust and MUST run on any +# silicon a device might be — an Apple-Silicon M2, an x86_64 or a RISC-V sovereign-silicon box. +# This gate tests it natively on x86_64 and compile-checks it for aarch64 and riscv64, so the +# "same binary logic, any silicon" claim is enforced, not asserted. +on: + push: + paths: + - 'runtime/quorumd/**' + - 'runtime/watchdog-validator/**' + - '.github/workflows/attest-verifier.yml' + pull_request: + paths: + - 'runtime/quorumd/**' + - 'runtime/watchdog-validator/**' + - '.github/workflows/attest-verifier.yml' + +permissions: + contents: read + +jobs: + test-x86_64: + name: test (x86_64 sovereign-silicon) + runs-on: ubuntu-latest + defaults: + run: + working-directory: runtime + steps: + - uses: actions/checkout@v4 + - name: Boot-attestation + quorum verifier tests + run: cargo test -p quorumd -p watchdog-validator + + cross-silicon-check: + name: compile-check (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [aarch64-unknown-linux-gnu, riscv64gc-unknown-linux-gnu] + defaults: + run: + working-directory: runtime + steps: + - uses: actions/checkout@v4 + - name: Add target + run: rustup target add ${{ matrix.target }} + # `cargo check` needs the target's std, not a cross-linker — enough to prove the pure-Rust + # verifier compiles for this silicon. + - name: Compile-check the verifier for ${{ matrix.target }} + run: cargo check -p quorumd -p watchdog-validator --target ${{ matrix.target }} diff --git a/runtime/quorumd/Cargo.toml b/runtime/quorumd/Cargo.toml index afa505b..e198876 100644 --- a/runtime/quorumd/Cargo.toml +++ b/runtime/quorumd/Cargo.toml @@ -7,7 +7,11 @@ description = "Minimal validator quorum daemon scaffold for SourceOS runtime" [dependencies] chrono = { version = "0.4", features = ["serde"] } +ed25519-dalek = "2" +hex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" thiserror = "1" uuid = { version = "1", features = ["v4", "serde"] } +watchdog-validator = { path = "../watchdog-validator" } diff --git a/runtime/quorumd/src/lib.rs b/runtime/quorumd/src/lib.rs index 61c9dc1..cc18bd3 100644 --- a/runtime/quorumd/src/lib.rs +++ b/runtime/quorumd/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Vote { @@ -6,6 +7,277 @@ pub struct Vote { pub verdict: String, } +// ── Canonical validator-quorum verifier (the on-device + CLI verifier) ────────────────────── +// +// Conforms to the authoritative QuorumProof shape (mcp-a2a-zero-trust :: +// schemas/canonical/quorum_proof.schema.json). This is the SAME contract the prophet-platform +// Python verifier (PP #1370) checks; the two are twins over one shape, not two schemas. +// +// Pure Rust, no arch-specific code — the identical binary logic runs on aarch64 (Apple Silicon), +// x86_64, and riscv64. This is why it lives at L0 in Rust and not in the cloud runtime: the +// canon runs this check IN the boot path (bootProbe halts on a failed Genesis quorum), before +// any network exists — a device decides its own trust locally, on whatever silicon it is. + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuorumSignature { + pub kind: String, + pub spiffe_id: String, + pub sig: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuorumProof { + pub rule: String, + pub validators: Vec, + pub signed_payload_hash: String, + pub signatures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuorumOutcome { + pub ok: bool, + pub reasons: Vec, +} + +/// Parse a `MofN-kind` rule (e.g. "2of3-human"). Returns None on M<1, N<1, or M>N. +fn parse_rule(rule: &str) -> Option<(usize, usize, &str)> { + let (m_n, kind) = rule.split_once('-')?; + let (m, n) = m_n.split_once("of")?; + let threshold: usize = m.parse().ok()?; + let total: usize = n.parse().ok()?; + if threshold < 1 || total < 1 || threshold > total { + return None; + } + Some((threshold, total, kind)) +} + +fn is_payload_hash(s: &str) -> bool { + s.len() == 7 + 64 + && s.starts_with("sha256:") + && s[7..].bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Verify a QuorumProof: shape + M-of-N threshold, fail-closed. When `payload_hash` is given the +/// proof must be over exactly that payload (binds the quorum to the thing being admitted). +/// +/// NOTE (v1): this checks that `threshold` DISTINCT listed validators each supplied a non-trivial +/// signature. Cryptographic verification of each `sig` against the validator's FIDO2/NitroKey +/// public key is the next step; the shape and threshold arithmetic are canonical here. +pub fn verify_quorum(proof: &QuorumProof, payload_hash: Option<&str>) -> QuorumOutcome { + let mut reasons: Vec = Vec::new(); + + let (threshold, total, kind) = match parse_rule(&proof.rule) { + Some(r) => r, + None => { + return QuorumOutcome { + ok: false, + reasons: vec![format!("rule '{}' does not parse as MofN-kind (1<=M<=N)", proof.rule)], + } + } + }; + + let vset: BTreeSet<&str> = proof.validators.iter().map(String::as_str).collect(); + if vset.len() != proof.validators.len() { + reasons.push("validators list has duplicates".into()); + } + if vset.len() < total { + reasons.push(format!("rule needs {} validators; only {} listed", total, vset.len())); + } + + if !is_payload_hash(&proof.signed_payload_hash) { + reasons.push("signed_payload_hash must be sha256:<64hex>".into()); + } else if let Some(ph) = payload_hash { + if proof.signed_payload_hash != ph { + reasons.push("signed_payload_hash does not match the admitted payload (quorum unbound)".into()); + } + } + + let mut seen: BTreeSet<&str> = BTreeSet::new(); + let mut valid = 0usize; + for (i, s) in proof.signatures.iter().enumerate() { + if s.kind != kind { + reasons.push(format!("signature[{i}] kind '{}' != rule kind '{kind}'", s.kind)); + continue; + } + if !vset.contains(s.spiffe_id.as_str()) { + reasons.push(format!("signature[{i}] signer '{}' is not a listed validator", s.spiffe_id)); + continue; + } + if seen.contains(s.spiffe_id.as_str()) { + reasons.push(format!("signature[{i}] duplicate signer '{}'", s.spiffe_id)); + continue; + } + if s.sig.len() < 16 { + reasons.push(format!("signature[{i}] sig too short / missing")); + continue; + } + seen.insert(s.spiffe_id.as_str()); + valid += 1; + } + if valid < threshold { + reasons.push(format!( + "{valid} valid distinct signature(s) < threshold {threshold} (rule {})", + proof.rule + )); + } + + QuorumOutcome { ok: reasons.is_empty(), reasons } +} + +// ── Cryptographic quorum: real validator signatures (Ed25519 / NitroKey / sovereign key) ──── +// +// `verify_quorum` checks the SHAPE + threshold + that distinct listed validators supplied a +// signature. `verify_quorum_signed` goes the last mile: each signature must be a valid Ed25519 +// signature by the validator's REGISTERED public key over the signed_payload_hash. An attacker +// cannot forge a validator's vote without that validator's private key — "every validator keeps +// its own truth" made real. Ed25519 is the NitroKey / OpenSSH / sovereign-key form; pure Rust, +// no arch-specific code, so it verifies the same on aarch64 / x86_64 / riscv64. Keys are pinned +// OUT OF BAND (Genesis enrollment) — a signature counts only if the signer is in BOTH the proof's +// `validators` and the registered key set. (ES256/WebAuthn assertions are a follow-up.) + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +/// spiffe_id -> Ed25519 public key, hex-encoded (64 hex chars = 32 bytes). +pub type ValidatorKeys = std::collections::BTreeMap; + +fn ed25519_ok(pubkey_hex: &str, message: &[u8], sig_hex: &str) -> bool { + let pk = match hex::decode(pubkey_hex) { + Ok(b) => b, + Err(_) => return false, + }; + let pk: [u8; 32] = match pk.try_into() { + Ok(a) => a, + Err(_) => return false, + }; + let sig_bytes = match hex::decode(sig_hex) { + Ok(b) => b, + Err(_) => return false, + }; + let vk = match VerifyingKey::from_bytes(&pk) { + Ok(v) => v, + Err(_) => return false, + }; + let sig = match Signature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + vk.verify(message, &sig).is_ok() +} + +/// Cryptographic quorum verification: structural validity AND >= threshold DISTINCT signatures +/// that each cryptographically verify (Ed25519) against the signer's registered key over the +/// signed_payload_hash. Fail-closed: an unregistered signer or an invalid signature does not count. +pub fn verify_quorum_signed( + proof: &QuorumProof, + payload_hash: Option<&str>, + keys: &ValidatorKeys, +) -> QuorumOutcome { + // Start from the structural check (shape, rule, payload binding, distinct listed signers). + let mut reasons = verify_quorum(proof, payload_hash).reasons; + + let (threshold, _total, kind) = match parse_rule(&proof.rule) { + Some(r) => r, + None => return QuorumOutcome { ok: false, reasons }, + }; + let vset: BTreeSet<&str> = proof.validators.iter().map(String::as_str).collect(); + let message = proof.signed_payload_hash.as_bytes(); + + let mut seen: BTreeSet<&str> = BTreeSet::new(); + let mut crypto_valid = 0usize; + for (i, s) in proof.signatures.iter().enumerate() { + if s.kind != kind || !vset.contains(s.spiffe_id.as_str()) || seen.contains(s.spiffe_id.as_str()) { + continue; // any structural reason is already recorded above + } + match keys.get(&s.spiffe_id) { + None => reasons.push(format!("signature[{i}] signer '{}' has no registered key", s.spiffe_id)), + Some(pubkey) => { + if ed25519_ok(pubkey, message, &s.sig) { + seen.insert(s.spiffe_id.as_str()); + crypto_valid += 1; + } else { + reasons.push(format!("signature[{i}] Ed25519 signature invalid for '{}'", s.spiffe_id)); + } + } + } + } + if crypto_valid < threshold { + reasons.push(format!("{crypto_valid} cryptographically-valid signature(s) < threshold {threshold}")); + } + + QuorumOutcome { ok: reasons.is_empty(), reasons } +} + +// ── Device enrollment gate — the fusion (attested boot × cryptographic quorum) ────────────── +// +// A device joins the fleet only if BOTH hold: its boot ATTESTS (the measured chain matches the +// pinned golden policy, watchdog_validator::attest_boot) AND a validator quorum CRYPTOGRAPHICALLY +// co-signs THIS enrollment (verify_quorum_signed, bound to a payload hash over the device + its +// attested boot). Neither alone is enough: an attested boot with no quorum is a device nobody +// vouched for; a quorum with no attestation vouches for an unmeasured box. This is the canon's +// Genesis binding, complete — and it runs on-device, on any silicon, in pure Rust. + +use sha2::{Digest, Sha256}; +use watchdog_validator::attestation::{attest_boot, AttestationPolicy, BootProofRecord}; + +/// The payload the validators must co-sign: binds the device to the EXACT boot that was measured, +/// so a quorum vote is valid for this device + this boot only (not replayable elsewhere). +pub fn enrollment_payload_hash(device_ref: &str, boot: &BootProofRecord) -> String { + let mut h = Sha256::new(); + h.update(device_ref.as_bytes()); + h.update(b"|outcome="); + h.update(boot.outcome.as_bytes()); + // ordered (stage, hash) pairs — the measured chain identity. + let mut stages: Vec<(&str, &str)> = boot + .stage_proofs + .iter() + .map(|s| (s.stage_name.as_str(), s.content_hash.as_str())) + .collect(); + stages.sort(); + for (name, hash) in stages { + h.update(b"|"); + h.update(name.as_bytes()); + h.update(b"="); + h.update(hash.as_bytes()); + } + format!("sha256:{}", hex::encode(h.finalize())) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnrollOutcome { + pub enrolled: bool, + pub reasons: Vec, + pub payload_hash: String, +} + +/// Enroll a device iff its boot attests AND a cryptographic validator quorum co-signs the +/// enrollment payload. Fail-closed: either check failing blocks enrollment. +pub fn enroll_device( + device_ref: &str, + boot: &BootProofRecord, + policy: &AttestationPolicy, + quorum: &QuorumProof, + keys: &ValidatorKeys, +) -> EnrollOutcome { + let mut reasons: Vec = Vec::new(); + + let att = attest_boot(boot, policy); + if !att.attested { + for r in &att.reasons { + reasons.push(format!("attestation: {r}")); + } + } + + let payload_hash = enrollment_payload_hash(device_ref, boot); + let q = verify_quorum_signed(quorum, Some(&payload_hash), keys); + if !q.ok { + for r in &q.reasons { + reasons.push(format!("quorum: {r}")); + } + } + + EnrollOutcome { enrolled: reasons.is_empty(), reasons, payload_hash } +} + pub fn aggregate(votes: &[Vote]) -> Option { let mut counts = std::collections::BTreeMap::::new(); for vote in votes { @@ -30,4 +302,210 @@ mod tests { ]; assert_eq!(aggregate(&votes).as_deref(), Some("reseal_resume")); } + + const PH: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn validators() -> Vec { + vec!["spiffe://v/1".into(), "spiffe://v/2".into(), "spiffe://v/3".into()] + } + fn sig(v: &str) -> QuorumSignature { + QuorumSignature { kind: "human".into(), spiffe_id: v.into(), sig: "MEUCIQD".to_string() + &"f".repeat(20) } + } + fn proof(sigs: Vec) -> QuorumProof { + QuorumProof { rule: "2of3-human".into(), validators: validators(), signed_payload_hash: PH.into(), signatures: sigs } + } + + #[test] + fn valid_two_of_three_passes() { + let p = proof(vec![sig("spiffe://v/1"), sig("spiffe://v/2")]); + assert!(verify_quorum(&p, Some(PH)).ok); + } + + #[test] + fn below_threshold_fails() { + assert!(!verify_quorum(&proof(vec![sig("spiffe://v/1")]), None).ok); + } + + #[test] + fn non_validator_signer_fails() { + let p = proof(vec![sig("spiffe://v/1"), sig("spiffe://intruder")]); + let o = verify_quorum(&p, None); + assert!(!o.ok && o.reasons.iter().any(|r| r.contains("not a listed validator"))); + } + + #[test] + fn duplicate_signer_not_counted_twice() { + let p = proof(vec![sig("spiffe://v/1"), sig("spiffe://v/1")]); + let o = verify_quorum(&p, None); + assert!(!o.ok && o.reasons.iter().any(|r| r.contains("duplicate"))); + } + + #[test] + fn payload_hash_binding_enforced() { + let other = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let o = verify_quorum(&proof(vec![sig("spiffe://v/1"), sig("spiffe://v/2")]), Some(other)); + assert!(!o.ok && o.reasons.iter().any(|r| r.contains("does not match"))); + } + + #[test] + fn malformed_rule_fails() { + let mut p = proof(vec![sig("spiffe://v/1"), sig("spiffe://v/2")]); + p.rule = "4of3-human".into(); + assert!(!verify_quorum(&p, None).ok); + } + + #[test] + fn kind_mismatch_fails() { + let p = proof(vec![ + QuorumSignature { kind: "machine".into(), spiffe_id: "spiffe://v/1".into(), sig: "x".repeat(20) }, + QuorumSignature { kind: "machine".into(), spiffe_id: "spiffe://v/2".into(), sig: "x".repeat(20) }, + ]); + assert!(!verify_quorum(&p, None).ok); + } + + // ── cryptographic quorum (Ed25519) ───────────────────────────────────────────────────── + use ed25519_dalek::{Signer, SigningKey}; + + // deterministic key per validator (seed = validator index), so tests need no RNG. + fn keypair(seed: u8) -> (SigningKey, String) { + let sk = SigningKey::from_bytes(&[seed; 32]); + (sk.clone(), hex::encode(sk.verifying_key().to_bytes())) + } + fn real_sig(sk: &SigningKey, spiffe: &str, payload_hash: &str) -> QuorumSignature { + let sig = sk.sign(payload_hash.as_bytes()); + QuorumSignature { kind: "human".into(), spiffe_id: spiffe.into(), sig: hex::encode(sig.to_bytes()) } + } + + #[test] + fn valid_ed25519_quorum_passes() { + let (sk1, pk1) = keypair(1); + let (sk2, pk2) = keypair(2); + let (_sk3, pk3) = keypair(3); + let mut keys = ValidatorKeys::new(); + keys.insert("spiffe://v/1".into(), pk1); + keys.insert("spiffe://v/2".into(), pk2); + keys.insert("spiffe://v/3".into(), pk3); + let p = proof(vec![real_sig(&sk1, "spiffe://v/1", PH), real_sig(&sk2, "spiffe://v/2", PH)]); + let o = verify_quorum_signed(&p, Some(PH), &keys); + assert!(o.ok, "{:?}", o.reasons); + } + + #[test] + fn forged_signature_fails() { + let (sk1, pk1) = keypair(1); + let (_sk2, pk2) = keypair(2); + let mut keys = ValidatorKeys::new(); + keys.insert("spiffe://v/1".into(), pk1); + keys.insert("spiffe://v/2".into(), pk2); + keys.insert("spiffe://v/3".into(), keypair(3).1); + // v2's signature is garbage (not signed by v2's key) — must not count. + let forged = QuorumSignature { kind: "human".into(), spiffe_id: "spiffe://v/2".into(), sig: "ab".repeat(32) }; + let p = proof(vec![real_sig(&sk1, "spiffe://v/1", PH), forged]); + let o = verify_quorum_signed(&p, Some(PH), &keys); + assert!(!o.ok && o.reasons.iter().any(|r| r.contains("Ed25519 signature invalid"))); + } + + #[test] + fn signature_over_wrong_payload_fails() { + // v2 signs a DIFFERENT payload — valid signature, wrong message → does not verify. + let (sk1, pk1) = keypair(1); + let (sk2, pk2) = keypair(2); + let mut keys = ValidatorKeys::new(); + keys.insert("spiffe://v/1".into(), pk1); + keys.insert("spiffe://v/2".into(), pk2); + keys.insert("spiffe://v/3".into(), keypair(3).1); + let other = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let p = proof(vec![real_sig(&sk1, "spiffe://v/1", PH), real_sig(&sk2, "spiffe://v/2", other)]); + let o = verify_quorum_signed(&p, Some(PH), &keys); + assert!(!o.ok); + } + + #[test] + fn unregistered_signer_fails() { + let (sk1, pk1) = keypair(1); + let (sk2, _pk2) = keypair(2); + let mut keys = ValidatorKeys::new(); + keys.insert("spiffe://v/1".into(), pk1); + // v2 has NO registered key. + keys.insert("spiffe://v/3".into(), keypair(3).1); + let p = proof(vec![real_sig(&sk1, "spiffe://v/1", PH), real_sig(&sk2, "spiffe://v/2", PH)]); + let o = verify_quorum_signed(&p, Some(PH), &keys); + assert!(!o.ok && o.reasons.iter().any(|r| r.contains("no registered key"))); + } + + // ── device enrollment fusion (attested boot × cryptographic quorum) ──────────────────── + use watchdog_validator::attestation::{AttestationPolicy, BootProofRecord, StagePin, StageProof}; + + fn vroot() -> String { + format!("sha256:{}", "d".repeat(64)) + } + + fn boot(outcome: &str) -> BootProofRecord { + let stage = |n: &str, h: String| StageProof { + stage_name: n.into(), content_hash: h, verdict: "verified".into(), artifact_ref: String::new(), + }; + BootProofRecord { + outcome: outcome.into(), + device_ref: "urn:srcos:device:d1".into(), + boot_plan_ref: "p".into(), + stage_proofs: vec![ + stage("firmware", format!("sha256:{}", "1".repeat(64))), + stage("rootfs", vroot()), + ], + signature: None, + } + } + fn boot_policy() -> AttestationPolicy { + let b = boot("success"); + AttestationPolicy { + expected_stages: b.stage_proofs.iter().map(|s| StagePin { stage_name: s.stage_name.clone(), content_hash: s.content_hash.clone() }).collect(), + rootfs_stage: Some("rootfs".into()), + rootfs_verity_root: Some(vroot()), + require_signature: false, + } + } + // a real 2-of-3 quorum signing the given enrollment payload hash. + fn quorum_over(payload_hash: &str) -> (QuorumProof, ValidatorKeys) { + let (sk1, pk1) = keypair(1); + let (sk2, pk2) = keypair(2); + let mut keys = ValidatorKeys::new(); + keys.insert("spiffe://v/1".into(), pk1); + keys.insert("spiffe://v/2".into(), pk2); + keys.insert("spiffe://v/3".into(), keypair(3).1); + let p = QuorumProof { + rule: "2of3-human".into(), + validators: validators(), + signed_payload_hash: payload_hash.into(), + signatures: vec![real_sig(&sk1, "spiffe://v/1", payload_hash), real_sig(&sk2, "spiffe://v/2", payload_hash)], + }; + (p, keys) + } + + #[test] + fn enroll_requires_both_attestation_and_quorum() { + let b = boot("success"); + let ph = enrollment_payload_hash("urn:srcos:device:d1", &b); + let (q, keys) = quorum_over(&ph); + let o = enroll_device("urn:srcos:device:d1", &b, &boot_policy(), &q, &keys); + assert!(o.enrolled, "{:?}", o.reasons); + } + + #[test] + fn enroll_rejects_unattested_boot_even_with_valid_quorum() { + let b = boot("failure"); // boot did not succeed → attestation fails + let ph = enrollment_payload_hash("urn:srcos:device:d1", &b); + let (q, keys) = quorum_over(&ph); + let o = enroll_device("urn:srcos:device:d1", &b, &boot_policy(), &q, &keys); + assert!(!o.enrolled && o.reasons.iter().any(|r| r.starts_with("attestation:"))); + } + + #[test] + fn enroll_rejects_quorum_bound_to_a_different_device() { + // a valid quorum, but signed over ANOTHER device's payload — must not enroll this one. + let b = boot("success"); + let other = enrollment_payload_hash("urn:srcos:device:OTHER", &b); + let (q, keys) = quorum_over(&other); + let o = enroll_device("urn:srcos:device:d1", &b, &boot_policy(), &q, &keys); + assert!(!o.enrolled && o.reasons.iter().any(|r| r.starts_with("quorum:"))); + } } diff --git a/runtime/watchdog-validator/src/attestation.rs b/runtime/watchdog-validator/src/attestation.rs index 8257c06..340e14d 100644 --- a/runtime/watchdog-validator/src/attestation.rs +++ b/runtime/watchdog-validator/src/attestation.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttestationSnapshot { @@ -15,6 +16,235 @@ pub fn snapshot() -> AttestationSnapshot { } } +// ── Measured boot + remote attestation (canon L0) ─────────────────────────────────────────── +// +// A device's boot is a MEASURED chain: each stage records the content hash of what it ran +// (BootProofRecord.stageProofs, the vendored sourceos-spec contract). Attestation verifies that +// measured chain against a pinned golden policy — fail-closed. A device is trustworthy from +// power-on only if EVERY stage it ran was pinned and matched; an unpinned stage is an +// unmeasured surface and fails. +// +// Pure Rust, no arch-specific code: the same verifier runs in the initramfs of an aarch64 M2 and +// an x86_64 / riscv64 sovereign-silicon box — only the pinned policy differs per silicon. The +// rootfs stage's measured hash is bound to the dm-verity root, so "the base is immutable" +// (verity) and "the base that booted is the pinned one" (attestation) are ONE evidence chain. + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StageProof { + pub stage_name: String, + pub content_hash: String, + pub verdict: String, // verified | skipped | failed | tampered + #[serde(default)] + pub artifact_ref: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootProofRecord { + pub outcome: String, // success | partial | failure | aborted + #[serde(default)] + pub device_ref: String, + #[serde(default)] + pub boot_plan_ref: String, + #[serde(default)] + pub stage_proofs: Vec, + #[serde(default)] + pub signature: Option, +} + +/// One pinned stage in the golden measured-boot chain. +#[derive(Debug, Clone)] +pub struct StagePin { + pub stage_name: String, + pub content_hash: String, +} + +/// The golden measured-boot policy for one silicon/edition. +#[derive(Debug, Clone, Default)] +pub struct AttestationPolicy { + pub expected_stages: Vec, + pub rootfs_stage: Option, // defaults to "rootfs" + pub rootfs_verity_root: Option, // dm-verity root the rootfs stage must equal + pub require_signature: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestOutcome { + pub attested: bool, + pub reasons: Vec, + pub verity_bound: bool, +} + +fn is_sha256(s: &str) -> bool { + s.len() == 7 + 64 + && s.starts_with("sha256:") + && s[7..].bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Attest a measured boot against a pinned policy. Fail-closed. +pub fn attest_boot(record: &BootProofRecord, policy: &AttestationPolicy) -> AttestOutcome { + let mut reasons: Vec = Vec::new(); + + // A policy that pins no stages could "attest" anything — refuse to be theater. + if policy.expected_stages.is_empty() { + return AttestOutcome { + attested: false, + reasons: vec!["attestation policy pins no stages — nothing measured".into()], + verity_bound: false, + }; + } + + // 1. the boot must have succeeded. + if record.outcome != "success" { + reasons.push(format!("boot outcome '{}' is not success", record.outcome)); + } + if record.stage_proofs.is_empty() { + reasons.push("no stageProofs — an unmeasured boot cannot be attested".into()); + } + + // 2. every stage that ran must have measured as 'verified'. + for s in &record.stage_proofs { + if s.verdict != "verified" { + reasons.push(format!("stage '{}' verdict '{}' != verified", s.stage_name, s.verdict)); + } + } + + // index measured stages by name + let measured: std::collections::BTreeMap<&str, &StageProof> = + record.stage_proofs.iter().map(|s| (s.stage_name.as_str(), s)).collect(); + let pinned: BTreeSet<&str> = policy.expected_stages.iter().map(|p| p.stage_name.as_str()).collect(); + + // 3. every pinned stage present with an exact hash match. + for pin in &policy.expected_stages { + match measured.get(pin.stage_name.as_str()) { + None => reasons.push(format!("expected stage '{}' missing from the boot proof", pin.stage_name)), + Some(s) if s.content_hash != pin.content_hash => reasons.push(format!( + "stage '{}' hash mismatch (measured {} != pinned {})", + pin.stage_name, s.content_hash, pin.content_hash + )), + Some(_) => {} + } + } + // 3b. fail-closed: no stage may run that isn't pinned (an unmeasured surface). + for s in &record.stage_proofs { + if !pinned.contains(s.stage_name.as_str()) { + reasons.push(format!( + "stage '{}' ran but is not pinned in the attestation policy (unmeasured surface)", + s.stage_name + )); + } + } + + // 4. dm-verity binding: the rootfs stage's measured hash == the pinned verity root. + let verity_bound = policy.rootfs_verity_root.is_some(); + if let Some(root) = &policy.rootfs_verity_root { + if !is_sha256(root) { + reasons.push("rootfsVerityRoot must be sha256:<64hex>".into()); + } + let stage = policy.rootfs_stage.as_deref().unwrap_or("rootfs"); + match measured.get(stage) { + None => reasons.push(format!("rootfs stage '{stage}' absent — cannot bind the dm-verity root")), + Some(s) if &s.content_hash != root => reasons.push(format!( + "rootfs hash {} != pinned dm-verity root {} (booted base is not the verified base)", + s.content_hash, root + )), + Some(_) => {} + } + } + + // 5. signed boot proof, if required. + if policy.require_signature && record.signature.as_deref().unwrap_or("").is_empty() { + reasons.push("attestation policy requires a signed boot proof".into()); + } + + AttestOutcome { attested: reasons.is_empty(), reasons, verity_bound } +} + +#[cfg(test)] +mod attest_tests { + use super::*; + + const VERITY: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn stage(name: &str, hash: &str, verdict: &str) -> StageProof { + StageProof { stage_name: name.into(), content_hash: hash.into(), verdict: verdict.into(), artifact_ref: String::new() } + } + fn good_stages() -> Vec { + vec![ + stage("firmware", "sha256:1111111111111111111111111111111111111111111111111111111111111111", "verified"), + stage("bootloader", "sha256:2222222222222222222222222222222222222222222222222222222222222222", "verified"), + stage("kernel", "sha256:3333333333333333333333333333333333333333333333333333333333333333", "verified"), + stage("rootfs", VERITY, "verified"), + ] + } + fn policy() -> AttestationPolicy { + AttestationPolicy { + expected_stages: good_stages().iter().map(|s| StagePin { stage_name: s.stage_name.clone(), content_hash: s.content_hash.clone() }).collect(), + rootfs_stage: Some("rootfs".into()), + rootfs_verity_root: Some(VERITY.into()), + require_signature: false, + } + } + fn record(stages: Vec, outcome: &str) -> BootProofRecord { + BootProofRecord { outcome: outcome.into(), device_ref: "urn:srcos:device:x".into(), boot_plan_ref: "p".into(), stage_proofs: stages, signature: None } + } + + #[test] + fn fully_measured_boot_attests() { + let o = attest_boot(&record(good_stages(), "success"), &policy()); + assert!(o.attested && o.verity_bound, "{:?}", o.reasons); + } + #[test] + fn non_success_outcome_rejected() { + assert!(!attest_boot(&record(good_stages(), "partial"), &policy()).attested); + } + #[test] + fn tampered_stage_rejected() { + let mut s = good_stages(); + s[3] = stage("rootfs", VERITY, "tampered"); + assert!(!attest_boot(&record(s, "success"), &policy()).attested); + } + #[test] + fn hash_mismatch_rejected() { + let mut s = good_stages(); + s[2] = stage("kernel", "sha256:9999999999999999999999999999999999999999999999999999999999999999", "verified"); + let o = attest_boot(&record(s, "success"), &policy()); + assert!(!o.attested && o.reasons.iter().any(|r| r.contains("hash mismatch"))); + } + #[test] + fn unpinned_stage_rejected() { + let mut s = good_stages(); + s.push(stage("mystery-blob", "sha256:7777777777777777777777777777777777777777777777777777777777777777", "verified")); + let o = attest_boot(&record(s, "success"), &policy()); + assert!(!o.attested && o.reasons.iter().any(|r| r.contains("not pinned"))); + } + #[test] + fn dm_verity_mismatch_rejected() { + let mut s = good_stages(); + s[3] = stage("rootfs", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "verified"); + let mut pol = policy(); + // repin the rootfs expected hash so ONLY the verity binding fails + pol.expected_stages = s.iter().map(|x| StagePin { stage_name: x.stage_name.clone(), content_hash: x.content_hash.clone() }).collect(); + let o = attest_boot(&record(s, "success"), &pol); + assert!(!o.attested && o.reasons.iter().any(|r| r.contains("dm-verity"))); + } + #[test] + fn empty_policy_attests_nothing() { + let pol = AttestationPolicy::default(); + assert!(!attest_boot(&record(good_stages(), "success"), &pol).attested); + } + #[test] + fn require_signature_enforced() { + let mut pol = policy(); + pol.require_signature = true; + assert!(!attest_boot(&record(good_stages(), "success"), &pol).attested); + let mut rec = record(good_stages(), "success"); + rec.signature = Some("MEUCIQD".to_string() + &"f".repeat(20)); + assert!(attest_boot(&rec, &pol).attested); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/runtime/watchdog-validator/src/lib.rs b/runtime/watchdog-validator/src/lib.rs index b749878..e7310d4 100644 --- a/runtime/watchdog-validator/src/lib.rs +++ b/runtime/watchdog-validator/src/lib.rs @@ -1,5 +1,7 @@ +pub mod attestation; pub mod audit_anchor; pub mod quarantine; +pub use attestation::{attest_boot, AttestOutcome, AttestationPolicy, BootProofRecord, StagePin, StageProof}; pub use audit_anchor::build_anchor_payload; pub use quarantine::{build_quarantine_plan, QuarantinePlan};