From 0c0bd16d6ffc1ad431a3e485a7e047919f1070c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:10:32 +0000 Subject: [PATCH 1/2] feat(attest): verify --report and --require-bound `attest verify` checked only that the seal's stored digests agree with one another, so a report edited after sealing still verified: nothing rehashed the report itself. `--report ` now rehashes the report file and fails on a report_hash mismatch. `--require-bound` fails on legacy seals (no hash_scheme), whose intent and evidence cannot be rehashed and are therefore not bound. Library: VerifyOptions plus verify_attestation_{file,str}_with; the existing functions keep their behaviour. VerifyResult::Ok gains report_checked so callers cannot mistake an unchecked report for a verified one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k --- CHANGELOG.adoc | 12 ++++ src/attestation/mod.rs | 135 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 34 ++++++++++- 3 files changed, 177 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 50f9828..2c9f415 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,6 +2,18 @@ === [Unreleased] +==== Added — `+attest verify --report+` and `+--require-bound+` + +* `+attest verify --report +` rehashes the report file and fails if it +does not match the seal's `+report_hash+`. Without it, verification only checks +that the stored digests agree with each other, so a report edited after sealing +still verified. +* `+attest verify --require-bound+` fails on legacy seals (no `+hash_scheme+`), +whose intent and evidence cannot be rehashed. +* The output now says whether the report was checked. Library callers use +`+VerifyOptions+` with `+verify_attestation_file_with+` / +`+verify_attestation_str_with+`; `+VerifyResult::Ok+` gains `+report_checked+`. + ==== Fixed — KYAML keys holding a line separator * KYAML output now escapes U+2028 (LS) and U+2029 (PS) as `+\uXXXX+`, as it diff --git a/src/attestation/mod.rs b/src/attestation/mod.rs index 610af5a..088cd01 100644 --- a/src/attestation/mod.rs +++ b/src/attestation/mod.rs @@ -54,12 +54,25 @@ pub enum VerifyResult { /// The embedded intent and evidence were rehashed and match the /// seal. False for legacy seals, whose hashes cannot be recomputed. content_bound: bool, + /// A report was supplied and its SHA-256 matches `seal.report_hash`. + report_checked: bool, signature_verified: bool, }, /// One or more chain invariants failed. Failed(Vec), } +/// Optional checks on top of the chain invariants. +#[derive(Debug, Clone, Copy, Default)] +pub struct VerifyOptions<'a> { + /// The report file's bytes. When given, their SHA-256 must equal + /// `seal.report_hash`, so the attestation is checked against the report + /// itself rather than only against the digest stored beside it. + pub report: Option<&'a [u8]>, + /// Reject legacy seals, whose intent and evidence cannot be rehashed. + pub require_bound: bool, +} + /// Verify an attestation sidecar file. /// /// Reads the JSON file at `path`, recomputes the chain hash from the stored @@ -72,6 +85,14 @@ pub enum VerifyResult { /// Returns `VerifyResult::Ok` when all checks pass, `VerifyResult::Failed` /// with a list of failure reasons otherwise. pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result { + verify_attestation_file_with(path, &VerifyOptions::default()) +} + +/// [`verify_attestation_file`] with the optional checks in `options`. +pub fn verify_attestation_file_with( + path: &std::path::Path, + options: &VerifyOptions<'_>, +) -> anyhow::Result { use std::io::Read; // Attestation envelopes are JSON. They embed three hashes plus a small @@ -87,12 +108,20 @@ pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result anyhow::Result { + verify_attestation_str_with(content, &VerifyOptions::default()) +} + +/// [`verify_attestation_str`] with the optional checks in `options`. +pub fn verify_attestation_str_with( + content: &str, + options: &VerifyOptions<'_>, +) -> anyhow::Result { let tree: serde_json::Value = serde_json::from_str(content) .map_err(|e| anyhow::anyhow!("parsing attestation envelope: {}", e))?; let envelope: A2mlEnvelope = serde_json::from_value(tree.clone()) @@ -165,6 +194,26 @@ pub fn verify_attestation_str(content: &str) -> anyhow::Result { } }; + if options.require_bound && seal.hash_scheme == seal::HASH_SCHEME_LEGACY { + failures + .push("legacy hash scheme: intent and evidence are not bound to the seal".to_string()); + } + + // The report itself, when supplied, must hash to seal.report_hash. + let report_checked = match options.report { + Some(report) => { + let computed = intent::sha256_str(report); + if computed != seal.report_hash { + failures.push(format!( + "report_hash mismatch: stored={} computed={}", + seal.report_hash, computed + )); + } + true + } + None => false, + }; + // decision_hash in the envelope must match seal.report_hash if envelope.decision_hash != seal.report_hash { failures.push(format!( @@ -185,6 +234,7 @@ pub fn verify_attestation_str(content: &str) -> anyhow::Result { issued_at: envelope.issued_at.clone(), chain_hash: seal.chain_hash.clone(), content_bound, + report_checked, signature_verified, }) } @@ -270,6 +320,11 @@ mod tests { /// Seal a small scan of a temporary directory and return the sidecar /// exactly as `assail --attest` writes it. fn sealed_sidecar() -> String { + sealed_sidecar_and_report().0 + } + + /// The sidecar plus the exact report bytes it seals. + fn sealed_sidecar_and_report() -> (String, String) { let dir = tempfile::tempdir().expect("tempdir"); let args = vec!["--verbose".to_string()]; let mut builder = AttestationChainBuilder::begin(dir.path(), &args).expect("begin"); @@ -280,7 +335,8 @@ mod tests { let report = crate::json::to_canonical_string(&serde_json::json!({"weak_points": []})) .expect("report"); let envelope = builder.seal(report.as_bytes(), None).expect("seal"); - crate::json::to_canonical_string(&envelope).expect("sidecar") + let sidecar = crate::json::to_canonical_string(&envelope).expect("sidecar"); + (sidecar, report) } fn edit(sidecar: &str, change: impl FnOnce(&mut serde_json::Value)) -> String { @@ -445,4 +501,79 @@ mod tests { "{reasons:?}" ); } + + fn verify_with(sidecar: &str, options: &VerifyOptions<'_>) -> VerifyResult { + verify_attestation_str_with(sidecar, options).expect("verify") + } + + #[test] + fn the_sealed_report_verifies() { + let (sidecar, report) = sealed_sidecar_and_report(); + let options = VerifyOptions { + report: Some(report.as_bytes()), + ..VerifyOptions::default() + }; + match verify_with(&sidecar, &options) { + VerifyResult::Ok { report_checked, .. } => assert!(report_checked), + VerifyResult::Failed(reasons) => panic!("{reasons:?}"), + } + // Without --report the report is not claimed as checked. + match verify_with(&sidecar, &VerifyOptions::default()) { + VerifyResult::Ok { report_checked, .. } => assert!(!report_checked), + VerifyResult::Failed(reasons) => panic!("{reasons:?}"), + } + } + + #[test] + fn an_edited_report_is_detected() { + // The seal and decision_hash agree with each other, so only + // rehashing the report itself can catch this. + let (sidecar, report) = sealed_sidecar_and_report(); + let tampered = report.replace("[]", "[{}]"); + assert_ne!(tampered, report); + let options = VerifyOptions { + report: Some(tampered.as_bytes()), + ..VerifyOptions::default() + }; + match verify_with(&sidecar, &options) { + VerifyResult::Failed(reasons) => assert!( + reasons + .iter() + .any(|r| r.starts_with("report_hash mismatch")), + "{reasons:?}" + ), + VerifyResult::Ok { .. } => panic!("edited report verified"), + } + } + + #[test] + fn require_bound_rejects_legacy_seals_only() { + let options = VerifyOptions { + require_bound: true, + ..VerifyOptions::default() + }; + let legacy = edit(&sealed_sidecar(), |tree| { + strip_scheme(tree); + let seal = &tree["attestation"]["seal"]; + let digest = |k: &str| seal[k].as_str().expect(k).to_string(); + let chain = seal::chain_hash( + seal::HASH_SCHEME_LEGACY, + &digest("intent_hash"), + &digest("evidence_hash"), + &digest("report_hash"), + ); + tree["attestation"]["seal"]["chain_hash"] = serde_json::json!(chain); + }); + match verify_with(&legacy, &options) { + VerifyResult::Failed(reasons) => assert_eq!( + reasons, + ["legacy hash scheme: intent and evidence are not bound to the seal"] + ), + VerifyResult::Ok { .. } => panic!("legacy seal passed --require-bound"), + } + match verify_with(&sealed_sidecar(), &options) { + VerifyResult::Ok { content_bound, .. } => assert!(content_bound), + VerifyResult::Failed(reasons) => panic!("{reasons:?}"), + } + } } diff --git a/src/main.rs b/src/main.rs index c68c2c2..8caa2d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -982,6 +982,15 @@ enum AttestAction { /// Path to the .attestation.json file #[arg(value_name = "FILE")] file: PathBuf, + + /// The report the attestation seals; its SHA-256 must match the seal's + /// report_hash + #[arg(long, value_name = "REPORT")] + report: Option, + + /// Fail on legacy seals, whose intent and evidence are not bound + #[arg(long)] + require_bound: bool, }, } @@ -2681,13 +2690,29 @@ fn run_main() -> Result<()> { } Commands::Attest { action } => match action { - AttestAction::Verify { file } => { - match attestation::verify_attestation_file(&file)? { + AttestAction::Verify { + file, + report, + require_bound, + } => { + let report_bytes = report + .as_deref() + .map(|path| { + fs::read(path) + .map_err(|e| anyhow::anyhow!("reading {}: {}", path.display(), e)) + }) + .transpose()?; + let options = attestation::VerifyOptions { + report: report_bytes.as_deref(), + require_bound, + }; + match attestation::verify_attestation_file_with(&file, &options)? { attestation::VerifyResult::Ok { issuer, issued_at, chain_hash, content_bound, + report_checked, signature_verified, } => { println!(" [OK] Attestation verified"); @@ -2699,6 +2724,11 @@ fn run_main() -> Result<()> { } else { println!(" Content: not rehashed (legacy hash scheme)"); } + if report_checked { + println!(" Report: rehashed, matches the seal"); + } else { + println!(" Report: not checked (pass --report)"); + } if signature_verified { println!(" Signature: verified (Ed25519)"); } else { From db5838d3d1bb828b39b8884c693f1f283f83122e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:24:24 +0000 Subject: [PATCH 2/2] fix(attest): stream the report hash in attest verify --report `attest verify --report` read the whole report into memory before hashing it, so a large or hostile file could exhaust memory. The sidecar itself is capped at 16 MiB; the report had no bound. VerifyOptions now carries the report's SHA-256 digest rather than its bytes, and attestation::sha256_reader hashes any reader through a fixed 8 KiB buffer. The CLI streams the file through it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k --- CHANGELOG.adoc | 7 +++--- src/attestation/mod.rs | 50 ++++++++++++++++++++++++++++++++++-------- src/main.rs | 9 +++++--- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 2c9f415..dae856e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -4,15 +4,16 @@ ==== Added — `+attest verify --report+` and `+--require-bound+` -* `+attest verify --report +` rehashes the report file and fails if it +* `+attest verify --report +` rehashes the report file, streaming it +through a fixed-size buffer, and fails if it does not match the seal's `+report_hash+`. Without it, verification only checks that the stored digests agree with each other, so a report edited after sealing still verified. * `+attest verify --require-bound+` fails on legacy seals (no `+hash_scheme+`), whose intent and evidence cannot be rehashed. * The output now says whether the report was checked. Library callers use -`+VerifyOptions+` with `+verify_attestation_file_with+` / -`+verify_attestation_str_with+`; `+VerifyResult::Ok+` gains `+report_checked+`. +`+VerifyOptions+` (carrying the report digest from `+sha256_reader+`) with +`+verify_attestation_file_with+` / `+verify_attestation_str_with+`; `+VerifyResult::Ok+` gains `+report_checked+`. ==== Fixed — KYAML keys holding a line separator diff --git a/src/attestation/mod.rs b/src/attestation/mod.rs index 088cd01..ad1e764 100644 --- a/src/attestation/mod.rs +++ b/src/attestation/mod.rs @@ -65,14 +65,36 @@ pub enum VerifyResult { /// Optional checks on top of the chain invariants. #[derive(Debug, Clone, Copy, Default)] pub struct VerifyOptions<'a> { - /// The report file's bytes. When given, their SHA-256 must equal - /// `seal.report_hash`, so the attestation is checked against the report - /// itself rather than only against the digest stored beside it. - pub report: Option<&'a [u8]>, + /// SHA-256 of the report file, as 64 lowercase hex characters (see + /// [`sha256_reader`]). When given, it must equal `seal.report_hash`, so + /// the attestation is checked against the report itself rather than only + /// against the digest stored beside it. A digest rather than the bytes, + /// so a caller can hash a large report as a stream. + pub report_sha256: Option<&'a str>, /// Reject legacy seals, whose intent and evidence cannot be rehashed. pub require_bound: bool, } +/// SHA-256 of everything `reader` yields, as 64 lowercase hex characters. +/// +/// Hashes through a fixed-size buffer, so memory use does not grow with the +/// input. Use it to fill [`VerifyOptions::report_sha256`]; a byte slice is a +/// reader too. +pub fn sha256_reader(mut reader: R) -> std::io::Result { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + let mut buf = [0_u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => hasher.update(&buf[..n]), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(hex::encode(hasher.finalize())) +} + /// Verify an attestation sidecar file. /// /// Reads the JSON file at `path`, recomputes the chain hash from the stored @@ -200,9 +222,8 @@ pub fn verify_attestation_str_with( } // The report itself, when supplied, must hash to seal.report_hash. - let report_checked = match options.report { - Some(report) => { - let computed = intent::sha256_str(report); + let report_checked = match options.report_sha256 { + Some(computed) => { if computed != seal.report_hash { failures.push(format!( "report_hash mismatch: stored={} computed={}", @@ -506,11 +527,22 @@ mod tests { verify_attestation_str_with(sidecar, options).expect("verify") } + #[test] + fn sha256_reader_matches_the_one_shot_hash() { + // FIPS 180-2 "abc" vector, and a stream longer than the read buffer. + assert_eq!( + sha256_reader(&b"abc"[..]).unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let big = vec![0x5a_u8; 64 * 1024 + 7]; + assert_eq!(sha256_reader(&big[..]).unwrap(), intent::sha256_str(&big)); + } + #[test] fn the_sealed_report_verifies() { let (sidecar, report) = sealed_sidecar_and_report(); let options = VerifyOptions { - report: Some(report.as_bytes()), + report_sha256: Some(&sha256_reader(report.as_bytes()).expect("hash")), ..VerifyOptions::default() }; match verify_with(&sidecar, &options) { @@ -532,7 +564,7 @@ mod tests { let tampered = report.replace("[]", "[{}]"); assert_ne!(tampered, report); let options = VerifyOptions { - report: Some(tampered.as_bytes()), + report_sha256: Some(&sha256_reader(tampered.as_bytes()).expect("hash")), ..VerifyOptions::default() }; match verify_with(&sidecar, &options) { diff --git a/src/main.rs b/src/main.rs index 8caa2d0..263cc0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2695,15 +2695,18 @@ fn run_main() -> Result<()> { report, require_bound, } => { - let report_bytes = report + // Hash the report as a stream: it may be large, and only its + // digest is compared. + let report_sha256 = report .as_deref() .map(|path| { - fs::read(path) + fs::File::open(path) + .and_then(attestation::sha256_reader) .map_err(|e| anyhow::anyhow!("reading {}: {}", path.display(), e)) }) .transpose()?; let options = attestation::VerifyOptions { - report: report_bytes.as_deref(), + report_sha256: report_sha256.as_deref(), require_bound, }; match attestation::verify_attestation_file_with(&file, &options)? {