From 4714fbb7fd7de197d16cabdc6f910d4cece7d335 Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Sat, 18 Jul 2026 17:06:38 +0300 Subject: [PATCH 1/3] feat(evidence): Rekor transparency-log anchoring (opt-in, air-gap via private Rekor) Claude-Session: https://claude.ai/code/session_01BEk2VHyMr1MrSPitnhox5o --- Cargo.lock | 1 + crates/llmvm-cli/Cargo.toml | 6 + crates/llmvm-cli/src/main.rs | 145 +++++ orchestrator/Cargo.toml | 10 + orchestrator/docs/keyless-signing.md | 100 ++++ orchestrator/src/audit/anchor.rs | 774 +++++++++++++++++++++++++++ orchestrator/src/audit/mod.rs | 1 + 7 files changed, 1037 insertions(+) create mode 100644 orchestrator/docs/keyless-signing.md create mode 100644 orchestrator/src/audit/anchor.rs diff --git a/Cargo.lock b/Cargo.lock index edfadff..43ea782 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,6 +428,7 @@ dependencies = [ "testcontainers-modules", "thiserror 2.0.18", "tokio", + "ureq", "zeroize", ] 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..d2e8ba6 100644 --- a/crates/llmvm-cli/src/main.rs +++ b/crates/llmvm-cli/src/main.rs @@ -982,6 +982,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 @@ -4181,6 +4218,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 +4325,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/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/mod.rs b/orchestrator/src/audit/mod.rs index 61fe3d3..90625a0 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; From fd7b22f1d519efb18aa1f561850c98225d487fc8 Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Sat, 18 Jul 2026 17:13:39 +0300 Subject: [PATCH 2/3] feat(evidence): verifiable redaction via content-commitment audit chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit log (format 1.1) now commits each entry to a content hash: content_sha256 = SHA-256(event_json) entry_hash = SHA-256(sequence_le || prev_hash || content_sha256) The chain links via the commitment, so an event can be redacted in place while entry_hash, prev_hash links, and audit_log_hash stay identical. Legacy (1.0) logs still verify under the original raw-event formula (per-entry detection via presence of content_sha256). AuditLog::redact_entry blanks PII string leaves (whole event or one field) to [REDACTED], preserving the commitment and stamping a Redaction marker; verify() validates the commitment for redacted entries and the live event otherwise. redact_bundle() rewrites audit_log.json, updates its checksum, recomputes bundle_hash, and drops the now-stale signature — audit_log_hash is invariant, which is how a verifier distinguishes redaction from tamper. New CLI: boruna evidence redact --event [--field ] [--reason]. Claude-Session: https://claude.ai/code/session_01BEk2VHyMr1MrSPitnhox5o --- crates/llmvm-cli/src/main.rs | 66 +++ orchestrator/docs/verifiable-redaction.md | 151 +++++++ orchestrator/src/audit/evidence.rs | 178 +++++++- orchestrator/src/audit/log.rs | 471 +++++++++++++++++++++- orchestrator/src/audit/mod.rs | 9 +- orchestrator/src/audit/verify.rs | 216 ++++++++-- 6 files changed, 1041 insertions(+), 50 deletions(-) create mode 100644 orchestrator/docs/verifiable-redaction.md diff --git a/crates/llmvm-cli/src/main.rs b/crates/llmvm-cli/src/main.rs index d2e8ba6..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. @@ -3965,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 { @@ -4202,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, 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/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 90625a0..f62526d 100644 --- a/orchestrator/src/audit/mod.rs +++ b/orchestrator/src/audit/mod.rs @@ -34,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] From a0c484ae5e442cf18f63b01ff68e7c2dd33145be Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Sat, 18 Jul 2026 18:03:18 +0300 Subject: [PATCH 3/3] chore(release): v3.2.0 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- 3 files changed, 41 insertions(+), 13 deletions(-) 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 43ea782..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", @@ -434,7 +434,7 @@ dependencies = [ [[package]] name = "boruna-pkg" -version = "3.1.0" +version = "3.2.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -447,7 +447,7 @@ dependencies = [ [[package]] name = "boruna-tooling" -version = "3.1.0" +version = "3.2.0" dependencies = [ "boruna-bytecode", "boruna-compiler", @@ -464,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]