diff --git a/CHANGELOG.md b/CHANGELOG.md
index c722d18..1e2074a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,34 @@ Versioning follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [3.2.0] — 2026-07-18
+
+Additive feature release — no breaking changes. Closes the two credibility gaps in
+Boruna's tamper-evidence story identified during the adjacent-market research:
+external witnessing (so the record isn't just "trust the recorder") and privacy
+(so a sealed bundle can carry redactable data). Together they compose — a redaction
+preserves `audit_log_hash`, so an out-of-band anchor distinguishes an authorized
+redaction from a tamper.
+
+### Added
+
+- **Transparency-log anchoring** — `boruna evidence anchor
` submits the
+ bundle's attestation to a Sigstore **Rekor** log and stores the inclusion proof +
+ signed entry timestamp back into the bundle, giving an external witness and a
+ trusted timestamp. `--rekor-url` points at a **private Rekor** for air-gapped use;
+ `--offline` emits the entry payload for out-of-band submission; `--verify` checks a
+ stored proof (RFC 6962 Merkle inclusion math) with no network. Network is opt-in,
+ behind the `rekor` cargo feature (`ureq`); the default build stays network-free.
+ Keyless (Fulcio) signing is design-noted (`orchestrator/docs/keyless-signing.md`).
+- **Verifiable redaction** — `boruna evidence redact --event [--field ]`
+ removes PII from a sealed bundle without breaking verification. The audit chain now
+ commits to a per-entry content hash (`entry_hash = SHA-256(seq ‖ prev ‖
+ content_sha256)`, bundle format `1.1`, back-compatible with `1.0`), so redacted
+ content is replaced by its commitment and the chain still verifies. `audit_log_hash`
+ is invariant under redaction but changes under tampering, so a redaction is an
+ authorized, recorded transformation while a content edit is detected. `evidence
+ verify` reports which entries are redacted. Encrypted bundles must be decrypted first.
+
## [3.1.0] — 2026-07-18
Additive feature release — no breaking changes. Deepens Boruna's two moats:
diff --git a/Cargo.lock b/Cargo.lock
index edfadff..d3a4729 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -289,7 +289,7 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "boruna-benches"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -302,7 +302,7 @@ dependencies = [
[[package]]
name = "boruna-bytecode"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"serde",
"serde_json",
@@ -312,7 +312,7 @@ dependencies = [
[[package]]
name = "boruna-cli"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -331,7 +331,7 @@ dependencies = [
[[package]]
name = "boruna-compiler"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-vm",
@@ -345,7 +345,7 @@ dependencies = [
[[package]]
name = "boruna-effect"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"serde",
@@ -356,7 +356,7 @@ dependencies = [
[[package]]
name = "boruna-framework"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -369,7 +369,7 @@ dependencies = [
[[package]]
name = "boruna-lsp"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-compiler",
"boruna-tooling",
@@ -381,7 +381,7 @@ dependencies = [
[[package]]
name = "boruna-mcp"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -402,7 +402,7 @@ dependencies = [
[[package]]
name = "boruna-orchestrator"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -428,12 +428,13 @@ dependencies = [
"testcontainers-modules",
"thiserror 2.0.18",
"tokio",
+ "ureq",
"zeroize",
]
[[package]]
name = "boruna-pkg"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -446,7 +447,7 @@ dependencies = [
[[package]]
name = "boruna-tooling"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -463,7 +464,7 @@ dependencies = [
[[package]]
name = "boruna-vm"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"boruna-bytecode",
"opentelemetry",
diff --git a/Cargo.toml b/Cargo.toml
index 9da926e..297e5fe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ members = [
]
[workspace.package]
-version = "3.1.0"
+version = "3.2.0"
edition = "2021"
[workspace.dependencies]
diff --git a/crates/llmvm-cli/Cargo.toml b/crates/llmvm-cli/Cargo.toml
index 05f3bb4..d799945 100644
--- a/crates/llmvm-cli/Cargo.toml
+++ b/crates/llmvm-cli/Cargo.toml
@@ -30,6 +30,12 @@ gcs = ["boruna-orchestrator/gcs"]
# `--bundle-storage azblob://...` constructs a real adapter. Off
# by default. See docs/guides/bundle-storage-azure.md.
azure = ["boruna-orchestrator/azure"]
+# Live transparency-log anchoring: forwards to boruna-orchestrator's
+# `rekor` feature so `boruna evidence anchor` (without `--offline`) can
+# POST to a Rekor instance. Off by default — the network client is only
+# pulled in when this is set. Offline entry-building + inclusion-proof
+# verification work without it. See docs/... and audit/anchor.rs.
+rekor = ["boruna-orchestrator/rekor"]
[dependencies]
boruna-bytecode = { path = "../llmbc" }
diff --git a/crates/llmvm-cli/src/main.rs b/crates/llmvm-cli/src/main.rs
index 30d0df2..7d8eb25 100644
--- a/crates/llmvm-cli/src/main.rs
+++ b/crates/llmvm-cli/src/main.rs
@@ -940,6 +940,31 @@ enum EvidenceCommand {
#[arg(long, value_name = "N")]
parallelism: Option,
},
+ /// Verifiably redact one audit-log entry in an evidence bundle so PII
+ /// can be removed from a SEALED bundle without breaking verification.
+ ///
+ /// The commitment-chain audit log (format 1.1) lets the entry's event
+ /// content be blanked in place while its `content_sha256` commitment —
+ /// and therefore the chain and `audit_log_hash` — stay intact. This
+ /// updates `file_checksums` + `bundle_hash` so `evidence verify` still
+ /// passes; any prior manifest signature is dropped (re-sign or
+ /// re-anchor afterward). `audit_log_hash` is invariant under redaction,
+ /// which is how a verifier tells a redaction from a tamper. See
+ /// `orchestrator/docs/verifiable-redaction.md`.
+ Redact {
+ /// Evidence bundle directory (must be plaintext, format 1.1).
+ dir: PathBuf,
+ /// Index (== sequence) of the audit-log entry to redact.
+ #[arg(long, value_name = "INDEX")]
+ event: usize,
+ /// Optional: redact only this named field of the event instead of
+ /// the whole event.
+ #[arg(long, value_name = "NAME")]
+ field: Option,
+ /// Optional operator-recorded reason (advisory; not hashed).
+ #[arg(long)]
+ reason: Option,
+ },
/// Compare two evidence bundles side-by-side (post1-evidence-diff).
/// Reports differences in workflow metadata, step outputs, audit event
/// counts, and verification status.
@@ -982,6 +1007,43 @@ enum EvidenceCommand {
#[arg(long)]
output: Option,
},
+ /// Anchor a signed bundle in a Sigstore Rekor transparency log,
+ /// adding an external witness + trusted timestamp on top of the
+ /// bundle's own hash chain (closes the "trust the recorder /
+ /// silent backdating" hole). Builds a `hashedrekord` entry from the
+ /// manifest's `bundle_hash` + ed25519 signature. Three modes:
+ ///
+ /// * default (live): POST the entry to `--rekor-url` and store the
+ /// returned entry (with inclusion proof) as `rekor-entry.json`.
+ /// Requires building with `--features rekor`.
+ /// * `--offline`: emit the entry payload (no network) for external
+ /// submission — to `--output` or stdout.
+ /// * `--verify`: verify a stored `rekor-entry.json` offline —
+ /// recompute the Merkle root from the inclusion proof and check
+ /// the entry commits to this bundle's `bundle_hash`.
+ ///
+ /// `--rekor-url` accepts a PRIVATE Rekor instance for air-gapped
+ /// deployments; it is not pinned to the public log.
+ Anchor {
+ /// Evidence bundle directory (must contain `manifest.json`).
+ dir: PathBuf,
+ /// Rekor instance base URL. Accepts a private/air-gapped Rekor.
+ #[arg(long, value_name = "URL", default_value = "https://rekor.sigstore.dev")]
+ rekor_url: String,
+ /// Emit the entry payload without any network call, for external
+ /// submission. Writes to `--output` (or stdout if unset).
+ #[arg(long)]
+ offline: bool,
+ /// Verify a stored `rekor-entry.json` (inclusion proof + that it
+ /// commits to this bundle's `bundle_hash`). Offline, no network.
+ #[arg(long)]
+ verify: bool,
+ /// Output/stored path. Defaults: `--offline` →
+ /// `/rekor-entry-request.json`; live → `/rekor-entry.json`;
+ /// `--verify` reads `/rekor-entry.json`.
+ #[arg(long)]
+ output: Option,
+ },
/// Generate a human-readable COMPLIANCE evidence-mapping report that
/// maps a bundle's actual contents to the specific regulatory
/// obligation each one helps satisfy. Verifies the bundle first and
@@ -3928,6 +3990,9 @@ fn run_evidence(
);
if result.valid {
println!("evidence bundle is VALID");
+ if !result.redacted_entries.is_empty() {
+ println!(" redacted entries: {:?}", result.redacted_entries);
+ }
} else {
eprintln!("evidence bundle INVALID:");
for err in &result.errors {
@@ -4165,6 +4230,44 @@ fn run_evidence(
parallelism,
)?;
}
+ EvidenceCommand::Redact {
+ dir,
+ event,
+ field,
+ reason,
+ } => {
+ let outcome = boruna_orchestrator::audit::evidence::redact_bundle(
+ &dir,
+ event,
+ field.as_deref(),
+ reason,
+ )
+ .map_err(|e| format!("redact failed: {e}"))?;
+ println!("redacted audit-log entry {}", outcome.redacted_sequence);
+ println!(" content_sha256: {}", outcome.content_sha256);
+ println!(" audit_log_hash: {} (unchanged)", outcome.audit_log_hash);
+ println!(" new bundle_hash: {}", outcome.new_bundle_hash);
+ if outcome.signature_stripped {
+ eprintln!(
+ "warning: a manifest signature was dropped (it signed the pre-redaction \
+ bundle_hash). Re-sign or re-anchor the bundle."
+ );
+ }
+ // Re-verify so the operator sees the bundle is still valid.
+ let result = verify_bundle_with_opts(&dir, &VerifyOptions::default());
+ if result.valid {
+ println!(
+ "re-verify: VALID (redacted entries: {:?})",
+ result.redacted_entries
+ );
+ } else {
+ eprintln!("re-verify: INVALID:");
+ for err in &result.errors {
+ eprintln!(" {err}");
+ }
+ process::exit(1);
+ }
+ }
EvidenceCommand::Diff {
bundle_a,
bundle_b,
@@ -4181,6 +4284,15 @@ fn run_evidence(
} => {
run_evidence_attest(dir, verify, signing_key, verify_key, output)?;
}
+ EvidenceCommand::Anchor {
+ dir,
+ rekor_url,
+ offline,
+ verify,
+ output,
+ } => {
+ run_evidence_anchor(dir, rekor_url, offline, verify, output)?;
+ }
EvidenceCommand::Report {
dir,
framework,
@@ -4279,6 +4391,105 @@ fn run_evidence_attest(
Ok(())
}
+/// `boruna evidence anchor ` — anchor a signed bundle in a Rekor
+/// transparency log, or verify a stored anchor offline.
+///
+/// Builds a `hashedrekord` entry from the manifest's `bundle_hash` +
+/// ed25519 signature. In live mode (default) it POSTs to `rekor_url`
+/// (requires the `rekor` feature) and stores the returned entry —
+/// inclusion proof + trusted timestamp — as `rekor-entry.json`. With
+/// `--offline` it emits the entry payload for external submission. With
+/// `--verify` it re-derives the Merkle root from a stored entry's
+/// inclusion proof and checks the entry commits to this bundle_hash.
+fn run_evidence_anchor(
+ dir: PathBuf,
+ rekor_url: String,
+ offline: bool,
+ verify: bool,
+ output: Option,
+) -> Result<(), Box> {
+ use boruna_orchestrator::audit::anchor::{
+ hashedrekord_from_manifest, verify_entry, RekorLogEntry,
+ };
+ use boruna_orchestrator::audit::evidence::BundleManifest;
+
+ let manifest_json = fs::read_to_string(dir.join("manifest.json"))
+ .map_err(|e| format!("cannot read manifest.json: {e}"))?;
+ let manifest: BundleManifest =
+ serde_json::from_str(&manifest_json).map_err(|e| format!("invalid manifest.json: {e}"))?;
+
+ // --verify: check a stored rekor-entry.json entirely offline.
+ if verify {
+ let entry_path = output.unwrap_or_else(|| dir.join("rekor-entry.json"));
+ let raw = fs::read_to_string(&entry_path)
+ .map_err(|e| format!("cannot read {}: {e}", entry_path.display()))?;
+ let entry: RekorLogEntry =
+ serde_json::from_str(&raw).map_err(|e| format!("invalid rekor-entry.json: {e}"))?;
+ match verify_entry(&entry, &manifest.bundle_hash) {
+ Ok(v) => {
+ println!("anchor is VALID");
+ println!(" logIndex: {}", v.log_index);
+ println!(" integratedTime: {}", v.integrated_time);
+ println!(" rootHash: {}", v.root_hash);
+ println!(" bundle_hash: {} (matches)", v.data_hash);
+ }
+ Err(e) => {
+ eprintln!("anchor INVALID: {e}");
+ process::exit(1);
+ }
+ }
+ return Ok(());
+ }
+
+ // --offline: emit the proposed entry payload; no network.
+ if offline {
+ let entry = hashedrekord_from_manifest(&manifest).map_err(|e| e.to_string())?;
+ let json = serde_json::to_string_pretty(&entry)
+ .map_err(|e| format!("cannot serialize entry: {e}"))?;
+ match output {
+ Some(path) => {
+ fs::write(&path, &json)
+ .map_err(|e| format!("cannot write {}: {e}", path.display()))?;
+ println!("rekor entry payload written to {}", path.display());
+ println!(" submit it externally, then store the response as rekor-entry.json");
+ }
+ None => println!("{json}"),
+ }
+ return Ok(());
+ }
+
+ // Live mode: POST to Rekor. Only available with the `rekor` feature.
+ #[cfg(feature = "rekor")]
+ {
+ use boruna_orchestrator::audit::anchor::submit;
+ let entry = hashedrekord_from_manifest(&manifest).map_err(|e| e.to_string())?;
+ let log_entry = submit(&rekor_url, &entry).map_err(|e| e.to_string())?;
+ // Sanity: confirm the returned entry verifies against this bundle
+ // before we store it as the bundle's anchor.
+ verify_entry(&log_entry, &manifest.bundle_hash)
+ .map_err(|e| format!("Rekor returned an entry that does not verify: {e}"))?;
+ let store = output.unwrap_or_else(|| dir.join("rekor-entry.json"));
+ let json = serde_json::to_string_pretty(&log_entry)
+ .map_err(|e| format!("cannot serialize entry: {e}"))?;
+ fs::write(&store, &json).map_err(|e| format!("cannot write {}: {e}", store.display()))?;
+ println!("anchored in Rekor: {rekor_url}");
+ println!(" logIndex: {}", log_entry.log_index);
+ println!(" integratedTime: {}", log_entry.integrated_time);
+ println!(" logID: {}", log_entry.log_id);
+ println!(" stored: {}", store.display());
+ Ok(())
+ }
+ #[cfg(not(feature = "rekor"))]
+ {
+ let _ = (&rekor_url, &output);
+ Err(
+ "live Rekor anchoring requires building with `--features rekor`; \
+ use `--offline` to emit the entry payload for external submission"
+ .into(),
+ )
+ }
+}
+
/// `boruna evidence rotate-kek` (post1-T-2.4). Dispatches to
/// single-bundle or batch (directory of bundles) rotation based on
/// what `target` points at.
diff --git a/orchestrator/Cargo.toml b/orchestrator/Cargo.toml
index 5c59b35..9881285 100644
--- a/orchestrator/Cargo.toml
+++ b/orchestrator/Cargo.toml
@@ -6,6 +6,13 @@ edition.workspace = true
[features]
default = ["persist-sqlite"]
http = ["boruna-vm/http"]
+# Transparency-log anchoring: live submit of a Rekor `hashedrekord`
+# entry over HTTP (Sigstore Rekor or a private/air-gapped instance).
+# Off by default so the default build (and all tests) stay network-free
+# — building a Rekor entry and verifying a stored inclusion proof are
+# always available; only the POST needs this. See audit/anchor.rs and
+# `boruna evidence anchor`.
+rekor = ["dep:ureq"]
# SQLite-backed persistent workflow checkpoint store. See ADR 001 +
# docs/design-persistence-store.md. Off-by-default in downstream binaries
# that don't need persistence (boruna-mcp, boruna-pkg); on by default for
@@ -114,6 +121,9 @@ rusqlite = { version = "0.32", features = ["bundled"], optional = true }
# CLI bounds it via `--parallelism N`. Default features pulled in
# (the crate has no system deps).
rayon = "1"
+# Minimal blocking HTTP client for live Rekor submission (behind the
+# `rekor` feature). Same crate/major the VM's `http` feature uses.
+ureq = { version = "2", optional = true }
# post1-T-3.1 / T-3.2: object_store backs the S3 (T-3.1) and GCS
# (T-3.2) BundleStorage adapters — and Azure Blob in T-3.3.
diff --git a/orchestrator/docs/keyless-signing.md b/orchestrator/docs/keyless-signing.md
new file mode 100644
index 0000000..bddc563
--- /dev/null
+++ b/orchestrator/docs/keyless-signing.md
@@ -0,0 +1,100 @@
+# Keyless signing (Fulcio) — design note
+
+**Status:** design sketch only. NOT implemented in this slice. The
+`boruna evidence anchor` command and `audit/anchor.rs` implement Rekor
+transparency-log anchoring for a bundle that is *already* signed with a
+self-managed ed25519 key (`EvidenceBundleBuilder::with_signing_key`).
+This note records how Sigstore **keyless** signing would plug into
+`evidence attest` / `evidence anchor` if we take it on later.
+
+## Why keyless
+
+Today the operator manages a long-lived ed25519 key: they must generate,
+store, rotate, and guard it, and a verifier has to be told out-of-band
+which public key to trust. Sigstore **keyless** removes the long-lived
+key entirely:
+
+1. The signer authenticates to an OIDC identity provider (Google,
+ GitHub Actions, corporate SSO, …) and obtains an **ID token** whose
+ `sub`/`email` identifies the workload or human.
+2. It generates an **ephemeral keypair** in memory.
+3. It sends the public key + a proof-of-possession + the OIDC token to
+ **Fulcio**, Sigstore's CA. Fulcio validates the token and issues a
+ **short-lived X.509 certificate** (~10 min validity) binding the
+ ephemeral public key to the OIDC identity (the identity is recorded in
+ a SAN / OID extension).
+4. The signer signs the artifact with the ephemeral **private** key,
+ then **throws the private key away**.
+5. The signature, the certificate, and the artifact digest are logged in
+ **Rekor**. Because the cert is short-lived, the Rekor entry's
+ `integratedTime` is what proves the signature was made *while the cert
+ was valid* — the transparency log is load-bearing, not optional.
+
+Net effect: no key to store or rotate; trust roots in "identity X signed
+this at time T", verifiable against Fulcio's + Rekor's public roots.
+
+## How it plugs into Boruna
+
+The current flow, unchanged:
+
+```
+finalize bundle → manifest.bundle_hash
+ → ManifestSignature (ed25519 over bundle_hash) [self-managed key]
+ → evidence attest → DSSE envelope (in-toto Statement)
+ → evidence anchor → Rekor hashedrekord entry [this slice]
+```
+
+Keyless would introduce an alternative *signing identity provider* behind
+the same seams, leaving the bundle format and the anchor path intact:
+
+- **New: a `SigningIdentity` abstraction.** Two implementations:
+ - `LocalKey(ed25519 seed)` — today's behavior.
+ - `Keyless { oidc_token, fulcio_url }` — runs steps 1–3 above and
+ yields `(ephemeral_signing_key, cert_chain_pem)`.
+- **`evidence attest` change.** When signing keyless, the DSSE signature
+ is produced with the ephemeral key, and the envelope gains a
+ certificate: DSSE has no cert field, so we attach the Fulcio cert chain
+ the way cosign does — either as an unauthenticated
+ `signatures[].cert` extension or, preferably, by emitting a Sigstore
+ **bundle** (`bundle.sigstore.json`, protobuf/JSON) that carries
+ `{ dsse envelope, verificationMaterial.x509CertificateChain,
+ tlogEntries[] }`. That bundle is what `cosign verify-blob-attestation`
+ and `sigstore-python` consume.
+- **`evidence anchor` change.** For keyless, the Rekor entry is a
+ `hashedrekord`/`dsse`/`intoto` type whose `publicKey.content` is the
+ **Fulcio leaf certificate** (PEM), not a bare ed25519 SPKI key. The
+ inclusion-proof verification in `audit/anchor.rs` is *unchanged* — it is
+ cert-agnostic (it hashes the Rekor `body` and checks the Merkle proof +
+ data-hash binding). The extra keyless verification steps layer on top:
+ - verify the Fulcio cert chains to the Fulcio root,
+ - check the OIDC identity (SAN) against an allowed-identity policy,
+ - check `integratedTime` falls within the cert's validity window,
+ - verify the signature with the cert's public key.
+
+## What it would cost / open questions
+
+- **An OIDC token source.** Interactive (browser device-flow) is fine for
+ humans; CI needs ambient tokens (GitHub Actions OIDC, GCP/AWS workload
+ identity). This is the bulk of the work and is inherently *non-local* —
+ it breaks Boruna's "runs fully offline / air-gapped" default, so keyless
+ must stay strictly opt-in, parallel to the self-managed-key path, never
+ replacing it.
+- **X.509 + protobuf deps.** Fulcio cert parsing/validation and the
+ Sigstore bundle format pull in `x509-cert` / `der` (and possibly the
+ sigstore protobufs). None are needed for the current anchor slice, so
+ they belong behind a `keyless` cargo feature, matching how `rekor`
+ gates the network client.
+- **Private deployments.** Air-gapped users can run a private Fulcio +
+ Rekor + OIDC (Dex); the `fulcio_url` / `rekor_url` must stay
+ configurable exactly like `--rekor-url` already is.
+
+## Recommended increment order
+
+1. (done) Rekor anchoring for self-managed-key bundles — this slice.
+2. `SigningIdentity` abstraction + emit a Sigstore `bundle.sigstore.json`
+ for the *local key* case (no OIDC yet) to prove the bundle format /
+ cosign-verify path end-to-end.
+3. Fulcio client + OIDC device-flow (behind `keyless`), then ambient CI
+ token sources.
+4. Full keyless verification (cert-chain + identity policy + time-window)
+ in `evidence anchor --verify`.
diff --git a/orchestrator/docs/verifiable-redaction.md b/orchestrator/docs/verifiable-redaction.md
new file mode 100644
index 0000000..8b594e9
--- /dev/null
+++ b/orchestrator/docs/verifiable-redaction.md
@@ -0,0 +1,151 @@
+# Verifiable Redaction (evidence bundle format 1.1)
+
+Turns a liability — *a sealed evidence bundle can't have un-redactable PII* —
+into a feature: PII can be removed from a sealed bundle **without breaking
+verification**, and the removal is itself recorded and tamper-evident.
+
+## The problem
+
+The original audit log (format 1.0) hashed the raw event JSON directly into
+the chain:
+
+```text
+entry_hash = SHA-256(sequence_le || prev_hash || event_json)
+```
+
+Removing any field from an event changes `event_json`, so `entry_hash`
+changes, so the chain breaks and every downstream `prev_hash` is invalidated.
+A sealed bundle was therefore append-only *and* un-erasable — a GDPR/CCPA
+liability.
+
+## The mechanism: commit-to-leaf-hash (format 1.1)
+
+Each entry now commits to a **content hash** of its event, and the chain
+links via that commitment rather than the event bytes:
+
+```text
+content_sha256 = SHA-256(event_json)
+entry_hash = SHA-256(sequence_le || prev_hash_ascii || content_sha256_ascii)
+```
+
+`content_sha256` is stored on the entry. Because the chain links through
+`content_sha256` (not the event bytes), the event content can be replaced
+in place while `content_sha256` — and therefore `entry_hash`, every
+`prev_hash`, and the log's overall `audit_log_hash` — stays identical.
+
+### Redaction
+
+`AuditLog::redact_entry(index, field, reason)` blanks PII-bearing string
+leaves in the event to the sentinel `"[REDACTED]"` (whole event, or a single
+named field), and stamps the entry with a `Redaction { content_sha256,
+reason }` marker. The event's serde **variant is preserved** (only string
+*values* are blanked, never the enum tag/field keys), so every existing
+reader — including the exhaustive `AuditEvent` match in the ITF exporter —
+keeps compiling and working with no change.
+
+A redacted entry's event is **non-authoritative**: the removed content is
+proven only by `content_sha256`. Verification of a redacted entry therefore
+checks the commitment, not the (now blanked) event.
+
+## Verification: `verify()`
+
+Per entry, the form is detected by the presence of `content_sha256`:
+
+- **Commitment (1.1)** — `entry_hash` must equal
+ `SHA-256(seq || prev || content_sha256)`, **and** the content must bind:
+ - not redacted → `SHA-256(event_json) == content_sha256`;
+ - redacted → the marker's `content_sha256 == entry.content_sha256`.
+- **Legacy (1.0)** — empty `content_sha256` → original formula
+ `SHA-256(seq || prev || event_json)`.
+
+So a whole log is either legacy or commitment-form and **both verify**
+(back-compat). New bundles are commitment-form; pre-1.1 bundles keep
+verifying under the legacy rule.
+
+### Redaction vs. tampering — the crux
+
+A redaction is an **authorized, recorded transformation**; a tamper is not.
+They are distinguished cryptographically, not by trust:
+
+| | valid redaction | content tamper |
+|---|---|---|
+| `content_sha256` of the entry | unchanged | must change to alter content |
+| `entry_hash` / chain | intact | broken (unless whole chain rewritten) |
+| **`audit_log_hash`** | **invariant** | **changes** |
+| `verify()` | passes | fails |
+| entry reported as redacted | yes (marker) | n/a |
+
+The load-bearing invariant is **`audit_log_hash` (the last `entry_hash`) does
+not change under redaction**. To alter event *content* an attacker must change
+a `content_sha256`, which changes that entry's `entry_hash`, which changes the
+final `audit_log_hash`. So:
+
+- An operator who anchored the original `audit_log_hash` out-of-band sees it
+ **survive redaction but not a tamper**.
+- Within the bundle, `verify_bundle` reports `redacted_entries` and the chain
+ still verifies for a redaction but fails for a content tamper.
+- A redact-then-tamper (rewrite the marker or the committed hash) splits the
+ marker/commitment binding and/or the `entry_hash` recompute → fails.
+
+## Bundle level: `redact_bundle(dir, index, field, reason)`
+
+Redacting inside an evidence bundle mutates `audit_log.json`, so it also:
+
+1. rewrites `audit_log.json` (event blanked + marker added);
+2. updates `manifest.file_checksums["audit_log.json"]` to the new bytes' hash;
+3. recomputes `manifest.bundle_hash` (so the bundle stays self-consistent and
+ `evidence verify` passes);
+4. drops any `manifest.signature` (it signed the *pre-redaction* `bundle_hash`).
+
+`manifest.audit_log_hash` is **unchanged** (it equals the invariant
+`audit_log_hash`).
+
+### Which top-level hashes a redaction legitimately changes
+
+| field | changes on redaction? | why |
+|---|---|---|
+| `audit_log_hash` | **no** | the whole point — the anchor that survives |
+| `file_checksums["audit_log.json"]` | yes | the file bytes changed (event blanked) |
+| `bundle_hash` | yes | it covers `file_checksums` |
+| `signature` | dropped | it signed the old `bundle_hash` |
+
+An operator anchoring on `audit_log_hash` (recommended) is unaffected by
+redaction. An operator anchoring on `bundle_hash` must re-record it after a
+redaction, and re-sign if they use signatures. This is inherent: redaction is
+a post-seal transformation the original signer did not endorse.
+
+## Scope / caveats (honest)
+
+- **No new `AuditEvent` variant.** The redaction placeholder is a sibling
+ `Redaction` marker on `AuditEntry` plus in-place string-blanking, *not* a
+ distinct event variant. This is a deliberate design choice: a new variant
+ would break the exhaustive `AuditEvent` match in the `boruna-tooling` ITF
+ exporter (`tooling/src/trace/audit_to_itf.rs`), which is outside this
+ slice's edit surface. The marker form is functionally equivalent (content
+ gone, commitment kept, redaction recorded) and touches no other crate.
+- **Field-level redaction marks the whole entry non-authoritative.** Because
+ the commitment is over the *original full* event, a partial (`--field`)
+ redaction cannot keep the untouched fields independently verifiable — once
+ redacted, the event is advisory and only `content_sha256` is anchored. Use
+ `--field` to minimize what is blanked for human readers; the trust boundary
+ is still the whole-event commitment.
+- **Encrypted bundles are not redactable here.** `redact_bundle` rejects
+ bundles with an `encryption` block (`BundleRedactError::EncryptedUnsupported`).
+ Decrypt/rotate first; redacting through the envelope is future work.
+- **`reason` is advisory.** It is stored in the marker but is *not* part of any
+ hash commitment, so it is not tamper-evident. Only `content_sha256` binds.
+
+## CLI
+
+```bash
+# Remove PII from a sealed bundle; re-verifies automatically.
+boruna evidence redact --event 3 --reason "GDPR erasure request"
+
+# Redact only one field of the event.
+boruna evidence redact --event 3 --field approver
+
+# Verify reports which entries are redacted.
+boruna evidence verify
+# evidence bundle is VALID
+# redacted entries: [3]
+```
diff --git a/orchestrator/src/audit/anchor.rs b/orchestrator/src/audit/anchor.rs
new file mode 100644
index 0000000..7c0407d
--- /dev/null
+++ b/orchestrator/src/audit/anchor.rs
@@ -0,0 +1,774 @@
+//! Transparency-log anchoring for evidence bundles (Sigstore Rekor).
+//!
+//! ## Why this exists
+//!
+//! Boruna's native evidence bundle is *tamper-evidence*: a SHA-256
+//! hash chain (`bundle_hash` over `file_checksums` + `audit_log_hash`)
+//! proves internal consistency, and an optional ed25519
+//! [`ManifestSignature`] roots that in an operator key. But the
+//! key-holder can still regenerate and **backdate** the whole chain —
+//! there is no external witness that the bundle existed at a given
+//! time, so it is not *non-repudiation*.
+//!
+//! A **Sigstore Rekor** transparency log closes that hole. Rekor is an
+//! append-only, externally-witnessed Merkle log (RFC 6962 / Trillian).
+//! Submitting an entry gets back:
+//!
+//! - a `logIndex` + `integratedTime` (a trusted timestamp), and
+//! - an **inclusion proof** (Merkle audit path) against a signed log
+//! root, plus a signed entry timestamp (SET).
+//!
+//! Anyone can later re-derive the log root from the entry + proof and
+//! confirm the entry was present — the log operator cannot silently
+//! drop or backdate it. This is the missing external witness.
+//!
+//! ## What this module does
+//!
+//! 1. Builds a Rekor **`hashedrekord`** entry payload from a bundle's
+//! `bundle_hash` + ed25519 [`ManifestSignature`] (or raw components):
+//! the canonical JSON a `POST /api/v1/log/entries` expects.
+//! 2. Verifies a stored `rekor-entry.json` **offline**: recomputes the
+//! RFC 6962 Merkle root from the entry's leaf hash + inclusion proof
+//! and checks it against the proof's `rootHash`, and confirms the
+//! entry commits to the bundle's `bundle_hash`.
+//!
+//! The live HTTP submit path ([`submit`]) is behind the `rekor` cargo
+//! feature, so the DEFAULT build (and all tests) are network-free. A
+//! **private Rekor URL** may be supplied for air-gapped deployments —
+//! nothing here hard-codes the public instance except the CLI default.
+//!
+//! ## Keyless (Fulcio) signing
+//!
+//! This slice deliberately anchors an *already-signed* bundle
+//! (self-managed ed25519 key). Full keyless OIDC → Fulcio short-lived
+//! certs is sketched in `orchestrator/docs/keyless-signing.md` and is
+//! NOT implemented here.
+
+use base64::Engine as _;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+use crate::audit::evidence::BundleManifest;
+
+/// Rekor `hashedrekord` schema version this module emits.
+pub const HASHEDREKORD_API_VERSION: &str = "0.0.1";
+/// Rekor entry kind.
+pub const HASHEDREKORD_KIND: &str = "hashedrekord";
+/// Default public Sigstore Rekor instance (the CLI default; any private
+/// URL is accepted for air-gapped deployments).
+pub const DEFAULT_REKOR_URL: &str = "https://rekor.sigstore.dev";
+
+/// Errors from building or verifying a transparency-log anchor.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AnchorError {
+ /// The manifest carried no ed25519 signature to anchor. Sign the
+ /// bundle first (`EvidenceBundleBuilder::with_signing_key`).
+ UnsignedManifest,
+ /// A hex field (signature / public key / proof hash) was malformed.
+ BadHex(String),
+ /// A base64 field was malformed.
+ BadBase64(String),
+ /// (De)serialization of an entry / response failed.
+ Serialization(String),
+ /// The inclusion proof's `logIndex` was not `< treeSize`.
+ IndexOutOfRange { index: u64, tree_size: u64 },
+ /// The proof had fewer hashes than the entry's position requires.
+ ProofTooShort { have: usize, need: usize },
+ /// The Merkle root recomputed from the proof did not match the
+ /// proof's stated `rootHash`. The entry is not provably in the log.
+ InclusionProofMismatch { computed: String, expected: String },
+ /// The entry commits to a different artifact hash than the bundle's
+ /// `bundle_hash` — the anchor is for a different bundle.
+ DataHashMismatch { entry: String, bundle: String },
+ /// Live submission failed (only reachable with the `rekor` feature).
+ Network(String),
+}
+
+impl std::fmt::Display for AnchorError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ AnchorError::UnsignedManifest => write!(
+ f,
+ "manifest has no ed25519 signature to anchor (sign the bundle first)"
+ ),
+ AnchorError::BadHex(m) => write!(f, "invalid hex: {m}"),
+ AnchorError::BadBase64(m) => write!(f, "invalid base64: {m}"),
+ AnchorError::Serialization(m) => write!(f, "rekor entry serialization failed: {m}"),
+ AnchorError::IndexOutOfRange { index, tree_size } => {
+ write!(
+ f,
+ "inclusion proof index {index} out of range (treeSize {tree_size})"
+ )
+ }
+ AnchorError::ProofTooShort { have, need } => {
+ write!(
+ f,
+ "inclusion proof too short: have {have} hashes, need at least {need}"
+ )
+ }
+ AnchorError::InclusionProofMismatch { computed, expected } => write!(
+ f,
+ "inclusion proof does not verify: recomputed root {computed} != rootHash {expected}"
+ ),
+ AnchorError::DataHashMismatch { entry, bundle } => write!(
+ f,
+ "anchor is for a different bundle: entry data hash {entry} != bundle_hash {bundle}"
+ ),
+ AnchorError::Network(m) => write!(f, "rekor submission failed: {m}"),
+ }
+ }
+}
+
+impl std::error::Error for AnchorError {}
+
+// ---------------------------------------------------------------------------
+// hashedrekord entry payload (the POST body)
+// ---------------------------------------------------------------------------
+
+/// `spec.data.hash`: the artifact digest the entry commits to.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct HashSpec {
+ pub algorithm: String,
+ pub value: String,
+}
+
+/// `spec.data`.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct DataSpec {
+ pub hash: HashSpec,
+}
+
+/// `spec.signature.publicKey`. Rekor expects a PEM-encoded key.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct PublicKeySpec {
+ /// base64(PEM(SubjectPublicKeyInfo)) of the ed25519 public key.
+ pub content: String,
+}
+
+/// `spec.signature`.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct SignatureSpec {
+ /// base64(raw 64-byte ed25519 signature over the artifact).
+ pub content: String,
+ #[serde(rename = "publicKey")]
+ pub public_key: PublicKeySpec,
+}
+
+/// `spec` of a `hashedrekord`.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct HashedRekordSpec {
+ pub data: DataSpec,
+ pub signature: SignatureSpec,
+}
+
+/// A full Rekor `hashedrekord` proposed-entry payload.
+///
+/// This is the canonical JSON a `POST /api/v1/log/entries` accepts. The
+/// entry commits to `{ data.hash, signature.content, publicKey.content }`
+/// — i.e. "this public key signed the artifact whose SHA-256 is X".
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RekorEntry {
+ #[serde(rename = "apiVersion")]
+ pub api_version: String,
+ pub kind: String,
+ pub spec: HashedRekordSpec,
+}
+
+/// Build a `hashedrekord` entry from a finalized, **signed** bundle
+/// manifest. The committed data hash is `manifest.bundle_hash`; the
+/// signature + public key come from the manifest's [`ManifestSignature`]
+/// (the ed25519 signature over `bundle_hash`).
+pub fn hashedrekord_from_manifest(manifest: &BundleManifest) -> Result {
+ let sig = manifest
+ .signature
+ .as_ref()
+ .ok_or(AnchorError::UnsignedManifest)?;
+ hashedrekord_entry(&manifest.bundle_hash, &sig.signature, &sig.public_key)
+}
+
+/// Build a `hashedrekord` entry from raw components: the artifact
+/// SHA-256 (hex), the ed25519 signature over it (hex, 64 bytes), and the
+/// ed25519 public key (hex, 32 bytes). The public key is re-encoded as a
+/// PEM `SubjectPublicKeyInfo` (what Rekor stores), and both signature and
+/// key are base64-wrapped per the `hashedrekord` schema.
+pub fn hashedrekord_entry(
+ data_sha256_hex: &str,
+ signature_hex: &str,
+ public_key_hex: &str,
+) -> Result {
+ let sig_bytes = decode_hex(signature_hex)?;
+ let pk_bytes = decode_hex_array::<32>(public_key_hex)?;
+
+ let b64 = base64::engine::general_purpose::STANDARD;
+ let pem = ed25519_spki_pem(&pk_bytes);
+
+ Ok(RekorEntry {
+ api_version: HASHEDREKORD_API_VERSION.to_string(),
+ kind: HASHEDREKORD_KIND.to_string(),
+ spec: HashedRekordSpec {
+ data: DataSpec {
+ hash: HashSpec {
+ algorithm: "sha256".to_string(),
+ value: data_sha256_hex.trim().to_lowercase(),
+ },
+ },
+ signature: SignatureSpec {
+ content: b64.encode(&sig_bytes),
+ public_key: PublicKeySpec {
+ content: b64.encode(pem.as_bytes()),
+ },
+ },
+ },
+ })
+}
+
+/// Serialize a proposed entry to its canonical JSON bytes (the POST
+/// body). `serde_json` emits struct fields in declaration order, giving
+/// byte-stable output for a given entry.
+pub fn entry_to_bytes(entry: &RekorEntry) -> Result, AnchorError> {
+ serde_json::to_vec(entry).map_err(|e| AnchorError::Serialization(e.to_string()))
+}
+
+// ---------------------------------------------------------------------------
+// stored Rekor response (rekor-entry.json) + inclusion-proof verification
+// ---------------------------------------------------------------------------
+
+/// A Rekor inclusion proof (RFC 6962 audit path). Field names match the
+/// Rekor `LogEntry.verification.inclusionProof` shape, so a real
+/// response deserializes directly.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct InclusionProof {
+ /// 0-based index of this entry's leaf within the tree.
+ #[serde(rename = "logIndex")]
+ pub log_index: u64,
+ /// Number of leaves in the tree the proof is against.
+ #[serde(rename = "treeSize")]
+ pub tree_size: u64,
+ /// Hex Merkle root the proof reconstructs.
+ #[serde(rename = "rootHash")]
+ pub root_hash: String,
+ /// Sibling hashes (hex), leaf→root order (RFC 6962 audit path).
+ pub hashes: Vec,
+ /// Signed tree head note. Opaque here; retained for round-trip.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub checkpoint: Option,
+}
+
+/// `verification` block of a Rekor log entry.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RekorVerification {
+ /// base64 signed entry timestamp (SET). Retained but NOT verified
+ /// here — verifying it needs Rekor's public key / trust root, which
+ /// is out of scope for this offline slice.
+ #[serde(
+ rename = "signedEntryTimestamp",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub signed_entry_timestamp: Option,
+ #[serde(rename = "inclusionProof")]
+ pub inclusion_proof: InclusionProof,
+}
+
+/// A stored Rekor log entry (`rekor-entry.json`). This is the single
+/// entry object from a `POST /api/v1/log/entries` response (the response
+/// is a `{ uuid: entry }` map; [`submit`] unwraps the one value).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RekorLogEntry {
+ /// base64 of the canonicalized entry Rekor leaf-hashed.
+ pub body: String,
+ #[serde(rename = "logIndex")]
+ pub log_index: u64,
+ #[serde(rename = "integratedTime")]
+ pub integrated_time: i64,
+ #[serde(rename = "logID")]
+ pub log_id: String,
+ pub verification: RekorVerification,
+}
+
+/// Outcome of a successful offline anchor verification.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VerifiedAnchor {
+ /// The Merkle root the entry + proof reconstruct (hex).
+ pub root_hash: String,
+ /// Rekor's trusted timestamp (unix seconds).
+ pub integrated_time: i64,
+ /// Global log index of the entry.
+ pub log_index: u64,
+ /// The artifact hash the entry commits to (== bundle_hash).
+ pub data_hash: String,
+}
+
+/// Verify a stored Rekor entry against a bundle's `bundle_hash`,
+/// entirely offline. Two independent checks must pass:
+///
+/// 1. **Inclusion proof**: the RFC 6962 leaf hash of the entry `body`,
+/// combined with the audit-path `hashes`, must reconstruct the
+/// proof's `rootHash`. This proves the entry is in the log at the
+/// stated position (the log operator committed to it).
+/// 2. **Binding**: the `hashedrekord` inside `body` must commit to
+/// exactly this bundle's `bundle_hash` — otherwise the anchor is for
+/// a different artifact.
+///
+/// Returns the reconstructed root + trusted timestamp on success.
+pub fn verify_entry(
+ entry: &RekorLogEntry,
+ bundle_hash: &str,
+) -> Result {
+ let b64 = base64::engine::general_purpose::STANDARD;
+
+ // 1. inclusion proof.
+ let body_bytes = b64
+ .decode(entry.body.as_bytes())
+ .map_err(|e| AnchorError::BadBase64(e.to_string()))?;
+ let leaf = rfc6962_leaf_hash(&body_bytes);
+
+ let proof = &entry.verification.inclusion_proof;
+ let mut siblings = Vec::with_capacity(proof.hashes.len());
+ for h in &proof.hashes {
+ siblings.push(decode_hex_array::<32>(h)?);
+ }
+ let computed = root_from_inclusion_proof(proof.log_index, proof.tree_size, leaf, &siblings)?;
+ let computed_hex = to_hex(&computed);
+ let expected = proof.root_hash.trim().to_lowercase();
+ if computed_hex != expected {
+ return Err(AnchorError::InclusionProofMismatch {
+ computed: computed_hex,
+ expected,
+ });
+ }
+
+ // 2. binding: the entry commits to this bundle's hash.
+ let parsed: RekorEntry = serde_json::from_slice(&body_bytes)
+ .map_err(|e| AnchorError::Serialization(e.to_string()))?;
+ let entry_hash = parsed.spec.data.hash.value.trim().to_lowercase();
+ let bundle = bundle_hash.trim().to_lowercase();
+ if entry_hash != bundle {
+ return Err(AnchorError::DataHashMismatch {
+ entry: entry_hash,
+ bundle,
+ });
+ }
+
+ Ok(VerifiedAnchor {
+ root_hash: computed_hex,
+ integrated_time: entry.integrated_time,
+ log_index: entry.log_index,
+ data_hash: entry_hash,
+ })
+}
+
+/// RFC 6962 leaf hash: `SHA-256(0x00 || leaf_data)`.
+pub fn rfc6962_leaf_hash(leaf_data: &[u8]) -> [u8; 32] {
+ let mut h = Sha256::new();
+ h.update([0x00u8]);
+ h.update(leaf_data);
+ h.finalize().into()
+}
+
+/// RFC 6962 internal node hash: `SHA-256(0x01 || left || right)`.
+fn rfc6962_node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
+ let mut h = Sha256::new();
+ h.update([0x01u8]);
+ h.update(left);
+ h.update(right);
+ h.finalize().into()
+}
+
+/// Reconstruct the Merkle tree root from an RFC 6962 inclusion proof,
+/// following the `RootFromInclusionProof` decomposition
+/// (transparency-dev/merkle). The audit path splits into `inner`
+/// hashes (which pair on the left/right of the running hash according
+/// to the bits of `index`) followed by `border` hashes (always on the
+/// left, folding in the right-hand subtrees toward the root).
+///
+/// `inner = bitlen(index XOR (size-1))`. For a single-leaf tree the
+/// proof is empty and the root is the leaf hash itself.
+fn root_from_inclusion_proof(
+ index: u64,
+ size: u64,
+ leaf_hash: [u8; 32],
+ proof: &[[u8; 32]],
+) -> Result<[u8; 32], AnchorError> {
+ if index >= size {
+ return Err(AnchorError::IndexOutOfRange {
+ index,
+ tree_size: size,
+ });
+ }
+ let inner = inner_proof_size(index, size);
+ if proof.len() < inner {
+ return Err(AnchorError::ProofTooShort {
+ have: proof.len(),
+ need: inner,
+ });
+ }
+
+ let mut res = leaf_hash;
+ // `inner` hashes: pair left/right by the bits of `index`.
+ for (i, sibling) in proof[..inner].iter().enumerate() {
+ if (index >> i) & 1 == 0 {
+ res = rfc6962_node_hash(&res, sibling);
+ } else {
+ res = rfc6962_node_hash(sibling, &res);
+ }
+ }
+ // `border` hashes: always fold in on the left toward the root.
+ for sibling in &proof[inner..] {
+ res = rfc6962_node_hash(sibling, &res);
+ }
+ Ok(res)
+}
+
+/// Number of "inner" proof hashes for a leaf at `index` in a tree of
+/// `size` leaves: the bit length of `index XOR (size - 1)`.
+fn inner_proof_size(index: u64, size: u64) -> usize {
+ let x = index ^ (size - 1);
+ (u64::BITS - x.leading_zeros()) as usize
+}
+
+// ---------------------------------------------------------------------------
+// live submission (network — behind the `rekor` feature)
+// ---------------------------------------------------------------------------
+
+/// Submit a proposed entry to a Rekor instance and return the created
+/// log entry (with inclusion proof). Behind the `rekor` cargo feature so
+/// the default build stays network-free.
+///
+/// `rekor_url` may be the public instance or any **private Rekor** URL
+/// (air-gapped deployments). The `POST /api/v1/log/entries` response is a
+/// `{ uuid: entry }` map with exactly one entry, which is unwrapped here.
+#[cfg(feature = "rekor")]
+pub fn submit(rekor_url: &str, entry: &RekorEntry) -> Result {
+ use std::collections::BTreeMap;
+ let body = entry_to_bytes(entry)?;
+ let url = format!("{}/api/v1/log/entries", rekor_url.trim_end_matches('/'));
+ let resp = ureq::post(&url)
+ .set("Content-Type", "application/json")
+ .set("Accept", "application/json")
+ .send_bytes(&body)
+ .map_err(|e| AnchorError::Network(e.to_string()))?;
+ let text = resp
+ .into_string()
+ .map_err(|e| AnchorError::Network(format!("cannot read Rekor response: {e}")))?;
+ let map: BTreeMap = serde_json::from_str(&text)
+ .map_err(|e| AnchorError::Network(format!("cannot parse Rekor response: {e}")))?;
+ map.into_values()
+ .next()
+ .ok_or_else(|| AnchorError::Network("Rekor returned an empty entry map".to_string()))
+}
+
+// ---------------------------------------------------------------------------
+// small encoding helpers
+// ---------------------------------------------------------------------------
+
+/// Build a PEM `SubjectPublicKeyInfo` for an ed25519 public key.
+///
+/// The SPKI DER for Ed25519 is a fixed 12-byte prefix (SEQUENCE →
+/// AlgorithmIdentifier{OID 1.3.101.112} → BIT STRING header) followed by
+/// the 32 raw key bytes. This lets Rekor store the key in the PEM form
+/// it expects without pulling in an X.509 dependency.
+fn ed25519_spki_pem(pubkey: &[u8; 32]) -> String {
+ // 30 2a SEQUENCE(42)
+ // 30 05 SEQUENCE(5) 06 03 2b 65 70 OID 1.3.101.112 (Ed25519)
+ // 03 21 00 BIT STRING(33: 0 unused bits + 32 key bytes)
+ const PREFIX: [u8; 12] = [
+ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
+ ];
+ let mut der = Vec::with_capacity(PREFIX.len() + 32);
+ der.extend_from_slice(&PREFIX);
+ der.extend_from_slice(pubkey);
+
+ let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
+ // 44-byte DER → 60 base64 chars, under the 64-char PEM line width.
+ let mut pem = String::with_capacity(b64.len() + 64);
+ pem.push_str("-----BEGIN PUBLIC KEY-----\n");
+ for chunk in b64.as_bytes().chunks(64) {
+ pem.push_str(std::str::from_utf8(chunk).expect("base64 is ascii"));
+ pem.push('\n');
+ }
+ pem.push_str("-----END PUBLIC KEY-----\n");
+ pem
+}
+
+/// Lowercase-hex encode bytes.
+fn to_hex(bytes: &[u8]) -> String {
+ use std::fmt::Write;
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ let _ = write!(s, "{b:02x}");
+ }
+ s
+}
+
+/// Decode a hex string into a byte vector.
+fn decode_hex(hex: &str) -> Result, AnchorError> {
+ let hex = hex.trim();
+ if !hex.len().is_multiple_of(2) {
+ return Err(AnchorError::BadHex(format!("odd length {}", hex.len())));
+ }
+ let mut out = Vec::with_capacity(hex.len() / 2);
+ for chunk in hex.as_bytes().chunks(2) {
+ let s = std::str::from_utf8(chunk).map_err(|_| AnchorError::BadHex("non-utf8".into()))?;
+ out.push(
+ u8::from_str_radix(s, 16).map_err(|_| AnchorError::BadHex("non-hex digit".into()))?,
+ );
+ }
+ Ok(out)
+}
+
+/// Decode a fixed-length hex string into `[u8; N]`.
+fn decode_hex_array(hex: &str) -> Result<[u8; N], AnchorError> {
+ let hex = hex.trim();
+ if hex.len() != N * 2 {
+ return Err(AnchorError::BadHex(format!(
+ "expected {} hex chars, got {}",
+ N * 2,
+ hex.len()
+ )));
+ }
+ let bytes = decode_hex(hex)?;
+ let mut out = [0u8; N];
+ out.copy_from_slice(&bytes);
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::audit::evidence::EvidenceBundleBuilder;
+ use crate::audit::log::{AuditEvent, AuditLog};
+ use std::path::Path;
+
+ fn signing_seed(base: u8) -> [u8; 32] {
+ let mut s = [0u8; 32];
+ for (i, b) in s.iter_mut().enumerate() {
+ *b = base.wrapping_add((i as u8).wrapping_mul(7));
+ }
+ s
+ }
+
+ /// Build a signed manifest so `hashedrekord_from_manifest` has a
+ /// `ManifestSignature` to anchor.
+ fn build_signed_manifest(dir: &Path, seed: &[u8; 32]) -> BundleManifest {
+ let mut builder = EvidenceBundleBuilder::new(dir, "run-anchor-001", "anchor-test")
+ .unwrap()
+ .with_signing_key(seed);
+ builder.add_workflow_def(r#"{"name":"test"}"#).unwrap();
+ builder.add_policy(r#"{"default_allow":true}"#).unwrap();
+ let mut audit = AuditLog::new();
+ audit.append(AuditEvent::WorkflowStarted {
+ workflow_hash: "abc".into(),
+ policy_hash: "def".into(),
+ });
+ audit.append(AuditEvent::WorkflowCompleted {
+ result_hash: "res".into(),
+ total_duration_ms: 3,
+ });
+ builder.finalize(&audit).unwrap()
+ }
+
+ #[test]
+ fn entry_from_manifest_commits_to_bundle_hash() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_signed_manifest(dir.path(), &signing_seed(1));
+ let entry = hashedrekord_from_manifest(&manifest).unwrap();
+
+ assert_eq!(entry.api_version, HASHEDREKORD_API_VERSION);
+ assert_eq!(entry.kind, HASHEDREKORD_KIND);
+ assert_eq!(entry.spec.data.hash.algorithm, "sha256");
+ // The committed data hash IS the bundle_hash.
+ assert_eq!(entry.spec.data.hash.value, manifest.bundle_hash);
+ // Signature + key are non-empty base64.
+ assert!(!entry.spec.signature.content.is_empty());
+ assert!(!entry.spec.signature.public_key.content.is_empty());
+ }
+
+ #[test]
+ fn entry_public_key_is_pem_spki() {
+ let seed = signing_seed(2);
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_signed_manifest(dir.path(), &seed);
+ let entry = hashedrekord_from_manifest(&manifest).unwrap();
+
+ let b64 = base64::engine::general_purpose::STANDARD;
+ let pem = b64
+ .decode(entry.spec.signature.public_key.content.as_bytes())
+ .unwrap();
+ let pem = String::from_utf8(pem).unwrap();
+ assert!(pem.starts_with("-----BEGIN PUBLIC KEY-----"));
+ assert!(pem.trim_end().ends_with("-----END PUBLIC KEY-----"));
+ // 12-byte SPKI prefix + 32 key bytes = 44 bytes DER.
+ let der_b64 = pem
+ .lines()
+ .filter(|l| !l.starts_with("-----"))
+ .collect::();
+ let der = b64.decode(der_b64.as_bytes()).unwrap();
+ assert_eq!(der.len(), 44);
+ assert_eq!(&der[9..12], &[0x03, 0x21, 0x00]); // BIT STRING header
+ }
+
+ #[test]
+ fn unsigned_manifest_cannot_be_anchored() {
+ // Bundle built WITHOUT a signing key → no ManifestSignature.
+ let dir = tempfile::tempdir().unwrap();
+ let mut builder =
+ EvidenceBundleBuilder::new(dir.path(), "run-anchor-002", "anchor-test").unwrap();
+ builder.add_workflow_def(r#"{"name":"test"}"#).unwrap();
+ let manifest = builder.finalize(&AuditLog::new()).unwrap();
+ assert_eq!(
+ hashedrekord_from_manifest(&manifest).unwrap_err(),
+ AnchorError::UnsignedManifest
+ );
+ }
+
+ #[test]
+ fn entry_bytes_are_deterministic() {
+ let entry = hashedrekord_entry(
+ "aa".repeat(32).as_str(),
+ "bb".repeat(64).as_str(),
+ "cc".repeat(32).as_str(),
+ )
+ .unwrap();
+ assert_eq!(
+ entry_to_bytes(&entry).unwrap(),
+ entry_to_bytes(&entry).unwrap()
+ );
+ }
+
+ // --- inclusion-proof math -------------------------------------------
+
+ #[test]
+ fn single_leaf_tree_root_is_leaf_hash() {
+ let leaf = rfc6962_leaf_hash(b"only-leaf");
+ let root = root_from_inclusion_proof(0, 1, leaf, &[]).unwrap();
+ assert_eq!(root, leaf);
+ }
+
+ #[test]
+ fn two_leaf_proof_verifies_both_positions() {
+ let leaf0 = rfc6962_leaf_hash(b"leaf-zero");
+ let leaf1 = rfc6962_leaf_hash(b"leaf-one");
+ let root = rfc6962_node_hash(&leaf0, &leaf1);
+
+ // index 0: sibling is leaf1 on the right.
+ assert_eq!(
+ root_from_inclusion_proof(0, 2, leaf0, &[leaf1]).unwrap(),
+ root
+ );
+ // index 1: sibling is leaf0 on the left.
+ assert_eq!(
+ root_from_inclusion_proof(1, 2, leaf1, &[leaf0]).unwrap(),
+ root
+ );
+ }
+
+ #[test]
+ fn index_out_of_range_is_rejected() {
+ let leaf = rfc6962_leaf_hash(b"x");
+ assert_eq!(
+ root_from_inclusion_proof(2, 2, leaf, &[]).unwrap_err(),
+ AnchorError::IndexOutOfRange {
+ index: 2,
+ tree_size: 2
+ }
+ );
+ }
+
+ /// Build a valid stored `RekorLogEntry` for `manifest` as leaf 0 of a
+ /// 2-leaf tree, with a hand-constructed inclusion proof.
+ fn mock_entry_two_leaf(manifest: &BundleManifest) -> RekorLogEntry {
+ let entry = hashedrekord_from_manifest(manifest).unwrap();
+ let body_bytes = entry_to_bytes(&entry).unwrap();
+ let b64 = base64::engine::general_purpose::STANDARD;
+ let body = b64.encode(&body_bytes);
+
+ let leaf0 = rfc6962_leaf_hash(&body_bytes);
+ let leaf1 = rfc6962_leaf_hash(b"the-other-leaf");
+ let root = rfc6962_node_hash(&leaf0, &leaf1);
+
+ RekorLogEntry {
+ body,
+ log_index: 100,
+ integrated_time: 1_700_000_000,
+ log_id: "c0ffee".to_string(),
+ verification: RekorVerification {
+ signed_entry_timestamp: Some("c2lnbmF0dXJl".to_string()),
+ inclusion_proof: InclusionProof {
+ log_index: 0,
+ tree_size: 2,
+ root_hash: to_hex(&root),
+ hashes: vec![to_hex(&leaf1)],
+ checkpoint: None,
+ },
+ },
+ }
+ }
+
+ #[test]
+ fn verify_entry_passes_for_valid_proof_and_binding() {
+ let seed = signing_seed(7);
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_signed_manifest(dir.path(), &seed);
+ let entry = mock_entry_two_leaf(&manifest);
+
+ let ok = verify_entry(&entry, &manifest.bundle_hash).unwrap();
+ assert_eq!(ok.data_hash, manifest.bundle_hash);
+ assert_eq!(ok.integrated_time, 1_700_000_000);
+ assert_eq!(ok.log_index, 100);
+ assert_eq!(ok.root_hash, entry.verification.inclusion_proof.root_hash);
+
+ // Round-trips through JSON like a real rekor-entry.json.
+ let json = serde_json::to_string_pretty(&entry).unwrap();
+ let back: RekorLogEntry = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, entry);
+ assert!(verify_entry(&back, &manifest.bundle_hash).is_ok());
+ }
+
+ #[test]
+ fn verify_entry_fails_on_tampered_proof_hash() {
+ let seed = signing_seed(7);
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_signed_manifest(dir.path(), &seed);
+ let mut entry = mock_entry_two_leaf(&manifest);
+
+ // Flip the sibling hash → recomputed root won't match rootHash.
+ entry.verification.inclusion_proof.hashes[0] = "00".repeat(32);
+ let err = verify_entry(&entry, &manifest.bundle_hash).unwrap_err();
+ assert!(matches!(err, AnchorError::InclusionProofMismatch { .. }));
+ }
+
+ #[test]
+ fn verify_entry_fails_on_tampered_root_hash() {
+ let seed = signing_seed(7);
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_signed_manifest(dir.path(), &seed);
+ let mut entry = mock_entry_two_leaf(&manifest);
+
+ entry.verification.inclusion_proof.root_hash = "ab".repeat(32);
+ let err = verify_entry(&entry, &manifest.bundle_hash).unwrap_err();
+ assert!(matches!(err, AnchorError::InclusionProofMismatch { .. }));
+ }
+
+ #[test]
+ fn verify_entry_fails_when_bundle_hash_differs() {
+ // A valid proof, but the caller asks about a DIFFERENT bundle.
+ let seed = signing_seed(7);
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_signed_manifest(dir.path(), &seed);
+ let entry = mock_entry_two_leaf(&manifest);
+
+ let wrong = "de".repeat(32);
+ let err = verify_entry(&entry, &wrong).unwrap_err();
+ match err {
+ AnchorError::DataHashMismatch {
+ entry: e,
+ bundle: b,
+ } => {
+ assert_eq!(e, manifest.bundle_hash);
+ assert_eq!(b, wrong);
+ }
+ other => panic!("expected DataHashMismatch, got {other:?}"),
+ }
+ }
+}
diff --git a/orchestrator/src/audit/evidence.rs b/orchestrator/src/audit/evidence.rs
index 64047f9..cb5f1e8 100644
--- a/orchestrator/src/audit/evidence.rs
+++ b/orchestrator/src/audit/evidence.rs
@@ -433,6 +433,179 @@ fn fullsync_file(file: &std::fs::File) -> std::io::Result<()> {
}
}
+/// Outcome of a successful [`redact_bundle`] call.
+#[derive(Debug, Clone)]
+pub struct RedactOutcome {
+ /// Sequence number of the redacted audit-log entry.
+ pub redacted_sequence: u64,
+ /// The preserved content commitment for the removed event.
+ pub content_sha256: String,
+ /// True iff a now-stale manifest signature was dropped (the operator
+ /// must re-sign or re-anchor after redaction — see below).
+ pub signature_stripped: bool,
+ /// The recomputed manifest `bundle_hash` after redaction.
+ pub new_bundle_hash: String,
+ /// The `audit_log_hash`, which is INVARIANT under redaction. An
+ /// operator holding this out-of-band anchor can distinguish a
+ /// redaction (anchor unchanged) from a content tamper (anchor
+ /// changes).
+ pub audit_log_hash: String,
+}
+
+/// Errors from [`redact_bundle`].
+#[derive(Debug)]
+pub enum BundleRedactError {
+ Io(std::io::Error),
+ InvalidManifest(String),
+ InvalidAuditLog(String),
+ /// The bundle's audit chain does not verify — refuse to redact a
+ /// bundle that is already broken (nothing to preserve).
+ ChainInvalid(u64),
+ /// Redaction of encrypted bundles is not supported here; rotate/
+ /// decrypt first. See `orchestrator/docs/verifiable-redaction.md`.
+ EncryptedUnsupported,
+ Redact(crate::audit::log::RedactError),
+}
+
+impl std::fmt::Display for BundleRedactError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ BundleRedactError::Io(e) => write!(f, "io: {e}"),
+ BundleRedactError::InvalidManifest(s) => write!(f, "invalid manifest: {s}"),
+ BundleRedactError::InvalidAuditLog(s) => write!(f, "invalid audit_log.json: {s}"),
+ BundleRedactError::ChainInvalid(seq) => {
+ write!(
+ f,
+ "audit chain is broken at entry {seq}; refusing to redact"
+ )
+ }
+ BundleRedactError::EncryptedUnsupported => write!(
+ f,
+ "redaction of encrypted bundles is not supported (decrypt/rotate first)"
+ ),
+ BundleRedactError::Redact(e) => write!(f, "{e}"),
+ }
+ }
+}
+
+impl std::error::Error for BundleRedactError {}
+
+impl From for BundleRedactError {
+ fn from(e: std::io::Error) -> Self {
+ BundleRedactError::Io(e)
+ }
+}
+
+/// Verifiably redact one audit-log entry inside an evidence bundle on
+/// disk, keeping the bundle verifiable.
+///
+/// The commitment chain (format 1.1) makes this possible: the redacted
+/// entry's `content_sha256` is preserved, so its `entry_hash`, the
+/// prev-hash links, and the log's overall `audit_log_hash` are all
+/// UNCHANGED. What legitimately changes is:
+///
+/// - `audit_log.json` bytes (the event content is blanked + a redaction
+/// marker added), hence
+/// - `manifest.file_checksums["audit_log.json"]`, hence
+/// - `manifest.bundle_hash` (recomputed here so the bundle stays
+/// self-consistent and `evidence verify` passes).
+///
+/// A prior ed25519 `signature` (which signed the OLD `bundle_hash`) is
+/// dropped, because redaction is a post-seal authorized transformation
+/// the original signer did not endorse; the operator re-signs afterward.
+///
+/// **Distinguishing redaction from tampering.** `audit_log_hash` is the
+/// invariant: a redaction leaves it unchanged, while any tamper that
+/// alters event *content* must change a `content_sha256` and therefore
+/// the chain and `audit_log_hash`. An operator who anchored the original
+/// `audit_log_hash` out-of-band sees it survive redaction but not a
+/// tamper. Within the bundle, verification also reports which entries
+/// are redacted, and the chain still verifies for a valid redaction but
+/// fails for a content tamper.
+///
+/// Only plaintext bundles are supported; encrypted bundles return
+/// [`BundleRedactError::EncryptedUnsupported`].
+pub fn redact_bundle(
+ bundle_dir: &Path,
+ index: usize,
+ field: Option<&str>,
+ reason: Option,
+) -> Result {
+ // 1. Load manifest.
+ let manifest_path = bundle_dir.join("manifest.json");
+ let manifest_raw = std::fs::read_to_string(&manifest_path)?;
+ let mut manifest: BundleManifest = serde_json::from_str(&manifest_raw)
+ .map_err(|e| BundleRedactError::InvalidManifest(e.to_string()))?;
+
+ if manifest.encryption.is_some() {
+ return Err(BundleRedactError::EncryptedUnsupported);
+ }
+
+ // 2. Load + verify the audit log BEFORE mutating anything.
+ let audit_path = bundle_dir.join("audit_log.json");
+ let audit_raw = std::fs::read_to_string(&audit_path)?;
+ let mut log = AuditLog::from_json(&audit_raw)
+ .map_err(|e| BundleRedactError::InvalidAuditLog(e.to_string()))?;
+ if let Err(seq) = log.verify() {
+ return Err(BundleRedactError::ChainInvalid(seq));
+ }
+ let audit_log_hash = log.hash();
+
+ // 3. Redact the entry; the chain (and audit_log_hash) is preserved.
+ let content_sha256 = log
+ .redact_entry(index, field, reason)
+ .map_err(BundleRedactError::Redact)?;
+ debug_assert_eq!(
+ log.hash(),
+ audit_log_hash,
+ "redaction must preserve audit_log_hash"
+ );
+ let redacted_sequence = log.entries()[index].sequence;
+
+ // 4. Rewrite audit_log.json and update its checksum.
+ let new_audit_json = log
+ .to_json()
+ .map_err(|e| BundleRedactError::InvalidAuditLog(e.to_string()))?;
+ atomic_write_with_dir_fsync(bundle_dir, "audit_log.json", new_audit_json.as_bytes())?;
+ manifest
+ .file_checksums
+ .insert("audit_log.json".to_string(), sha256_str(&new_audit_json));
+
+ // 5. audit_log_hash is unchanged; drop the now-stale signature.
+ let signature_stripped = manifest.signature.take().is_some();
+
+ // 6. Recompute bundle_hash exactly as finalize does (clear
+ // bundle_hash + signature, pretty-print, sha256).
+ let new_bundle_hash = recompute_bundle_hash(&manifest)
+ .map_err(|e| BundleRedactError::InvalidManifest(e.to_string()))?;
+ manifest.bundle_hash = new_bundle_hash.clone();
+
+ // 7. Atomically rewrite the manifest.
+ let final_json = serde_json::to_string_pretty(&manifest)
+ .map_err(|e| BundleRedactError::InvalidManifest(e.to_string()))?;
+ atomic_write_with_dir_fsync(bundle_dir, "manifest.json", final_json.as_bytes())?;
+
+ Ok(RedactOutcome {
+ redacted_sequence,
+ content_sha256,
+ signature_stripped,
+ new_bundle_hash,
+ audit_log_hash,
+ })
+}
+
+/// Recompute a manifest's `bundle_hash` the way
+/// [`EvidenceBundleBuilder::finalize`] does: clone, clear `bundle_hash`
+/// and `signature`, pretty-print, sha256. Mirrors
+/// `verify::recompute_bundle_hash` / `rotate::compute_bundle_hash`.
+fn recompute_bundle_hash(manifest: &BundleManifest) -> Result {
+ let mut clone = manifest.clone();
+ clone.bundle_hash = String::new();
+ clone.signature = None;
+ let json = serde_json::to_string_pretty(&clone)?;
+ Ok(sha256_str(&json))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -623,7 +796,10 @@ mod tests {
);
let raw = std::fs::read_to_string(&bundle_json_path).unwrap();
let parsed: BundleJson = serde_json::from_str(&raw).unwrap();
- assert_eq!(parsed.format_version, "1.0");
+ // Locks the current emitted format version byte-exactly. Bumped
+ // to 1.1 with the commitment-chain audit log (verifiable
+ // redaction). A 1.0 reader still accepts this bundle (same major).
+ assert_eq!(parsed.format_version, "1.1");
assert_eq!(parsed.run_id, "run-fmt-001");
assert!(!parsed.boruna_version.is_empty());
assert!(parsed.components.iter().any(|c| c == "manifest.json"));
diff --git a/orchestrator/src/audit/log.rs b/orchestrator/src/audit/log.rs
index 2bccc0b..d5ac0b7 100644
--- a/orchestrator/src/audit/log.rs
+++ b/orchestrator/src/audit/log.rs
@@ -1,15 +1,111 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
+/// Sentinel value substituted for a redacted string leaf. Verification
+/// never inspects this text — the removed content is proven by the
+/// entry's `content_sha256` commitment, not by the remnant.
+pub const REDACTION_SENTINEL: &str = "[REDACTED]";
+
/// A single entry in the hash-chained audit log.
+///
+/// ## Commitment chain (format 1.1)
+///
+/// Each entry commits to a **content hash** of its event rather than
+/// hashing the raw event JSON into the chain directly. Concretely:
+///
+/// ```text
+/// content_sha256 = SHA-256(event_json)
+/// entry_hash = SHA-256(sequence_le || prev_hash_ascii || content_sha256_ascii)
+/// ```
+///
+/// Because the chain links via `content_sha256` (not the event bytes),
+/// the event content can later be REDACTED — replaced in place while
+/// leaving `content_sha256` untouched — and the chain still recomputes
+/// to the identical `entry_hash`. This is what makes a sealed audit log
+/// redactable without breaking verification (verifiable redaction).
+///
+/// ## Back-compat (format 1.0)
+///
+/// Legacy entries have no `content_sha256` (it deserializes to the empty
+/// string via `#[serde(default)]`). Those entries verify under the
+/// original formula `SHA-256(sequence_le || prev_hash || event_json)`.
+/// Presence of a non-empty `content_sha256` selects the commitment form
+/// per entry, so a whole log is either legacy or commitment-form and both
+/// verify.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub sequence: u64,
pub prev_hash: String,
+ /// Commitment to the event content: `SHA-256(event_json)`. Empty on
+ /// legacy (format 1.0) entries, which chain over the raw event JSON.
+ /// Skipped when empty so re-serialized legacy logs stay byte-identical.
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub content_sha256: String,
pub event: AuditEvent,
+ /// Present iff this entry's event content has been redacted. The
+ /// event field then holds a placeholder (PII-bearing strings blanked
+ /// to [`REDACTION_SENTINEL`]) and is NON-AUTHORITATIVE — the removed
+ /// content is proven only by `content_sha256`. Absent on normal
+ /// entries (skipped in JSON, so unredacted logs are byte-unchanged).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub redacted: Option,
pub entry_hash: String,
}
+/// Records that an entry was redacted: an AUTHORIZED, recorded removal of
+/// event content. It carries the same `content_sha256` commitment that
+/// the entry already binds into its `entry_hash`, so a verifier can
+/// confirm the redaction is consistent (marker matches the committed
+/// hash) without ever seeing the original content.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Redaction {
+ /// The removed content's `SHA-256` — equal to the entry's
+ /// `content_sha256`. Binding the two lets a verifier detect a
+ /// redact-then-tamper (rewriting one but not the other).
+ pub content_sha256: String,
+ /// Optional operator-supplied reason. Advisory metadata; not part of
+ /// the commitment.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option,
+}
+
+/// Error from [`AuditLog::redact_entry`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RedactError {
+ /// Entry index is past the end of the log.
+ OutOfRange(usize),
+ /// Entry is a legacy (format 1.0) entry with no content commitment;
+ /// redaction requires the commitment chain (format 1.1). Migrate the
+ /// bundle first.
+ LegacyUnsupported,
+ /// Entry has already been redacted.
+ AlreadyRedacted(usize),
+ /// A `--field` target was not present in the event.
+ FieldNotFound(String),
+ /// Re-serialization of the placeholder event failed.
+ Serde(String),
+}
+
+impl std::fmt::Display for RedactError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RedactError::OutOfRange(i) => write!(f, "entry index {i} is out of range"),
+ RedactError::LegacyUnsupported => write!(
+ f,
+ "entry uses the legacy (1.0) chain with no content commitment; \
+ redaction requires format 1.1 (migrate the bundle first)"
+ ),
+ RedactError::AlreadyRedacted(i) => write!(f, "entry {i} is already redacted"),
+ RedactError::FieldNotFound(name) => {
+ write!(f, "field `{name}` not found in the event")
+ }
+ RedactError::Serde(e) => write!(f, "placeholder serialization failed: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for RedactError {}
+
/// Events that can be recorded in the audit log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuditEvent {
@@ -91,6 +187,10 @@ impl AuditLog {
}
/// Append a new event. Returns the entry's hash.
+ ///
+ /// Uses the commitment chain (format 1.1): the entry commits to
+ /// `content_sha256 = SHA-256(event_json)`, and the chain links via
+ /// that commitment so the event can later be redacted in place.
pub fn append(&mut self, event: AuditEvent) -> String {
let sequence = self.entries.len() as u64;
let prev_hash = self
@@ -99,12 +199,15 @@ impl AuditLog {
.map(|e| e.entry_hash.clone())
.unwrap_or_else(|| "0".repeat(64));
- let entry_hash = Self::compute_hash(sequence, &prev_hash, &event);
+ let content_sha256 = Self::content_hash(&event);
+ let entry_hash = Self::compute_entry_hash(sequence, &prev_hash, &content_sha256);
self.entries.push(AuditEntry {
sequence,
prev_hash,
+ content_sha256,
event,
+ redacted: None,
entry_hash: entry_hash.clone(),
});
@@ -112,6 +215,15 @@ impl AuditLog {
}
/// Verify the integrity of the entire chain. Returns Ok(()) or the index of the first bad entry.
+ ///
+ /// Each entry is checked under its own format:
+ /// - **Commitment (1.1)** — non-empty `content_sha256`: the entry
+ /// hash must equal `SHA-256(seq || prev || content_sha256)`, and
+ /// the content must bind — either the live event hashes to
+ /// `content_sha256` (unredacted), or the entry is redacted and its
+ /// marker carries the same `content_sha256` (authorized removal).
+ /// - **Legacy (1.0)** — empty `content_sha256`: the original formula
+ /// `SHA-256(seq || prev || event_json)`.
pub fn verify(&self) -> Result<(), u64> {
let mut expected_prev = "0".repeat(64);
@@ -119,9 +231,39 @@ impl AuditLog {
if entry.prev_hash != expected_prev {
return Err(entry.sequence);
}
- let computed = Self::compute_hash(entry.sequence, &entry.prev_hash, &entry.event);
- if computed != entry.entry_hash {
- return Err(entry.sequence);
+ if entry.content_sha256.is_empty() {
+ // Legacy (format 1.0) chain over the raw event JSON.
+ let computed =
+ Self::compute_hash_legacy(entry.sequence, &entry.prev_hash, &entry.event);
+ if computed != entry.entry_hash {
+ return Err(entry.sequence);
+ }
+ } else {
+ // Commitment (format 1.1) chain over content_sha256.
+ let computed = Self::compute_entry_hash(
+ entry.sequence,
+ &entry.prev_hash,
+ &entry.content_sha256,
+ );
+ if computed != entry.entry_hash {
+ return Err(entry.sequence);
+ }
+ match &entry.redacted {
+ // Authorized removal: the marker must commit to the
+ // same content hash the chain binds. A redact-then-
+ // tamper that rewrites either side is caught here.
+ Some(r) => {
+ if r.content_sha256 != entry.content_sha256 {
+ return Err(entry.sequence);
+ }
+ }
+ // Unredacted: the live event must match its commitment.
+ None => {
+ if Self::content_hash(&entry.event) != entry.content_sha256 {
+ return Err(entry.sequence);
+ }
+ }
+ }
}
expected_prev = entry.entry_hash.clone();
}
@@ -129,6 +271,78 @@ impl AuditLog {
Ok(())
}
+ /// Redact an entry's event content in place (verifiable redaction).
+ ///
+ /// Blanks PII-bearing string leaves in the event to
+ /// [`REDACTION_SENTINEL`] while preserving the entry's
+ /// `content_sha256` commitment (and therefore its `entry_hash`, its
+ /// `prev_hash` links, and the log's overall [`Self::hash`]). The
+ /// entry is stamped with a [`Redaction`] marker; verification then
+ /// treats the event as non-authoritative and validates only the
+ /// commitment. `verify()` still PASSES; a tamper that alters the
+ /// commitment still FAILS.
+ ///
+ /// - `field: None` blanks every string leaf in the event.
+ /// - `field: Some(name)` blanks only that named field of the event.
+ ///
+ /// Returns the preserved `content_sha256` on success. Errors if the
+ /// index is out of range, the entry is legacy-format, already
+ /// redacted, or the named field is absent.
+ pub fn redact_entry(
+ &mut self,
+ index: usize,
+ field: Option<&str>,
+ reason: Option,
+ ) -> Result {
+ let entry = self
+ .entries
+ .get_mut(index)
+ .ok_or(RedactError::OutOfRange(index))?;
+ if entry.content_sha256.is_empty() {
+ return Err(RedactError::LegacyUnsupported);
+ }
+ if entry.redacted.is_some() {
+ return Err(RedactError::AlreadyRedacted(index));
+ }
+
+ let mut value =
+ serde_json::to_value(&entry.event).map_err(|e| RedactError::Serde(e.to_string()))?;
+ match field {
+ None => blank_all_strings(&mut value),
+ Some(name) => {
+ // The event serializes as a single-key object
+ // `{"Variant": {fields...}}`; descend into the inner
+ // field object to reach the target field.
+ let target = value
+ .as_object_mut()
+ .and_then(|o| o.values_mut().next())
+ .and_then(|inner| inner.as_object_mut())
+ .and_then(|fields| fields.get_mut(name))
+ .ok_or_else(|| RedactError::FieldNotFound(name.to_string()))?;
+ blank_all_strings(target);
+ }
+ }
+ let placeholder: AuditEvent =
+ serde_json::from_value(value).map_err(|e| RedactError::Serde(e.to_string()))?;
+
+ entry.event = placeholder;
+ entry.redacted = Some(Redaction {
+ content_sha256: entry.content_sha256.clone(),
+ reason,
+ });
+ Ok(entry.content_sha256.clone())
+ }
+
+ /// Sequences of entries that have been redacted, in order. Used by
+ /// `verify_bundle` to report which entries carry redactions.
+ pub fn redacted_sequences(&self) -> Vec {
+ self.entries
+ .iter()
+ .filter(|e| e.redacted.is_some())
+ .map(|e| e.sequence)
+ .collect()
+ }
+
/// Get all entries.
pub fn entries(&self) -> &[AuditEntry] {
&self.entries
@@ -186,7 +400,30 @@ impl AuditLog {
self.entries
}
- fn compute_hash(sequence: u64, prev_hash: &str, event: &AuditEvent) -> String {
+ /// Content commitment for an event: `SHA-256(event_json)`. This is
+ /// what the entry (and thus the chain) binds, so the event bytes can
+ /// be redacted later without disturbing the chain.
+ fn content_hash(event: &AuditEvent) -> String {
+ let event_json = serde_json::to_string(event).unwrap_or_default();
+ let mut hasher = Sha256::new();
+ hasher.update(event_json.as_bytes());
+ format!("{:x}", hasher.finalize())
+ }
+
+ /// Commitment-chain (format 1.1) entry hash:
+ /// `SHA-256(sequence_le || prev_hash || content_sha256)`.
+ fn compute_entry_hash(sequence: u64, prev_hash: &str, content_sha256: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(sequence.to_le_bytes());
+ hasher.update(prev_hash.as_bytes());
+ hasher.update(content_sha256.as_bytes());
+ format!("{:x}", hasher.finalize())
+ }
+
+ /// Legacy (format 1.0) entry hash used to verify pre-1.1 logs:
+ /// `SHA-256(sequence_le || prev_hash || event_json)`. Retained for
+ /// back-compat verification only; `append` never produces this form.
+ fn compute_hash_legacy(sequence: u64, prev_hash: &str, event: &AuditEvent) -> String {
let event_json = serde_json::to_string(event).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(sequence.to_le_bytes());
@@ -196,6 +433,19 @@ impl AuditLog {
}
}
+/// Recursively replace every string leaf in `v` with
+/// [`REDACTION_SENTINEL`]. Object KEYS (which carry the serde enum tag
+/// and field names) are left intact so the value still deserializes back
+/// into the same `AuditEvent` variant.
+fn blank_all_strings(v: &mut serde_json::Value) {
+ match v {
+ serde_json::Value::String(s) => *s = REDACTION_SENTINEL.to_string(),
+ serde_json::Value::Array(a) => a.iter_mut().for_each(blank_all_strings),
+ serde_json::Value::Object(o) => o.values_mut().for_each(blank_all_strings),
+ _ => {}
+ }
+}
+
impl Default for AuditLog {
fn default() -> Self {
Self::new()
@@ -331,4 +581,215 @@ mod tests {
assert_eq!(log.entries()[0].entry_hash, h1);
assert_eq!(log.entries()[1].prev_hash, h1);
}
+
+ // ---- verifiable redaction: commitment chain (format 1.1) ----
+
+ fn sample_log() -> AuditLog {
+ let mut log = AuditLog::new();
+ log.append(AuditEvent::WorkflowStarted {
+ workflow_hash: "wf".into(),
+ policy_hash: "po".into(),
+ });
+ log.append(AuditEvent::ApprovalGranted {
+ step_id: "s1".into(),
+ approver: "alice@example.com".into(),
+ });
+ log.append(AuditEvent::WorkflowCompleted {
+ result_hash: "res".into(),
+ total_duration_ms: 10,
+ });
+ log
+ }
+
+ #[test]
+ fn commitment_chain_populates_content_hash_and_verifies() {
+ let log = sample_log();
+ assert!(log.verify().is_ok());
+ // Every entry carries a 64-hex content commitment, and the entry
+ // hash is the commitment-form hash (not the legacy form).
+ for e in log.entries() {
+ assert_eq!(e.content_sha256.len(), 64, "content_sha256 must be set");
+ assert_eq!(
+ e.entry_hash,
+ AuditLog::compute_entry_hash(e.sequence, &e.prev_hash, &e.content_sha256)
+ );
+ assert_eq!(e.content_sha256, AuditLog::content_hash(&e.event));
+ }
+ }
+
+ #[test]
+ fn legacy_format_1_0_log_still_verifies() {
+ // Hand-build a legacy entry: empty content_sha256, entry_hash via
+ // the ORIGINAL formula SHA-256(seq || prev || event_json).
+ let event = AuditEvent::StepStarted {
+ step_id: "s1".into(),
+ input_hash: "inp".into(),
+ };
+ let prev = "0".repeat(64);
+ let entry_hash = AuditLog::compute_hash_legacy(0, &prev, &event);
+ let legacy = AuditEntry {
+ sequence: 0,
+ prev_hash: prev,
+ content_sha256: String::new(), // legacy marker
+ event,
+ redacted: None,
+ entry_hash,
+ };
+ let log = AuditLog::from_entries(vec![legacy]);
+ assert!(log.verify().is_ok(), "legacy 1.0 entry must still verify");
+
+ // A round-trip through JSON (no content_sha256 field present)
+ // rehydrates as legacy and still verifies.
+ let json = log.to_json().unwrap();
+ assert!(!json.contains("content_sha256"));
+ let restored = AuditLog::from_json(&json).unwrap();
+ assert!(restored.verify().is_ok());
+ }
+
+ #[test]
+ fn redact_whole_event_preserves_chain_and_removes_pii() {
+ let mut log = sample_log();
+ let anchor_hash = log.hash();
+ let orig_commit = log.entries()[1].content_sha256.clone();
+
+ let returned = log
+ .redact_entry(1, None, Some("GDPR erasure request".into()))
+ .unwrap();
+
+ // Chain still verifies; the log's overall hash is UNCHANGED.
+ assert!(log.verify().is_ok(), "redacted log must still verify");
+ assert_eq!(
+ log.hash(),
+ anchor_hash,
+ "audit_log hash invariant under redaction"
+ );
+ assert_eq!(returned, orig_commit);
+
+ let e = &log.entries()[1];
+ // The commitment is preserved; the marker records the redaction.
+ assert_eq!(e.content_sha256, orig_commit);
+ let r = e.redacted.as_ref().expect("marker present");
+ assert_eq!(r.content_sha256, orig_commit);
+ assert_eq!(r.reason.as_deref(), Some("GDPR erasure request"));
+
+ // The PII (approver email) is gone; strings blanked to sentinel.
+ match &e.event {
+ AuditEvent::ApprovalGranted { step_id, approver } => {
+ assert_eq!(step_id, REDACTION_SENTINEL);
+ assert_eq!(approver, REDACTION_SENTINEL);
+ assert!(!approver.contains("alice"));
+ }
+ other => panic!("variant must be preserved, got {other:?}"),
+ }
+ assert_eq!(log.redacted_sequences(), vec![1]);
+ }
+
+ #[test]
+ fn redact_single_field_blanks_only_that_field() {
+ let mut log = sample_log();
+ log.redact_entry(1, Some("approver"), None).unwrap();
+ assert!(log.verify().is_ok());
+ match &log.entries()[1].event {
+ AuditEvent::ApprovalGranted { step_id, approver } => {
+ assert_eq!(step_id, "s1"); // untouched
+ assert_eq!(approver, REDACTION_SENTINEL); // blanked
+ }
+ other => panic!("unexpected: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn redact_unknown_field_errors() {
+ let mut log = sample_log();
+ assert_eq!(
+ log.redact_entry(1, Some("no_such_field"), None),
+ Err(RedactError::FieldNotFound("no_such_field".into()))
+ );
+ }
+
+ #[test]
+ fn redact_already_redacted_errors() {
+ let mut log = sample_log();
+ log.redact_entry(1, None, None).unwrap();
+ assert_eq!(
+ log.redact_entry(1, None, None),
+ Err(RedactError::AlreadyRedacted(1))
+ );
+ }
+
+ #[test]
+ fn redact_out_of_range_errors() {
+ let mut log = sample_log();
+ assert_eq!(
+ log.redact_entry(99, None, None),
+ Err(RedactError::OutOfRange(99))
+ );
+ }
+
+ #[test]
+ fn redact_legacy_entry_unsupported() {
+ let event = AuditEvent::StepFailed {
+ step_id: "s1".into(),
+ error: "boom".into(),
+ };
+ let prev = "0".repeat(64);
+ let entry_hash = AuditLog::compute_hash_legacy(0, &prev, &event);
+ let mut log = AuditLog::from_entries(vec![AuditEntry {
+ sequence: 0,
+ prev_hash: prev,
+ content_sha256: String::new(),
+ event,
+ redacted: None,
+ entry_hash,
+ }]);
+ assert_eq!(
+ log.redact_entry(0, None, None),
+ Err(RedactError::LegacyUnsupported)
+ );
+ }
+
+ #[test]
+ fn tamper_of_unredacted_event_is_detected() {
+ // Motivated attacker edits the event but keeps content_sha256 and
+ // entry_hash: the content-binding check catches the mismatch.
+ let mut log = sample_log();
+ log.entries[1].event = AuditEvent::ApprovalGranted {
+ step_id: "s1".into(),
+ approver: "attacker".into(),
+ };
+ assert_eq!(log.verify().unwrap_err(), 1);
+ }
+
+ #[test]
+ fn redact_then_tamper_commitment_fails() {
+ // Redact, then rewrite the marker's content_sha256 to forge a
+ // different original. The marker/commitment binding breaks.
+ let mut log = sample_log();
+ log.redact_entry(1, None, None).unwrap();
+ assert!(log.verify().is_ok());
+
+ log.entries[1].redacted = Some(Redaction {
+ content_sha256: "f".repeat(64),
+ reason: None,
+ });
+ assert_eq!(log.verify().unwrap_err(), 1);
+
+ // Alternatively, rewriting the entry's committed hash breaks the
+ // entry_hash recompute (chain integrity).
+ let mut log2 = sample_log();
+ log2.redact_entry(1, None, None).unwrap();
+ log2.entries[1].content_sha256 = "e".repeat(64);
+ assert!(log2.verify().is_err());
+ }
+
+ #[test]
+ fn redaction_survives_json_round_trip() {
+ let mut log = sample_log();
+ log.redact_entry(1, None, Some("privacy".into())).unwrap();
+ let json = log.to_json().unwrap();
+ let restored = AuditLog::from_json(&json).unwrap();
+ assert!(restored.verify().is_ok());
+ assert_eq!(restored.redacted_sequences(), vec![1]);
+ assert_eq!(restored.hash(), log.hash());
+ }
}
diff --git a/orchestrator/src/audit/mod.rs b/orchestrator/src/audit/mod.rs
index 61fe3d3..f62526d 100644
--- a/orchestrator/src/audit/mod.rs
+++ b/orchestrator/src/audit/mod.rs
@@ -1,3 +1,4 @@
+pub mod anchor;
pub mod attestation;
pub mod encryption;
pub mod evidence;
@@ -33,4 +34,11 @@ pub use verify::*;
/// - Different major (`2.x`) → breaking: readers MUST reject.
///
/// See `docs/spec/evidence-bundle-1.0.md` for the full spec.
-pub const BUNDLE_FORMAT_VERSION: &str = "1.0";
+///
+/// Bumped to `1.1` for the commitment-chain audit log that enables
+/// verifiable redaction (`orchestrator/docs/verifiable-redaction.md`).
+/// This is a MINOR bump: same-major, so a 1.0 reader still accepts a 1.1
+/// bundle, and the current reader verifies both legacy (1.0, chain over
+/// raw event JSON) and commitment-form (1.1) audit logs — the form is
+/// detected per entry by the presence of `content_sha256`.
+pub const BUNDLE_FORMAT_VERSION: &str = "1.1";
diff --git a/orchestrator/src/audit/verify.rs b/orchestrator/src/audit/verify.rs
index 215f2d4..a54056d 100644
--- a/orchestrator/src/audit/verify.rs
+++ b/orchestrator/src/audit/verify.rs
@@ -38,6 +38,25 @@ impl std::error::Error for EvidenceError {}
pub struct VerifyResult {
pub valid: bool,
pub errors: Vec,
+ /// Sequence numbers of audit-log entries that carry an authorized
+ /// redaction. Populated once the audit log is parsed; empty when the
+ /// bundle is rejected before that point. A validly-redacted bundle is
+ /// still `valid: true` with these entries listed — that is how a
+ /// reader sees a redaction is present (and recorded), as distinct
+ /// from a tamper (which flips `valid` to false).
+ pub redacted_entries: Vec,
+}
+
+impl VerifyResult {
+ /// Build an invalid result with no redaction info (used for the
+ /// early-return rejection paths before the audit log is parsed).
+ fn invalid(errors: Vec) -> Self {
+ VerifyResult {
+ valid: false,
+ errors,
+ redacted_entries: Vec::new(),
+ }
+ }
}
/// Read and validate the top-level `bundle.json` format gate.
@@ -155,10 +174,7 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
// bundle from a future incompatible major. This runs FIRST so
// we don't try to read content we can't safely interpret.
if let Err(e) = check_bundle_format(bundle_dir) {
- return VerifyResult {
- valid: false,
- errors: vec![e.to_string()],
- };
+ return VerifyResult::invalid(vec![e.to_string()]);
}
// 1. Load and parse manifest
@@ -166,20 +182,14 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
let manifest_json = match std::fs::read_to_string(&manifest_path) {
Ok(j) => j,
Err(e) => {
- return VerifyResult {
- valid: false,
- errors: vec![format!("cannot read manifest.json: {e}")],
- };
+ return VerifyResult::invalid(vec![format!("cannot read manifest.json: {e}")]);
}
};
let manifest: BundleManifest = match serde_json::from_str(&manifest_json) {
Ok(m) => m,
Err(e) => {
- return VerifyResult {
- valid: false,
- errors: vec![format!("invalid manifest.json: {e}")],
- };
+ return VerifyResult::invalid(vec![format!("invalid manifest.json: {e}")]);
}
};
@@ -240,52 +250,39 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
None => match crate::audit::encryption::resolve_kek(None) {
Ok(k) => k,
Err(e) => {
- return VerifyResult {
- valid: false,
- errors: vec![format!("invalid KEK: {e}")],
- };
+ return VerifyResult::invalid(vec![format!("invalid KEK: {e}")]);
}
},
};
let key = match resolved_kek {
Some(k) => k,
None => {
- return VerifyResult {
- valid: false,
- errors: vec![format!(
- "evidence.encryption_key_required: bundle is encrypted (kek_id={}); \
- supply --bundle-encryption-key or set BORUNA_BUNDLE_KEK",
- info.kek_id
- )],
- };
+ return VerifyResult::invalid(vec![format!(
+ "evidence.encryption_key_required: bundle is encrypted (kek_id={}); \
+ supply --bundle-encryption-key or set BORUNA_BUNDLE_KEK",
+ info.kek_id
+ )]);
}
};
match Envelope::unwrap(info, &key) {
Ok(env) => Some(env),
Err(EncryptionError::UnsupportedAlgorithm { found, expected }) => {
- return VerifyResult {
- valid: false,
- errors: vec![format!(
- "evidence.unsupported_algorithm: bundle declares algorithm={found:?}; \
- reader supports only {expected:?}"
- )],
- };
+ return VerifyResult::invalid(vec![format!(
+ "evidence.unsupported_algorithm: bundle declares algorithm={found:?}; \
+ reader supports only {expected:?}"
+ )]);
}
Err(EncryptionError::EncryptionKeyMismatch) => {
- return VerifyResult {
- valid: false,
- errors: vec![format!(
- "evidence.encryption_key_mismatch: supplied KEK does not unwrap the \
- bundle's wrapped_dek (kek_id={})",
- info.kek_id
- )],
- };
+ return VerifyResult::invalid(vec![format!(
+ "evidence.encryption_key_mismatch: supplied KEK does not unwrap the \
+ bundle's wrapped_dek (kek_id={})",
+ info.kek_id
+ )]);
}
Err(e) => {
- return VerifyResult {
- valid: false,
- errors: vec![format!("invalid encryption metadata: {e}")],
- };
+ return VerifyResult::invalid(vec![format!(
+ "invalid encryption metadata: {e}"
+ )]);
}
}
}
@@ -328,6 +325,7 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
}
// 4. Verify audit log chain integrity (decrypt-then-parse).
+ let mut redacted_entries: Vec = Vec::new();
let audit_path = bundle_dir.join("audit_log.json");
match std::fs::read(&audit_path) {
Ok(raw) => {
@@ -343,6 +341,10 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
match std::str::from_utf8(&audit_pt) {
Ok(audit_json) => match AuditLog::from_json(audit_json) {
Ok(audit_log) => {
+ // A valid redaction preserves the chain and
+ // audit_log_hash; a content tamper breaks one
+ // of them. So these checks are exactly what
+ // separates "redacted" from "tampered".
if let Err(bad_seq) = audit_log.verify() {
errors.push(format!("audit log chain broken at entry {bad_seq}"));
}
@@ -353,6 +355,7 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
audit_log.hash()
));
}
+ redacted_entries = audit_log.redacted_sequences();
}
Err(e) => {
errors.push(format!("invalid audit_log.json: {e}"));
@@ -384,6 +387,7 @@ pub fn verify_bundle_with_opts(bundle_dir: &Path, opts: &VerifyOptions) -> Verif
VerifyResult {
valid: errors.is_empty(),
errors,
+ redacted_entries,
}
}
@@ -558,6 +562,132 @@ mod tests {
let result = verify_bundle(&bundle_dir);
assert!(result.valid, "errors: {:?}", result.errors);
+ assert!(result.redacted_entries.is_empty());
+ }
+
+ // ---- verifiable redaction at the bundle level ----
+
+ /// Build a bundle whose audit log carries PII (an approver email at
+ /// entry 1) so redaction has something to remove.
+ fn build_bundle_with_pii(dir: &Path, run_id: &str) -> BundleManifest {
+ let mut builder = EvidenceBundleBuilder::new(dir, run_id, "pii-test").unwrap();
+ builder.add_workflow_def(r#"{"name":"test"}"#).unwrap();
+ builder.add_policy(r#"{"default_allow":true}"#).unwrap();
+ let mut audit = AuditLog::new();
+ audit.append(AuditEvent::WorkflowStarted {
+ workflow_hash: "abc".into(),
+ policy_hash: "def".into(),
+ });
+ audit.append(AuditEvent::ApprovalGranted {
+ step_id: "s1".into(),
+ approver: "alice.privacy@example.com".into(),
+ });
+ audit.append(AuditEvent::WorkflowCompleted {
+ result_hash: "res".into(),
+ total_duration_ms: 60,
+ });
+ builder.finalize(&audit).unwrap()
+ }
+
+ #[test]
+ fn redact_bundle_still_verifies_and_reports_redaction() {
+ use crate::audit::evidence::redact_bundle;
+ let dir = tempfile::tempdir().unwrap();
+ let orig = build_bundle_with_pii(dir.path(), "run-redact-001");
+ let bundle_dir = dir.path().join("run-redact-001");
+
+ // Sanity: PII is present on disk before redaction.
+ let before = std::fs::read_to_string(bundle_dir.join("audit_log.json")).unwrap();
+ assert!(before.contains("alice.privacy@example.com"));
+
+ let outcome = redact_bundle(&bundle_dir, 1, None, Some("GDPR erasure".into())).unwrap();
+
+ // audit_log_hash is INVARIANT under redaction; bundle_hash changed.
+ assert_eq!(outcome.audit_log_hash, orig.audit_log_hash);
+ assert_ne!(outcome.new_bundle_hash, orig.bundle_hash);
+ assert_eq!(outcome.redacted_sequence, 1);
+
+ // Bundle STILL verifies and reports the redacted entry.
+ let res = verify_bundle(&bundle_dir);
+ assert!(
+ res.valid,
+ "redacted bundle must verify; errors: {:?}",
+ res.errors
+ );
+ assert_eq!(res.redacted_entries, vec![1]);
+
+ // The original PII is gone; the commitment + marker remain.
+ let after = std::fs::read_to_string(bundle_dir.join("audit_log.json")).unwrap();
+ assert!(!after.contains("alice.privacy@example.com"));
+ assert!(after.contains(&outcome.content_sha256));
+ assert!(after.contains("[REDACTED]"));
+ }
+
+ #[test]
+ fn redact_bundle_then_tamper_placeholder_fails() {
+ use crate::audit::evidence::redact_bundle;
+ use crate::audit::log::AuditLog as Log;
+ let dir = tempfile::tempdir().unwrap();
+ build_bundle_with_pii(dir.path(), "run-redact-002");
+ let bundle_dir = dir.path().join("run-redact-002");
+ redact_bundle(&bundle_dir, 1, None, None).unwrap();
+ assert!(verify_bundle(&bundle_dir).valid);
+
+ // Forge a different original by corrupting the redacted entry's
+ // committed hash in the file. The entry's own `content_sha256`
+ // field serializes before its `redacted` marker, so replacing the
+ // first occurrence splits the commitment from the marker AND from
+ // entry_hash — the chain no longer recomputes.
+ let audit_json = std::fs::read_to_string(bundle_dir.join("audit_log.json")).unwrap();
+ let log = Log::from_json(&audit_json).unwrap();
+ let corrupted = audit_json.replacen(&log.entries()[1].content_sha256, &"0".repeat(64), 1);
+ std::fs::write(bundle_dir.join("audit_log.json"), &corrupted).unwrap();
+
+ let res = verify_bundle(&bundle_dir);
+ assert!(!res.valid, "redact-then-tamper must fail");
+ }
+
+ #[test]
+ fn tamper_after_redact_content_fails_via_checksum() {
+ use crate::audit::evidence::redact_bundle;
+ let dir = tempfile::tempdir().unwrap();
+ build_bundle_with_pii(dir.path(), "run-redact-003");
+ let bundle_dir = dir.path().join("run-redact-003");
+ redact_bundle(&bundle_dir, 1, None, None).unwrap();
+ assert!(verify_bundle(&bundle_dir).valid);
+
+ // A naive tamper of the audit log (without updating the manifest
+ // checksum) is caught by the file_checksums loop.
+ std::fs::write(
+ bundle_dir.join("audit_log.json"),
+ r#"[{"sequence":0,"event":{"WorkflowStarted":{"workflow_hash":"x","policy_hash":"y"}},"entry_hash":"z"}]"#,
+ )
+ .unwrap();
+ assert!(!verify_bundle(&bundle_dir).valid);
+ }
+
+ #[test]
+ fn redact_encrypted_bundle_is_rejected() {
+ use crate::audit::evidence::{redact_bundle, BundleRedactError};
+ let dir = tempfile::tempdir().unwrap();
+ let kek = [7u8; KEY_LEN];
+ let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-redact-enc", "enc")
+ .unwrap()
+ .with_encryption(&kek, "default")
+ .unwrap();
+ builder.add_workflow_def(r#"{"name":"x"}"#).unwrap();
+ builder.add_policy(r#"{}"#).unwrap();
+ let mut audit = AuditLog::new();
+ audit.append(AuditEvent::ApprovalGranted {
+ step_id: "s1".into(),
+ approver: "bob@example.com".into(),
+ });
+ builder.finalize(&audit).unwrap();
+ let bundle_dir = dir.path().join("run-redact-enc");
+ assert!(matches!(
+ redact_bundle(&bundle_dir, 0, None, None),
+ Err(BundleRedactError::EncryptedUnsupported)
+ ));
}
#[test]