Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@

=== [Unreleased]

==== Fixed — assail reports are deterministic

* The same input now yields the same report on every run. `+frameworks+`,
`+recommended_attacks+`, the taint-matrix rows and dependency-graph edges were
built from `+HashSet+`/`+HashMap+` iteration order, which changes per run, so
the canonical JSON (and any hash over it, such as the `+--attest+` seal)
differed for identical input. They now use `+BTreeSet+`/`+BTreeMap+` (enum
declaration order via derived `+Ord+` on `+AttackAxis+`, `+Framework+` and
`+WeakPointCategory+`). A regression test runs the analysis nine times.
==== 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)+`.
* `+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 report output

Expand Down
13 changes: 11 additions & 2 deletions src/abi/AttestationUnforgeability.idr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 22 additions & 20 deletions src/attestation/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
//! │
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/attestation/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading