Skip to content

Commit 1b766ab

Browse files
feat(attest): verify --report and --require-bound (#263)
Closes two follow-ups from #247. ## Problem `attest verify` checks that the seal's three stored digests chain together, and that `decision_hash` equals `report_hash`. Both of those values are stored inside the sidecar. **Nothing rehashed the report itself**, so a report edited after sealing still verified: ``` $ attest verify r.attestation.json # r.json edited after sealing [OK] Attestation verified ``` Legacy seals (no `hash_scheme`) also verify without binding their intent and evidence, and there was no way to require binding. ## Change - **`--report <REPORT>`** rehashes the report file and fails with `report_hash mismatch: stored=… computed=…` if the hashes differ. - **`--require-bound`** fails on legacy seals with `legacy hash scheme: intent and evidence are not bound to the seal`. - The output now has a `Report:` line: `rehashed, matches the seal` or `not checked (pass --report)`. - **Library:** - `VerifyOptions { report, require_bound }`, used with new `verify_attestation_file_with` / `verify_attestation_str_with`. - The existing `verify_attestation_file` / `verify_attestation_str` keep their behaviour; they call the new functions with default options. - `VerifyResult::Ok` gains `report_checked`, so a caller can't mistake an unchecked report for a verified one. ## Validation - **New tests:** - `the_sealed_report_verifies`: `report_checked` is true with `--report` and false without it. - `an_edited_report_is_detected` - `require_bound_rejects_legacy_seals_only`: a genuine legacy seal fails, a JCS seal passes. - **End to end with the built CLI** (`assail examples --attest`): - The genuine report verifies (`Report: rehashed, matches the seal`). - A report edited after sealing fails with `report_hash mismatch`; the same file without `--report` still says `[OK]`, which is the gap this closes. - A legacy sidecar, rebuilt with `jq` plus a `sha256sum` chain hash, passes by default and fails with `--require-bound`. - A missing report file gives a clear `reading …: No such file or directory`. - `cargo fmt --check`, plus `cargo clippy --locked --all-targets -D warnings` with and without `--all-features`: clean. - `cargo test --locked --all-features`: all suites green (lib: 526 passed). This touches `CHANGELOG.adoc` like the other open PRs (#261, #262). Whichever merges later needs a trivial merge of main, and I'll handle it. --- _Generated by [Claude Code](https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k)_ --------- Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41966b7 commit 1b766ab

3 files changed

Lines changed: 212 additions & 12 deletions

File tree

‎CHANGELOG.adoc‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
=== [Unreleased]
44

5-
==== Fixed — JCS numbers on decimal ties
6-
7-
* `+ijson-jcs+` is bumped from 3a9a4ee to 03df9ed, which formats numbers with
8-
`+ryu-js+` (ECMAScript `+Number::toString+`). The old pin printed RFC 8785
9-
Appendix B vector 0x43143ff3c1cb0959 as `+1424953923781206.3+`; JCS requires
10-
`+1424953923781206.2+` (ties to even). Any report or attestation holding such a
11-
float now hashes to the value other JCS implementations compute. A test in
12-
`+crate::json+` pins the vector.
5+
==== Added — `+attest verify --report+` and `+--require-bound+`
6+
7+
* `+attest verify --report <REPORT>+` rehashes the report file, streaming it
8+
through a fixed-size buffer, and fails if it
9+
does not match the seal's `+report_hash+`. Without it, verification only checks
10+
that the stored digests agree with each other, so a report edited after sealing
11+
still verified.
12+
* `+attest verify --require-bound+` fails on legacy seals (no `+hash_scheme+`),
13+
whose intent and evidence cannot be rehashed.
14+
* The output now says whether the report was checked. Library callers use
15+
`+VerifyOptions+` (carrying the report digest from `+sha256_reader+`) with
16+
`+verify_attestation_file_with+` / `+verify_attestation_str_with+`; `+VerifyResult::Ok+` gains `+report_checked+`.
1317

1418
==== Fixed — KYAML keys holding a line separator
1519

‎src/attestation/mod.rs‎

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,47 @@ pub enum VerifyResult {
5454
/// The embedded intent and evidence were rehashed and match the
5555
/// seal. False for legacy seals, whose hashes cannot be recomputed.
5656
content_bound: bool,
57+
/// A report was supplied and its SHA-256 matches `seal.report_hash`.
58+
report_checked: bool,
5759
signature_verified: bool,
5860
},
5961
/// One or more chain invariants failed.
6062
Failed(Vec<String>),
6163
}
6264

