From b2770d5755c22dae7a861e755a52d92ff0e57707 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:28:04 +0000 Subject: [PATCH 1/2] feat(attestation): hash intent and evidence as JCS and bind them on verify Seals now hash the intent and evidence records as their JCS (RFC 8785) bytes, so anyone holding the sidecar can recompute them in any language (jq -cjS ... | sha256sum). The sidecar itself is written as I-JSON/JCS. `attest verify` now rehashes the embedded intent and evidence. Before, it only checked the chain hash against the three stored digests, so both records could be edited after sealing without detection. The seal records a `hash_scheme` (`jcs-sha256`), prefixed into the chain hash so any Ed25519 signature covers it: dropping the field to skip the content check breaks the chain hash. Sidecars from before the field verify as legacy, reported as not rehashed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k --- CHANGELOG.adoc | 16 +++ src/attestation/chain.rs | 42 +++---- src/attestation/envelope.rs | 1 + src/attestation/mod.rs | 218 ++++++++++++++++++++++++++++++++++-- src/attestation/seal.rs | 63 +++++++++-- src/json.rs | 7 ++ src/main.rs | 8 +- 7 files changed, 314 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index f9f5d07..4e9400c 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,6 +2,22 @@ === [Unreleased] +==== Changed — attestation hashes are reproducible JCS + +* `+assail --attest+` now hashes the intent and evidence records as their +JCS (RFC 8785) bytes instead of `+serde_json+` struct order, and writes the +`+.attestation.json+` sidecar itself as I-JSON/JCS. Anyone holding the sidecar +can recompute both hashes in any language, for example with +`+jq -cjS .attestation.intent | sha256sum+`. +* The seal gains a `+hash_scheme+` field (`+jcs-sha256+`). The scheme is +prefixed to the chain hash, so it is covered by any Ed25519 signature: +relabelling a seal as legacy to skip the content check breaks the chain hash. +* `+attest verify+` now rehashes the embedded intent and evidence and fails on +a mismatch. Before, it checked only that the chain hash matched the three +stored digests, so the intent and evidence could be edited after sealing +without detection. Sidecars written before this change (no `+hash_scheme+`) +still verify, reported as `+not rehashed (legacy hash scheme)+`. + ==== Added — `.kyaml` input (estate YAML policy D280/D307) * Attack profiles, ambush timelines, amuck specs, `+diff+` reports and diff --git a/src/attestation/chain.rs b/src/attestation/chain.rs index 7218022..aefc3ff 100644 --- a/src/attestation/chain.rs +++ b/src/attestation/chain.rs @@ -30,11 +30,11 @@ //! //! 3. **Seal** (after scanning): `seal()` finalises the accumulator //! (which records wall clock time, CPU time, and peak RSS), serialises -//! intent and evidence to JSON, then computes: -//! - `intent_hash` = SHA-256 of the serialised intent JSON -//! - `evidence_hash` = SHA-256 of the serialised evidence JSON +//! intent and evidence to JCS (RFC 8785), then computes: +//! - `intent_hash` = SHA-256 of the intent's JCS bytes +//! - `evidence_hash` = SHA-256 of the evidence's JCS bytes //! - `report_hash` = SHA-256 of the report JSON bytes -//! - `chain_hash` = SHA-256(intent_hash || evidence_hash || report_hash) +//! - `chain_hash` = SHA-256(hash_scheme || intent_hash || evidence_hash || report_hash) //! //! The chain_hash is the cryptographic root — if any component changes, //! chain_hash changes. Optionally, chain_hash is signed with Ed25519. @@ -61,11 +61,11 @@ //! ▼ //! builder.seal(report_json, signing_key) //! │ └─ accumulator.finalize() → freezes evidence, records timing/memory -//! │ └─ serde_json::to_vec(intent) → intent bytes for hashing -//! │ └─ serde_json::to_vec(evidence) → evidence bytes for hashing +//! │ └─ JCS (RFC 8785) of intent → intent bytes for hashing +//! │ └─ JCS (RFC 8785) of evidence → evidence bytes for hashing //! │ └─ ReportSeal::create(nonce, intent_json, evidence_json, report_json) //! │ └─ Computes intent_hash, evidence_hash, report_hash -//! │ └─ chain_hash = SHA-256(intent_hash || evidence_hash || report_hash) +//! │ └─ chain_hash = SHA-256(hash_scheme || intent_hash || evidence_hash || report_hash) //! │ └─ seal.sign(key_path)? → Ed25519 over chain_hash (if key provided) //! │ └─ A2mlEnvelope::wrap(chain) → sets envelope_type = "trustfile" //! │ @@ -86,7 +86,8 @@ //! flow of the program. //! //! - **INV-5 (Chain Hash)**: Computed in `ReportSeal::create()` by hashing -//! the concatenation of intent_hash, evidence_hash, and report_hash. +//! the concatenation of hash_scheme, intent_hash, evidence_hash, and +//! report_hash. //! This is the cryptographic binding of all three phases. //! //! ## Known Gaps @@ -190,20 +191,22 @@ impl AttestationChainBuilder { /// and computes `evidence_hash` = SHA-256 of the serialised evidence. /// /// 2. **Serialise for hashing**: Converts the intent and evidence - /// structs to JSON bytes via `serde_json::to_vec`. These byte - /// sequences are what get hashed — the serialised form, not the - /// in-memory struct. This means the hash is deterministic: same - /// field values → same JSON bytes → same hash. + /// structs to their JCS (RFC 8785) bytes via + /// [`crate::json::to_canonical_bytes`]. These byte sequences are what + /// get hashed. JCS fixes key order, number form and string escaping, + /// so same field values → same bytes → same hash, and a verifier in + /// any language can recompute both hashes from the sidecar alone. /// /// 3. **Create seal**: Calls `ReportSeal::create()` with the nonce /// and the three JSON byte slices. The seal computes: /// - `intent_hash` = SHA-256(intent_json) /// - `evidence_hash` = SHA-256(evidence_json) /// - `report_hash` = SHA-256(report_json) - /// - `chain_hash` = SHA-256(intent_hash || evidence_hash || report_hash) + /// - `chain_hash` = SHA-256(hash_scheme || intent_hash || evidence_hash || report_hash) /// - /// The `||` is string concatenation of the hex digests — 64 + 64 + 64 = 192 - /// ASCII characters fed into SHA-256. + /// The `||` is string concatenation: the scheme name `jcs-sha256` + /// followed by the three hex digests, 10 + 64 + 64 + 64 = 202 ASCII + /// characters fed into SHA-256. /// /// 4. **Sign** (optional): If a signing key path is provided, calls /// `seal.sign(key_path)`, which reads the Ed25519 private key seed, @@ -225,11 +228,10 @@ impl AttestationChainBuilder { .expect("accumulator already consumed") .finalize(); - // Step 2: Serialise intent and evidence to deterministic JSON bytes. - // serde_json::to_vec produces compact JSON (no pretty-printing), - // which is deterministic for the same field values. - let intent_json = serde_json::to_vec(&self.intent)?; - let evidence_json = serde_json::to_vec(&evidence)?; + // Step 2: Serialise intent and evidence to their JCS bytes, which + // `verify_attestation_file` recomputes from the sidecar. + let intent_json = crate::json::to_canonical_bytes(&self.intent)?; + let evidence_json = crate::json::to_canonical_bytes(&evidence)?; // Step 3: Create the seal — computes all four hashes including chain_hash. // The nonce is passed directly from self.intent.session_nonce, preserving diff --git a/src/attestation/envelope.rs b/src/attestation/envelope.rs index 610008f..1cf7603 100644 --- a/src/attestation/envelope.rs +++ b/src/attestation/envelope.rs @@ -106,6 +106,7 @@ mod tests { let seal = ReportSeal { session_nonce: "a".repeat(64), + hash_scheme: crate::attestation::seal::HASH_SCHEME_JCS.to_string(), report_hash: "0".repeat(64), intent_hash: "1".repeat(64), evidence_hash: "2".repeat(64), diff --git a/src/attestation/mod.rs b/src/attestation/mod.rs index 3d2c1ca..eae34cf 100644 --- a/src/attestation/mod.rs +++ b/src/attestation/mod.rs @@ -51,6 +51,9 @@ pub enum VerifyResult { issuer: String, issued_at: String, chain_hash: String, + /// The embedded intent and evidence were rehashed and match the + /// seal. False for legacy seals, whose hashes cannot be recomputed. + content_bound: bool, signature_verified: bool, }, /// One or more chain invariants failed. @@ -61,13 +64,14 @@ pub enum VerifyResult { /// /// Reads the JSON file at `path`, recomputes the chain hash from the stored /// intent/evidence/report hashes, and checks that it matches `seal.chain_hash`. +/// Under the JCS hash scheme it also rehashes the embedded intent and +/// evidence records, so editing either one after sealing is detected. /// If `seal.signature` and `seal.public_key` are present, also verifies the /// Ed25519 signature over the chain hash. /// /// 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 { - use sha2::{Digest, Sha256}; use std::io::Read; // Attestation envelopes are JSON. They embed three hashes plus a small @@ -83,19 +87,28 @@ pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result 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()) .map_err(|e| anyhow::anyhow!("parsing attestation envelope: {}", e))?; let chain = &envelope.attestation; let seal = &chain.seal; let mut failures = Vec::new(); - // Recompute chain_hash = SHA-256(intent_hash || evidence_hash || report_hash) - let mut hasher = Sha256::new(); - hasher.update(seal.intent_hash.as_bytes()); - hasher.update(seal.evidence_hash.as_bytes()); - hasher.update(seal.report_hash.as_bytes()); - let computed_chain_hash = hex::encode(hasher.finalize()); + // Recompute chain_hash = SHA-256(hash_scheme || intent_hash || evidence_hash || report_hash) + let computed_chain_hash = seal::chain_hash( + &seal.hash_scheme, + &seal.intent_hash, + &seal.evidence_hash, + &seal.report_hash, + ); if computed_chain_hash != seal.chain_hash { failures.push(format!( @@ -104,6 +117,35 @@ pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result { + for (name, stored) in [ + ("intent", &seal.intent_hash), + ("evidence", &seal.evidence_hash), + ] { + let bytes = crate::json::to_canonical_bytes(&tree["attestation"][name])?; + let computed = intent::sha256_str(&bytes); + if &computed != stored { + failures.push(format!( + "{}_hash mismatch: stored={} computed={}", + name, stored, computed + )); + } + } + true + } + seal::HASH_SCHEME_LEGACY => false, + other => { + failures.push(format!("unknown hash_scheme: {}", other)); + false + } + }; + // decision_hash in the envelope must match seal.report_hash if envelope.decision_hash != seal.report_hash { failures.push(format!( @@ -123,6 +165,7 @@ pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result) -> bool { fn verify_signature(_seal: &ReportSeal, _failures: &mut Vec) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + + /// Seal a small scan of a temporary directory and return the sidecar + /// exactly as `assail --attest` writes it. + fn sealed_sidecar() -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let args = vec!["--verbose".to_string()]; + let mut builder = AttestationChainBuilder::begin(dir.path(), &args).expect("begin"); + builder.accumulator().record_directory("src"); + builder + .accumulator() + .record_file("main.rs", b"fn main() {}", "Rust"); + 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") + } + + fn edit(sidecar: &str, change: impl FnOnce(&mut serde_json::Value)) -> String { + let mut tree: serde_json::Value = serde_json::from_str(sidecar).expect("parse"); + change(&mut tree); + serde_json::to_string_pretty(&tree).expect("serialize") + } + + fn failures(sidecar: &str) -> Vec { + match verify_attestation_str(sidecar).expect("verify") { + VerifyResult::Failed(reasons) => reasons, + VerifyResult::Ok { .. } => Vec::new(), + } + } + + #[test] + fn fresh_sidecar_verifies_with_content_bound() { + match verify_attestation_str(&sealed_sidecar()).expect("verify") { + VerifyResult::Ok { content_bound, .. } => assert!(content_bound), + VerifyResult::Failed(reasons) => panic!("{reasons:?}"), + } + } + + #[test] + fn intent_hash_is_sha256_of_hand_written_jcs() { + // Rebuild the intent's JCS by hand (keys in code-point order, no + // whitespace) so the test does not lean on the canonicaliser it checks. + let tree: serde_json::Value = serde_json::from_str(&sealed_sidecar()).expect("parse"); + let intent = &tree["attestation"]["intent"]; + let field = |k: &str| intent[k].as_str().expect(k).to_string(); + let jcs = format!( + "{{\"cli_args\":[\"--verbose\"],\"commitment_hash\":\"{}\",\"session_nonce\":\"{}\",\ + \"target_hash\":\"{}\",\"target_path\":\"{}\",\"timestamp\":\"{}\",\ + \"tool_binary_hash\":\"{}\",\"tool_version\":\"{}\"}}", + field("commitment_hash"), + field("session_nonce"), + field("target_hash"), + field("target_path"), + field("timestamp"), + field("tool_binary_hash"), + field("tool_version"), + ); + assert_eq!( + hex::encode(Sha256::digest(jcs.as_bytes())), + tree["attestation"]["seal"]["intent_hash"] + .as_str() + .expect("intent_hash") + ); + } + + #[test] + fn reformatting_the_sidecar_keeps_it_valid() { + // The hashes cover JCS bytes, not the file's layout. + let pretty = edit(&sealed_sidecar(), |_| {}); + assert!(failures(&pretty).is_empty()); + } + + #[test] + fn editing_intent_or_evidence_after_sealing_is_detected() { + let sidecar = sealed_sidecar(); + let tampered = edit(&sidecar, |tree| { + tree["attestation"]["intent"]["cli_args"] = serde_json::json!(["--quiet"]); + }); + let reasons = failures(&tampered); + assert!( + reasons + .iter() + .any(|r| r.starts_with("intent_hash mismatch")), + "{reasons:?}" + ); + + let tampered = edit(&sidecar, |tree| { + tree["attestation"]["evidence"]["files_read"] = serde_json::json!(0); + }); + let reasons = failures(&tampered); + assert!( + reasons + .iter() + .any(|r| r.starts_with("evidence_hash mismatch")), + "{reasons:?}" + ); + } + + fn strip_scheme(tree: &mut serde_json::Value) { + tree["attestation"]["seal"] + .as_object_mut() + .expect("seal") + .remove("hash_scheme"); + } + + #[test] + fn legacy_seal_verifies_but_is_not_content_bound() { + // A seal written before `hash_scheme` existed: no field, and a + // chain hash over the three digests alone. + 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_attestation_str(&legacy).expect("verify") { + VerifyResult::Ok { content_bound, .. } => assert!(!content_bound), + VerifyResult::Failed(reasons) => panic!("{reasons:?}"), + } + } + + #[test] + fn relabelling_a_seal_as_legacy_breaks_the_chain_hash() { + // Dropping `hash_scheme` would skip the content check, so it must + // not verify: the scheme is inside the (signed) chain hash. + let downgraded = edit(&sealed_sidecar(), |tree| { + strip_scheme(tree); + tree["attestation"]["intent"]["cli_args"] = serde_json::json!(["--quiet"]); + }); + let reasons = failures(&downgraded); + assert!( + reasons.iter().any(|r| r.starts_with("chain_hash mismatch")), + "{reasons:?}" + ); + } + + #[test] + fn unknown_hash_scheme_fails() { + let unknown = edit(&sealed_sidecar(), |tree| { + tree["attestation"]["seal"]["hash_scheme"] = serde_json::json!("md5"); + }); + let reasons = failures(&unknown); + assert!( + reasons.iter().any(|r| r == "unknown hash_scheme: md5"), + "{reasons:?}" + ); + } +} diff --git a/src/attestation/seal.rs b/src/attestation/seal.rs index 9e3492b..e1659b6 100644 --- a/src/attestation/seal.rs +++ b/src/attestation/seal.rs @@ -7,18 +7,58 @@ //! the intent, evidence, and report together via a chain hash: //! //! ```text -//! chain_hash = SHA-256(intent_hash || evidence_hash || report_hash) +//! chain_hash = SHA-256(hash_scheme || intent_hash || evidence_hash || report_hash) //! ``` //! +//! Legacy seals omit the `hash_scheme` prefix. +//! //! If any component is tampered with after sealing, the chain hash will //! not match. Optionally, the chain hash can be signed with an Ed25519 //! private key to provide non-repudiation. +//! +//! `hash_scheme` records how the intent and evidence were turned into +//! bytes before hashing. Under [`HASH_SCHEME_JCS`] they are the JCS +//! (RFC 8785) bytes of each record, so anyone holding the sidecar can +//! recompute both hashes in any language. Seals written before the field +//! existed read as [`HASH_SCHEME_LEGACY`]: `serde_json` struct order, which +//! only this crate reproduces. The scheme is part of the chain hash (and so +//! of any signature), so relabelling a seal as legacy to skip the content +//! check breaks the chain hash. use super::intent::sha256_str; use anyhow::Result; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +/// Intent and evidence hashed as their JCS (RFC 8785) bytes. +pub const HASH_SCHEME_JCS: &str = "jcs-sha256"; + +/// Intent and evidence hashed as `serde_json::to_vec` output. Seals that +/// predate the `hash_scheme` field deserialise to this. +pub const HASH_SCHEME_LEGACY: &str = "serde-json-sha256"; + +fn legacy_hash_scheme() -> String { + HASH_SCHEME_LEGACY.to_string() +} + +/// The chain hash over three hex digests. Every scheme except the legacy +/// one prefixes its name, binding the scheme into the hash. +pub fn chain_hash( + hash_scheme: &str, + intent_hash: &str, + evidence_hash: &str, + report_hash: &str, +) -> String { + let mut hasher = Sha256::new(); + if hash_scheme != HASH_SCHEME_LEGACY { + hasher.update(hash_scheme.as_bytes()); + } + hasher.update(intent_hash.as_bytes()); + hasher.update(evidence_hash.as_bytes()); + hasher.update(report_hash.as_bytes()); + hex::encode(hasher.finalize()) +} + /// Post-execution seal that binds the three attestation phases together. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReportSeal { @@ -28,6 +68,10 @@ pub struct ReportSeal { /// SHA-256 of the serialised report JSON. pub report_hash: String, + /// How the intent and evidence were serialised for hashing. + #[serde(default = "legacy_hash_scheme")] + pub hash_scheme: String, + /// SHA-256 of the serialised intent JSON (i.e., `intent.commitment_hash` /// is already a commitment, but this is the hash of the full intent record). pub intent_hash: String, @@ -56,10 +100,10 @@ pub struct ReportSeal { impl ReportSeal { /// Create a seal binding the intent, evidence, and report hashes. /// - /// The `intent_json` and `evidence_json` are the serialised forms of - /// the respective structs. The `report_json` is the serialised scan - /// report. All three are hashed independently, then combined into - /// the chain hash. + /// `intent_json` and `evidence_json` must be the JCS bytes of the + /// intent and evidence records, because the seal records + /// [`HASH_SCHEME_JCS`]. The `report_json` is the report file as written. + /// All three are hashed independently, then combined into the chain hash. pub fn create( session_nonce: &str, intent_json: &[u8], @@ -70,16 +114,11 @@ impl ReportSeal { let evidence_hash = sha256_str(evidence_json); let report_hash = sha256_str(report_json); - let chain_hash = { - let mut hasher = Sha256::new(); - hasher.update(intent_hash.as_bytes()); - hasher.update(evidence_hash.as_bytes()); - hasher.update(report_hash.as_bytes()); - hex::encode(hasher.finalize()) - }; + let chain_hash = chain_hash(HASH_SCHEME_JCS, &intent_hash, &evidence_hash, &report_hash); Self { session_nonce: session_nonce.to_string(), + hash_scheme: HASH_SCHEME_JCS.to_string(), report_hash, intent_hash, evidence_hash, diff --git a/src/json.rs b/src/json.rs index 2e25381..b6a7c2d 100644 --- a/src/json.rs +++ b/src/json.rs @@ -24,6 +24,13 @@ pub fn to_canonical_string(value: &T) -> Result { Ok(out) } +/// The bare JCS bytes of `value`, with no trailing line feed: the form +/// RFC 8785 defines for hashing and signing. +pub fn to_canonical_bytes(value: &T) -> Result> { + let tree = serde_json::to_value(value).context("serializing to JSON")?; + ijson_jcs::to_jcs(&tree).context("canonicalizing JSON (JCS)") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index df1e0c6..0e36280 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1507,7 +1507,7 @@ fn run_main() -> Result<()> { // Seal and write attestation sidecar if let Some(builder) = chain_builder { let envelope = builder.seal(report_json.as_bytes(), signing_key.as_deref())?; - let attestation_json = serde_json::to_string_pretty(&envelope)?; + let attestation_json = panic_attack::json::to_canonical_string(&envelope)?; let sidecar_path = if let Some(out) = &output { let stem = out.file_stem().unwrap_or_default().to_string_lossy(); @@ -2677,12 +2677,18 @@ fn run_main() -> Result<()> { issuer, issued_at, chain_hash, + content_bound, signature_verified, } => { println!(" [OK] Attestation verified"); println!(" Issuer: {}", issuer); println!(" Issued at: {}", issued_at); println!(" Chain: {}", chain_hash); + if content_bound { + println!(" Content: intent and evidence rehashed (JCS)"); + } else { + println!(" Content: not rehashed (legacy hash scheme)"); + } if signature_verified { println!(" Signature: verified (Ed25519)"); } else { From d88ccd957cc7c9ef9f1c6fb2b64f4f6fcc2419e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:45:25 +0000 Subject: [PATCH 2/2] fix(attestation): reject non-SHA-256 digest fields on verify The legacy chain hashes `i || e || r` and the JCS chain hashes `"jcs-sha256" || i || e || r`. With variable-width digest fields, moving the scheme name into `intent_hash` and dropping `hash_scheme` reproduced the signed bytes exactly while falling back to the legacy path, which skips the content rehash. A signed sidecar could then be edited and still verify. (Codex review finding on #247.) `attest verify` now requires every digest to be 64 lowercase hex characters, so the two chain inputs (192 and 202 bytes) cannot coincide. A test reproduces the attack; it verified as OK before this change. The Idris2 model's doc now states the scheme-prefixed formula and that it covers one scheme at a time; scheme binding is not yet modelled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k --- CHANGELOG.adoc | 4 ++ src/abi/AttestationUnforgeability.idr | 13 ++++++- src/attestation/mod.rs | 53 +++++++++++++++++++++++++++ src/attestation/seal.rs | 4 +- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4e9400c..8cf0110 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -17,6 +17,10 @@ a mismatch. Before, it checked only that the chain hash matched the three stored digests, so the intent and evidence could be edited after sealing without detection. Sidecars written before this change (no `+hash_scheme+`) still verify, reported as `+not rehashed (legacy hash scheme)+`. +* `+attest verify+` rejects any digest field that is not exactly 64 lowercase hex +characters. Fixed widths keep the legacy and scheme-prefixed chain inputs +(192 and 202 bytes) apart; otherwise moving the scheme name into `+intent_hash+` +would reproduce the signed bytes on the legacy path. ==== Added — `.kyaml` input (estate YAML policy D280/D307) diff --git a/src/abi/AttestationUnforgeability.idr b/src/abi/AttestationUnforgeability.idr index a842f86..02c39a6 100644 --- a/src/abi/AttestationUnforgeability.idr +++ b/src/abi/AttestationUnforgeability.idr @@ -7,9 +7,18 @@ ||| Mirrors `src/attestation/{intent,evidence,seal}.rs`. The seal binds the ||| three attestation phases via ||| -||| chain_hash = H(intent_hash || evidence_hash || report_hash) +||| chain_hash = H(hash_scheme || intent_hash || evidence_hash || report_hash) ||| -||| and optionally signs that chain hash with Ed25519. +||| (legacy seals omit `hash_scheme`) and optionally signs that chain hash +||| with Ed25519. +||| +||| SCOPE: `chainHash` below is the chain hash for ONE fixed scheme, i.e. +||| `\i, e, r => H(s || i || e || r)` for a given `s`; the theorems hold for +||| each scheme separately. Binding the scheme itself (relabelling a seal's +||| scheme invalidates it) is NOT modelled here. In the code it rests on the +||| verifier accepting only 64-hex-character digests, which makes the +||| encoding injective across schemes (192 vs 202 input bytes). A model with +||| the scheme as a fourth input and a no-downgrade lemma is follow-up work. ||| ||| The cryptographic facts the seal relies on are taken as explicit ||| HYPOTHESES — a `parameters` block, NOT `postulate` (panic-attack's own diff --git a/src/attestation/mod.rs b/src/attestation/mod.rs index eae34cf..610af5a 100644 --- a/src/attestation/mod.rs +++ b/src/attestation/mod.rs @@ -102,6 +102,25 @@ pub fn verify_attestation_str(content: &str) -> anyhow::Result { let seal = &chain.seal; let mut failures = Vec::new(); + // Every digest must be exactly 64 lowercase hex characters. With fixed + // widths the legacy chain input (192 bytes) and the scheme-prefixed one + // (202 bytes) cannot coincide; without them, moving the scheme name into + // `intent_hash` and dropping `hash_scheme` would reproduce the signed + // bytes on the legacy path, which skips the content check. + for (name, digest) in [ + ("intent_hash", &seal.intent_hash), + ("evidence_hash", &seal.evidence_hash), + ("report_hash", &seal.report_hash), + ("chain_hash", &seal.chain_hash), + ] { + if !is_sha256_hex(digest) { + failures.push(format!("{} is not a SHA-256 digest: {}", name, digest)); + } + } + if !failures.is_empty() { + return Ok(VerifyResult::Failed(failures)); + } + // Recompute chain_hash = SHA-256(hash_scheme || intent_hash || evidence_hash || report_hash) let computed_chain_hash = seal::chain_hash( &seal.hash_scheme, @@ -170,6 +189,14 @@ pub fn verify_attestation_str(content: &str) -> anyhow::Result { }) } +/// A hex-encoded SHA-256 digest as `hex::encode` writes it. +fn is_sha256_hex(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + #[cfg(feature = "signing")] fn verify_signature(seal: &ReportSeal, failures: &mut Vec) -> bool { use ed25519_dalek::{Signature, VerifyingKey}; @@ -381,6 +408,32 @@ mod tests { ); } + #[test] + fn shifting_the_scheme_into_a_digest_field_is_rejected() { + // The legacy chain hashes `i || e || r`; the JCS chain hashes + // `"jcs-sha256" || i || e || r`. Moving the scheme name into + // `intent_hash` would reproduce the signed bytes exactly while + // dropping to the legacy path, which skips the content check. + let shifted = edit(&sealed_sidecar(), |tree| { + strip_scheme(tree); + let seal = &mut tree["attestation"]["seal"]; + let original = seal["intent_hash"] + .as_str() + .expect("intent_hash") + .to_string(); + seal["intent_hash"] = + serde_json::json!(format!("{}{}", seal::HASH_SCHEME_JCS, original)); + tree["attestation"]["intent"]["cli_args"] = serde_json::json!(["--quiet"]); + }); + let reasons = failures(&shifted); + assert!( + reasons + .iter() + .any(|r| r.starts_with("intent_hash is not a SHA-256 digest")), + "{reasons:?}" + ); + } + #[test] fn unknown_hash_scheme_fails() { let unknown = edit(&sealed_sidecar(), |tree| { diff --git a/src/attestation/seal.rs b/src/attestation/seal.rs index e1659b6..922d4e1 100644 --- a/src/attestation/seal.rs +++ b/src/attestation/seal.rs @@ -42,7 +42,9 @@ fn legacy_hash_scheme() -> String { } /// The chain hash over three hex digests. Every scheme except the legacy -/// one prefixes its name, binding the scheme into the hash. +/// one prefixes its name, binding the scheme into the hash. The encoding is +/// injective across schemes only because each digest is exactly 64 hex +/// characters, which `verify_attestation_str` checks before calling this. pub fn chain_hash( hash_scheme: &str, intent_hash: &str,