65+
/// Optional checks on top of the chain invariants.
66+
#[derive(Debug, Clone, Copy, Default)]
67+
pub struct VerifyOptions<'a> {
68+
/// SHA-256 of the report file, as 64 lowercase hex characters (see
69+
/// [`sha256_reader`]). When given, it must equal `seal.report_hash`, so
70+
/// the attestation is checked against the report itself rather than only
71+
/// against the digest stored beside it. A digest rather than the bytes,
72+
/// so a caller can hash a large report as a stream.
73+
pub report_sha256: Option<&'a str>,
74+
/// Reject legacy seals, whose intent and evidence cannot be rehashed.
75+
pub require_bound: bool,
76+
}
77+
78+
/// SHA-256 of everything `reader` yields, as 64 lowercase hex characters.
79+
///
80+
/// Hashes through a fixed-size buffer, so memory use does not grow with the
81+
/// input. Use it to fill [`VerifyOptions::report_sha256`]; a byte slice is a
82+
/// reader too.
83+
pub fn sha256_reader<R: std::io::Read>(mut reader: R) -> std::io::Result<String> {
84+
use sha2::{Digest, Sha256};
85+
let mut hasher = Sha256::new();
86+
let mut buf = [0_u8; 8192];
87+
loop {
88+
match reader.read(&mut buf) {
89+
Ok(0) => break,
90+
Ok(n) => hasher.update(&buf[..n]),
91+
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
92+
Err(e) => return Err(e),
93+
}
94+
}
95+
Ok(hex::encode(hasher.finalize()))
96+
}
97+
6398
/// Verify an attestation sidecar file.
6499
///
65100
/// Reads the JSON file at `path`, recomputes the chain hash from the stored
@@ -72,6 +107,14 @@ pub enum VerifyResult {
72107
/// Returns `VerifyResult::Ok` when all checks pass, `VerifyResult::Failed`
73108
/// with a list of failure reasons otherwise.
74109
pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result<VerifyResult> {
110+
verify_attestation_file_with(path, &VerifyOptions::default())
111+
}
112+
113+
/// [`verify_attestation_file`] with the optional checks in `options`.
114+
pub fn verify_attestation_file_with(
115+
path: &std::path::Path,
116+
options: &VerifyOptions<'_>,
117+
) -> anyhow::Result<VerifyResult> {
75118
use std::io::Read;
76119

77120
// Attestation envelopes are JSON. They embed three hashes plus a small
@@ -87,12 +130,20 @@ pub fn verify_attestation_file(path: &std::path::Path) -> anyhow::Result<VerifyR
87130
.read_to_string(&mut content)
88131
.map_err(|e| anyhow::anyhow!("reading {}: {}", path.display(), e))?;
89132

90-
verify_attestation_str(&content)
133+
verify_attestation_str_with(&content, options)
91134
}
92135

93136
/// Verify an attestation envelope held in memory. See
94137
/// [`verify_attestation_file`].
95138
pub fn verify_attestation_str(content: &str) -> anyhow::Result<VerifyResult> {
139+
verify_attestation_str_with(content, &VerifyOptions::default())
140+
}
141+
142+
/// [`verify_attestation_str`] with the optional checks in `options`.
143+
pub fn verify_attestation_str_with(
144+
content: &str,
145+
options: &VerifyOptions<'_>,
146+
) -> anyhow::Result<VerifyResult> {
96147
let tree: serde_json::Value = serde_json::from_str(content)
97148
.map_err(|e| anyhow::anyhow!("parsing attestation envelope: {}", e))?;
98149
let envelope: A2mlEnvelope = serde_json::from_value(tree.clone())
@@ -165,6 +216,25 @@ pub fn verify_attestation_str(content: &str) -> anyhow::Result<VerifyResult> {
165216
}
166217
};
167218

219+
if options.require_bound && seal.hash_scheme == seal::HASH_SCHEME_LEGACY {
220+
failures
221+
.push("legacy hash scheme: intent and evidence are not bound to the seal".to_string());
222+
}
223+
224+
// The report itself, when supplied, must hash to seal.report_hash.
225+
let report_checked = match options.report_sha256 {
226+
Some(computed) => {
227+
if computed != seal.report_hash {
228+
failures.push(format!(
229+
"report_hash mismatch: stored={} computed={}",
230+
seal.report_hash, computed
231+
));
232+
}
233+
true
234+
}
235+
None => false,
236+
};
237+
168238
// decision_hash in the envelope must match seal.report_hash
169239
if envelope.decision_hash != seal.report_hash {
170240
failures.push(format!(
@@ -185,6 +255,7 @@ pub fn verify_attestation_str(content: &str) -> anyhow::Result<VerifyResult> {
185255
issued_at: envelope.issued_at.clone(),
186256
chain_hash: seal.chain_hash.clone(),
187257
content_bound,
258+
report_checked,
188259
signature_verified,
189260
})
190261
}
@@ -270,6 +341,11 @@ mod tests {
270341
/// Seal a small scan of a temporary directory and return the sidecar
271342
/// exactly as `assail --attest` writes it.
272343
fn sealed_sidecar() -> String {
344+
sealed_sidecar_and_report().0
345+
}
346+
347+
/// The sidecar plus the exact report bytes it seals.
348+
fn sealed_sidecar_and_report() -> (String, String) {
273349
let dir = tempfile::tempdir().expect("tempdir");
274350
let args = vec!["--verbose".to_string()];
275351
let mut builder = AttestationChainBuilder::begin(dir.path(), &args).expect("begin");
@@ -280,7 +356,8 @@ mod tests {
280356
let report = crate::json::to_canonical_string(&serde_json::json!({"weak_points": []}))
281357
.expect("report");
282358
let envelope = builder.seal(report.as_bytes(), None).expect("seal");
283-
crate::json::to_canonical_string(&envelope).expect("sidecar")
359+
let sidecar = crate::json::to_canonical_string(&envelope).expect("sidecar");
360+
(sidecar, report)
284361
}
285362

286363
fn edit(sidecar: &str, change: impl FnOnce(&mut serde_json::Value)) -> String {
@@ -445,4 +522,90 @@ mod tests {
445522
"{reasons:?}"
446523
);
447524
}
525+
526+
fn verify_with(sidecar: &str, options: &VerifyOptions<'_>) -> VerifyResult {
527+
verify_attestation_str_with(sidecar, options).expect("verify")
528+
}
529+
530+
#[test]
531+
fn sha256_reader_matches_the_one_shot_hash() {
532+
// FIPS 180-2 "abc" vector, and a stream longer than the read buffer.
533+
assert_eq!(
534+
sha256_reader(&b"abc"[..]).unwrap(),
535+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
536+
);
537+
let big = vec![0x5a_u8; 64 * 1024 + 7];
538+
assert_eq!(sha256_reader(&big[..]).unwrap(), intent::sha256_str(&big));
539+
}
540+
541+
#[test]
542+
fn the_sealed_report_verifies() {
543+
let (sidecar, report) = sealed_sidecar_and_report();
544+
let options = VerifyOptions {
545+
report_sha256: Some(&sha256_reader(report.as_bytes()).expect("hash")),
546+
..VerifyOptions::default()
547+
};
548+
match verify_with(&sidecar, &options) {
549+
VerifyResult::Ok { report_checked, .. } => assert!(report_checked),
550+
VerifyResult::Failed(reasons) => panic!("{reasons:?}"),
551+
}
552+
// Without --report the report is not claimed as checked.
553+
match verify_with(&sidecar, &VerifyOptions::default()) {
554+
VerifyResult::Ok { report_checked, .. } => assert!(!report_checked),
555+
VerifyResult::Failed(reasons) => panic!("{reasons:?}"),
556+
}
557+
}
558+
559+
#[test]
560+
fn an_edited_report_is_detected() {
561+
// The seal and decision_hash agree with each other, so only
562+
// rehashing the report itself can catch this.
563+
let (sidecar, report) = sealed_sidecar_and_report();
564+
let tampered = report.replace("[]", "[{}]");
565+
assert_ne!(tampered, report);
566+
let options = VerifyOptions {
567+
report_sha256: Some(&sha256_reader(tampered.as_bytes()).expect("hash")),
568+
..VerifyOptions::default()
569+
};
570+
match verify_with(&sidecar, &options) {
571+
VerifyResult::Failed(reasons) => assert!(
572+
reasons
573+
.iter()
574+
.any(|r| r.starts_with("report_hash mismatch")),
575+
"{reasons:?}"
576+
),
577+
VerifyResult::Ok { .. } => panic!("edited report verified"),
578+
}
579+
}
580+
581+
#[test]
582+
fn require_bound_rejects_legacy_seals_only() {
583+
let options = VerifyOptions {
584+
require_bound: true,
585+
..VerifyOptions::default()
586+
};
587+
let legacy = edit(&sealed_sidecar(), |tree| {
588+
strip_scheme(tree);
589+
let seal = &tree["attestation"]["seal"];
590+
let digest = |k: &str| seal[k].as_str().expect(k).to_string();
591+
let chain = seal::chain_hash(
592+
seal::HASH_SCHEME_LEGACY,
593+
&digest("intent_hash"),
594+
&digest("evidence_hash"),
595+
&digest("report_hash"),
596+
);
597+
tree["attestation"]["seal"]["chain_hash"] = serde_json::json!(chain);
598+
});
599+
match verify_with(&legacy, &options) {
600+
VerifyResult::Failed(reasons) => assert_eq!(
601+
reasons,
602+
["legacy hash scheme: intent and evidence are not bound to the seal"]
603+
),
604+
VerifyResult::Ok { .. } => panic!("legacy seal passed --require-bound"),
605+
}
606+
match verify_with(&sealed_sidecar(), &options) {
607+
VerifyResult::Ok { content_bound, .. } => assert!(content_bound),
608+
VerifyResult::Failed(reasons) => panic!("{reasons:?}"),
609+
}
610+
}
448611
}

‎src/main.rs‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,15 @@ enum AttestAction {
982982
/// Path to the .attestation.json file
983983
#[arg(value_name = "FILE")]
984984
file: PathBuf,
985+
986+
/// The report the attestation seals; its SHA-256 must match the seal's
987+
/// report_hash
988+
#[arg(long, value_name = "REPORT")]
989+
report: Option<PathBuf>,
990+
991+
/// Fail on legacy seals, whose intent and evidence are not bound
992+
#[arg(long)]
993+
require_bound: bool,
985994
},
986995
}
987996

@@ -2681,13 +2690,32 @@ fn run_main() -> Result<()> {
26812690
}
26822691

26832692
Commands::Attest { action } => match action {
2684-
AttestAction::Verify { file } => {
2685-
match attestation::verify_attestation_file(&file)? {
2693+
AttestAction::Verify {
2694+
file,
2695+
report,
2696+
require_bound,
2697+
} => {
2698+
// Hash the report as a stream: it may be large, and only its
2699+
// digest is compared.
2700+
let report_sha256 = report
2701+
.as_deref()
2702+
.map(|path| {
2703+
fs::File::open(path)
2704+
.and_then(attestation::sha256_reader)
2705+
.map_err(|e| anyhow::anyhow!("reading {}: {}", path.display(), e))
2706+
})
2707+
.transpose()?;
2708+
let options = attestation::VerifyOptions {
2709+
report_sha256: report_sha256.as_deref(),
2710+
require_bound,
2711+
};
2712+
match attestation::verify_attestation_file_with(&file, &options)? {
26862713
attestation::VerifyResult::Ok {
26872714
issuer,
26882715
issued_at,
26892716
chain_hash,
26902717
content_bound,
2718+
report_checked,
26912719
signature_verified,
26922720
} => {
26932721
println!(" [OK] Attestation verified");
@@ -2699,6 +2727,11 @@ fn run_main() -> Result<()> {
26992727
} else {
27002728
println!(" Content: not rehashed (legacy hash scheme)");
27012729
}
2730+
if report_checked {
2731+
println!(" Report: rehashed, matches the seal");
2732+
} else {
2733+
println!(" Report: not checked (pass --report)");
2734+
}
27022735
if signature_verified {
27032736
println!(" Signature: verified (Ed25519)");
27042737
} else {

0 commit comments

Comments
 (0)