diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e9c4db0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + # Choke-point guard (impl spec C5 / design F-10): production server & CLI code + # must execute actions through Router::exec, never by calling an adapter's + # exec() directly — otherwise auditing can be bypassed. Tests are exempt. + - name: Choke-point guard (no direct adapter exec) + run: | + # Catch `.exec(`, `. exec (`, and `Adapter::exec`; allow only `router.exec`. + hits=$(grep -rnE '\.[[:space:]]*exec[[:space:]]*\(|Adapter::exec' \ + crates/server/src crates/cli/src \ + | grep -vE 'router[[:space:]]*\.[[:space:]]*exec' || true) + if [ -n "$hits" ]; then + echo "::error::Direct adapter exec() found outside the Router::exec choke point:" + echo "$hits" + exit 1 + fi + + - name: Build (default) + run: cargo build --workspace + + - name: Build (audit feature) + run: cargo build -p relais-core --features audit + + - name: Clippy (relais-core, default) + run: cargo clippy -p relais-core --all-targets -- -D warnings + + - name: Clippy (relais-core, audit feature) + run: cargo clippy -p relais-core --features audit --all-targets -- -D warnings + + - name: Test (default workspace) + run: cargo test --workspace + + - name: Test (audit feature) + run: cargo test -p relais-core --features audit diff --git a/Cargo.lock b/Cargo.lock index b8ef618..b9193dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -407,6 +419,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -419,6 +437,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -484,6 +511,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chromiumoxide" version = "0.7.0" @@ -574,6 +625,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -631,6 +683,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.1" @@ -720,6 +778,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -744,6 +829,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -767,6 +862,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -807,6 +903,31 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -920,6 +1041,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1210,6 +1337,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.12" @@ -1572,6 +1705,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-canon" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ae153a2bd47d61acc0d131295408e32ef87ed9785825a6f4ecef85afc0edb" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -1946,6 +2090,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -1991,6 +2146,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2011,6 +2176,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2093,9 +2269,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2279,7 +2455,7 @@ dependencies = [ "clap", "dirs", "open", - "rand 0.8.5", + "rand 0.8.6", "relais-adapter-github", "relais-adapter-hackernews", "relais-adapter-scs", @@ -2303,16 +2479,22 @@ dependencies = [ "aes-gcm", "anyhow", "async-trait", + "base64", "chrono", - "rand 0.8.5", + "ed25519-dalek", + "hex", + "json-canon", + "rand 0.8.6", "reqwest", "serde", "serde_json", "sha2", + "signet-core", "sled", "tempfile", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] @@ -2430,6 +2612,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -2501,6 +2692,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "schannel" version = "0.1.28" @@ -2611,6 +2808,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2658,6 +2868,39 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "signet-core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ffff632cdc05aff47875851a7e9067ef9a1f8264004a9eeb6bbfc184547411" +dependencies = [ + "argon2", + "base64", + "chacha20poly1305", + "chrono", + "dirs", + "ed25519-dalek", + "fs2", + "hex", + "json-canon", + "rand 0.8.6", + "regex", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "thiserror 2.0.18", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -2708,6 +2951,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3080,7 +3333,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.5", + "rand 0.8.6", "sha1", "thiserror 1.0.69", "utf-8", @@ -3144,6 +3397,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/adapters/github/src/lib.rs b/crates/adapters/github/src/lib.rs index 5164c32..4afb7dd 100644 --- a/crates/adapters/github/src/lib.rs +++ b/crates/adapters/github/src/lib.rs @@ -111,6 +111,7 @@ impl Adapter for GitHubAdapter { pagination: None, rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/crates/adapters/hackernews/src/lib.rs b/crates/adapters/hackernews/src/lib.rs index 2467500..b3f36d6 100644 --- a/crates/adapters/hackernews/src/lib.rs +++ b/crates/adapters/hackernews/src/lib.rs @@ -58,6 +58,7 @@ impl HackerNewsAdapter { }), rate_limit: None, cached: false, + receipt: None, }, }) } @@ -174,6 +175,7 @@ impl Adapter for HackerNewsAdapter { pagination: None, rate_limit: None, cached: false, + receipt: None, }, }) } @@ -213,6 +215,7 @@ impl Adapter for HackerNewsAdapter { }), rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/crates/adapters/scs-legacy/src/lib.rs b/crates/adapters/scs-legacy/src/lib.rs index a3f8efe..e933b0d 100644 --- a/crates/adapters/scs-legacy/src/lib.rs +++ b/crates/adapters/scs-legacy/src/lib.rs @@ -174,6 +174,7 @@ impl Adapter for ScsLegacyAdapter { pagination: None, rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/crates/adapters/scs/src/lib.rs b/crates/adapters/scs/src/lib.rs index 6aec470..e127625 100644 --- a/crates/adapters/scs/src/lib.rs +++ b/crates/adapters/scs/src/lib.rs @@ -140,6 +140,7 @@ impl Adapter for ScsAdapter { pagination, rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 031c97d..4585c5d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -6,6 +6,11 @@ license.workspace = true description = "Command-line interface for the relais API gateway (installs the `relais` binary)." repository.workspace = true +[features] +default = [] +# Enables cryptographic call auditing (signet) and the `relais audit` commands. +audit = ["relais-core/audit", "relais-server/audit"] + [lib] name = "relais_cli" path = "src/lib.rs" diff --git a/crates/cli/src/commands/audit.rs b/crates/cli/src/commands/audit.rs new file mode 100644 index 0000000..d2da58e --- /dev/null +++ b/crates/cli/src/commands/audit.rs @@ -0,0 +1,81 @@ +//! `relais audit {init,pubkey,verify,tail}` (C7). +//! +//! Real implementations require the `audit` feature; without it the command returns +//! a clear "rebuild with --features audit" error so the CLI surface is stable. + +use crate::AuditAction; +use anyhow::Result; + +#[cfg(not(feature = "audit"))] +pub async fn run(_action: AuditAction) -> Result<()> { + anyhow::bail!( + "this build of relais was compiled without the `audit` feature; \ + rebuild with `cargo install relais-cli --features audit` (or `--features audit`)" + ) +} + +#[cfg(feature = "audit")] +pub async fn run(action: AuditAction) -> Result<()> { + use relais_core::audit::key::AuditKey; + use relais_core::audit::verify::{audit_verify, tail, TrustAnchor}; + + let dir = super::audit_dir()?; + + match action { + AuditAction::Init { owner } => { + let owner = owner + .or_else(|| std::env::var("RELAIS_AUDIT_OWNER").ok()) + .unwrap_or_else(|| "relais".into()); + let key = AuditKey::load_or_init(&dir, &owner, None) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + println!("audit key ready under {}", dir.join("keys").display()); + println!("owner: {owner}"); + println!("pubkey: {}", key.pubkey); + println!( + "\nTo verify elsewhere, add this pubkey to {}", + dir.join("trusted_keys.json").display() + ); + Ok(()) + } + AuditAction::Pubkey => { + let key = AuditKey::load_or_init(&dir, "relais", None) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + println!("{}", key.pubkey); + Ok(()) + } + AuditAction::Verify { head } => { + let anchor = TrustAnchor::load(&dir).map_err(|e| anyhow::anyhow!(e.to_string()))?; + let report = audit_verify(&dir, &anchor, head.as_deref()) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + println!("records: {}", report.records); + println!("chain: {}", if report.chain_ok { "ok" } else { "BROKEN" }); + if let Some(h) = &report.head { + println!("head: {h}"); + } + if report.ok() { + println!("result: OK — audit chain verified"); + Ok(()) + } else { + if !report.chain_ok { + eprintln!("FAIL: hash chain is broken"); + } + for f in &report.failures { + eprintln!("FAIL: {f}"); + } + let issues = report.failures.len() + usize::from(!report.chain_ok); + anyhow::bail!("audit verification failed ({issues} issue(s))") + } + } + AuditAction::Tail { site, since, limit } => { + let entries = tail(&dir, site.as_deref(), since.as_deref(), limit) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + if entries.is_empty() { + println!("(no audit records)"); + } + for e in entries { + println!("{} {} {} signer={}", e.ts, e.id, e.tool, e.signer); + } + Ok(()) + } + } +} diff --git a/crates/cli/src/commands/exec.rs b/crates/cli/src/commands/exec.rs index cf37d26..cfac1ff 100644 --- a/crates/cli/src/commands/exec.rs +++ b/crates/cli/src/commands/exec.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Result}; use relais_core::types::{Credentials, ExecContext}; use serde_json::Value; -use super::{build_router, open_vault}; +use super::{build_exec_router, open_vault}; pub async fn run(path: &str, data: Option<&str>) -> Result<()> { let parts: Vec<&str> = path.splitn(3, '.').collect(); @@ -60,10 +60,7 @@ pub async fn run(path: &str, data: Option<&str>) -> Result<()> { } } - let router = build_router(); - let adapter = router - .get(site_id) - .ok_or_else(|| anyhow::anyhow!("site '{site_id}' not found"))?; + let router = build_exec_router()?; let ctx = ExecContext { site: site_id.to_string(), @@ -73,7 +70,8 @@ pub async fn run(path: &str, data: Option<&str>) -> Result<()> { credentials, }; - let response = adapter.exec(&ctx).await?; + // Route through the gateway choke point (auditing covers this path when enabled). + let response = router.exec(&ctx).await?; let json = serde_json::to_string_pretty(&response)?; println!("{json}"); Ok(()) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 3a44b11..6dc9a07 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod apis; +pub mod audit; pub mod auth; pub mod exec; pub mod serve; @@ -8,6 +9,74 @@ pub mod vault; use relais_core::router::Router; +/// The signet/audit directory: `RELAIS_SIGNET_DIR` or `~/.relais/signet`. +#[cfg(feature = "audit")] +pub fn audit_dir() -> anyhow::Result { + let dir = if let Ok(d) = std::env::var("RELAIS_SIGNET_DIR") { + std::path::PathBuf::from(d) + } else { + dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("could not find home directory"))? + .join(".relais") + .join("signet") + }; + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Attach an audit sink to a router from the environment, if auditing is enabled. +/// +/// Off unless `RELAIS_AUDIT_MODE` is `open` or `closed`. In **closed** mode a +/// sink-init failure is **fatal** (we must not run unaudited when the operator asked +/// for "no result without a receipt"); in **open** mode it logs and runs unaudited. +/// Must be called within a tokio runtime (the writer task needs one). +#[cfg(feature = "audit")] +fn attach_audit(router: Router) -> anyhow::Result { + use relais_core::audit::{AuditConfig, AuditMode, AuditSink}; + + let mode = match std::env::var("RELAIS_AUDIT_MODE") { + Err(_) => return Ok(router), // auditing disabled + Ok(m) => match m.as_str() { + "open" => AuditMode::Open, + "closed" => AuditMode::Closed, + other => { + tracing::warn!("ignoring RELAIS_AUDIT_MODE={other} (expected 'open' or 'closed')"); + return Ok(router); + } + }, + }; + let dir = audit_dir()?; + let owner = std::env::var("RELAIS_AUDIT_OWNER").unwrap_or_else(|_| "relais".into()); + match AuditSink::new(AuditConfig { + dir, + owner, + mode, + capacity: 1024, + ack_timeout: std::time::Duration::from_secs(5), + }) { + Ok(sink) => Ok(router.with_audit(sink)), + Err(e) => match mode { + AuditMode::Closed => Err(anyhow::anyhow!( + "audit sink init failed in closed mode (refusing to run unaudited): {e}" + )), + AuditMode::Open => { + tracing::error!(error = %e, "audit sink init failed; running unaudited (open mode)"); + Ok(router) + } + }, + } +} + +/// Build the router used for **action execution** (`exec`/`serve`): all adapters plus +/// the audit sink when enabled. Introspection commands use [`build_router`] instead +/// (they never execute actions, so auditing does not apply). +pub fn build_exec_router() -> anyhow::Result { + let router = build_router(); + #[cfg(feature = "audit")] + let router = attach_audit(router)?; + Ok(router) +} + /// Open the vault from the default location (~/.relais/vault/). /// /// Reads the master password from `RELAIS_VAULT_PASSWORD` or falls back to a diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs index 2bf6879..d97ce37 100644 --- a/crates/cli/src/commands/serve.rs +++ b/crates/cli/src/commands/serve.rs @@ -4,10 +4,10 @@ use anyhow::Result; use relais_server::state::SharedState; use tokio::net::TcpListener; -use super::{build_router, open_vault}; +use super::{build_exec_router, open_vault}; pub async fn run(port: u16, jwt_secret: String) -> Result<()> { - let router = build_router(); + let router = build_exec_router()?; // Open vault if available; don't fail if vault is inaccessible. let vault = open_vault().ok(); diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 8cef355..37bbccb 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -50,6 +50,41 @@ pub enum Commands { #[command(subcommand)] action: AuthAction, }, + /// Cryptographic call auditing (signet): keys, verification, log tail + Audit { + #[command(subcommand)] + action: AuditAction, + }, +} + +#[derive(Subcommand)] +pub enum AuditAction { + /// Generate (or load) the gateway signing key and print its public key + Init { + /// Owner recorded in receipts (defaults to RELAIS_AUDIT_OWNER, then "relais") + #[arg(long)] + owner: Option, + }, + /// Print the gateway public key (`ed25519:`) + Pubkey, + /// Verify the audit chain against the trusted-key anchor (fail-closed) + Verify { + /// Expected chain head (record_hash) retained out-of-band; detects tail truncation + #[arg(long)] + head: Option, + }, + /// List recent audit records + Tail { + /// Only records for this site (matches the `site.` tool prefix) + #[arg(long)] + site: Option, + /// Only records at or after this RFC 3339 timestamp + #[arg(long)] + since: Option, + /// Maximum number of records to show + #[arg(long)] + limit: Option, + }, } #[derive(Subcommand)] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a32ec33..001b144 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -23,6 +23,7 @@ async fn main() -> anyhow::Result<()> { } Commands::Vault { action } => relais_cli::commands::vault::run(&action)?, Commands::Auth { action } => relais_cli::commands::auth::run(action).await?, + Commands::Audit { action } => relais_cli::commands::audit::run(action).await?, } Ok(()) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 02cb514..8a62660 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -6,6 +6,12 @@ license.workspace = true description = "Core traits, types, and credential vault for the relais API gateway." repository.workspace = true +[features] +default = [] +# Cryptographic call auditing via signet (opt-in). sha2 is already a core dep below +# (used by vault.rs) and is reused for hash recompute — not re-listed as optional. +audit = ["dep:signet-core", "dep:ed25519-dalek", "dep:json-canon", "dep:hex", "dep:base64", "dep:tokio", "dep:tracing"] + [dependencies] serde.workspace = true serde_json.workspace = true @@ -19,6 +25,15 @@ aes-gcm = "0.10" sha2 = "0.10" rand = "0.8" +# audit feature (signet integration) +signet-core = { version = "0.10", optional = true } +ed25519-dalek = { version = "2", optional = true } +json-canon = { version = "0.1", optional = true } +hex = { version = "0.4", optional = true } +base64 = { version = "0.22", optional = true } +tokio = { workspace = true, optional = true } +tracing = { version = "0.1", optional = true } + [dev-dependencies] tempfile = "3" tokio.workspace = true diff --git a/crates/core/src/audit/envelope.rs b/crates/core/src/audit/envelope.rs new file mode 100644 index 0000000..8cbb0e2 --- /dev/null +++ b/crates/core/src/audit/envelope.rs @@ -0,0 +1,257 @@ +//! Mapping from relais `ExecContext`/`Response` to the signet [`Action`] + +//! response-content envelope that the writer signs (C2). +//! +//! The redacted request value becomes **both** `Action.params` and the sidecar's +//! `request`; the response envelope becomes **both** the `sign_compound` +//! `response_content` (hashed) and the sidecar's `response`. Keeping them identical +//! is what lets `relais audit verify` recompute the hashes (C6). + +use serde_json::{json, Map, Value}; +use signet_core::Action; + +use super::redact::{AuditMeta, Redactor}; +use crate::error::AdapterError; +use crate::types::{ExecContext, Response}; + +/// Build the redacted request envelope: `redact(ctx.params)` with `_relais_audit` +/// metadata attached. If `ctx.params` is not a JSON object it is nested under a +/// `"params"` key so the audit metadata always has a home. +pub fn build_request( + ctx: &ExecContext, + meta: &AuditMeta, + redactor: &Redactor, + secrets: &[String], +) -> Value { + let redacted = redactor.redact_value(&ctx.params, secrets); + let mut obj = match redacted { + // Caller already uses `_relais_audit` → nest its params so our metadata is + // unambiguous and nothing is silently overwritten. + Value::Object(m) if m.contains_key("_relais_audit") => { + let mut wrap = Map::new(); + wrap.insert("params".to_string(), Value::Object(m)); + wrap + } + Value::Object(m) => m, + other => { + let mut m = Map::new(); + m.insert("params".to_string(), other); + m + } + }; + obj.insert("_relais_audit".to_string(), meta.to_json()); + Value::Object(obj) +} + +/// Build the response-content envelope that `sign_compound` hashes. The success/ +/// failure outcome lives here (signet's `sign_compound` leaves `Response.outcome` +/// as `None`). `transport_ok` is explicitly transport-level — a business error +/// carried in a 2xx body is still `transport_ok: true`, with the body in `data`. +pub fn build_response_envelope( + result: &Result, + redactor: &Redactor, + secrets: &[String], +) -> Value { + match result { + Ok(resp) => json!({ + "transport_ok": true, + "data": redactor.redact_value(&resp.data, secrets), + "business_status": "unclassified", + }), + Err(e) => json!({ + "transport_ok": false, + "error": { + "kind": error_kind(e), + // redact: error text can echo a credential (BLOCKER) + "message": redactor.redact_str(&e.to_string(), secrets), + }, + }), + } +} + +/// Construct the signet [`Action`]. `params_hash` is left empty — `sign_compound` +/// computes it. `target` is exactly the site's `base_url` (the site id is already in +/// `tool`); the resolved endpoint path lives in adapter code and is not attested. +pub fn build_action( + ctx: &ExecContext, + request: Value, + base_url: &str, + session: Option, + trace_id: String, + call_id: String, +) -> Action { + Action { + tool: format!("{}.{}.{}", ctx.site, ctx.resource, ctx.action), + params: request, + params_hash: String::new(), + target: base_url.to_string(), + transport: "https".to_string(), + session, + call_id: Some(call_id), + response_hash: None, + trace_id: Some(trace_id), + parent_receipt_id: None, + } +} + +/// Stable short identifier for an `AdapterError` variant (the `kind` in a failure +/// envelope). +fn error_kind(e: &AdapterError) -> &'static str { + match e { + AdapterError::Auth(_) => "auth", + AdapterError::RateLimited { .. } => "rate_limited", + AdapterError::NotFound(_) => "not_found", + AdapterError::Unsupported(_) => "unsupported", + AdapterError::SiteNotFound(_) => "site_not_found", + AdapterError::AuditUnavailable(_) => "audit_unavailable", + AdapterError::Upstream(_) => "upstream", + AdapterError::Other(_) => "other", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Credentials, ResponseMeta}; + use serde_json::json; + + fn meta() -> AuditMeta { + AuditMeta { + auth_injection: "acs_token->query".into(), + credential_ref: "kref_test".into(), + t0: "2026-06-21T00:00:00Z".into(), + t1: "2026-06-21T00:00:01Z".into(), + } + } + + fn ctx_with_token(tok: &str) -> ExecContext { + ExecContext { + site: "scs".into(), + resource: "order".into(), + action: "create".into(), + params: json!({ "customer_id": "42", "note": tok }), + credentials: Some(Credentials::api_key(tok)), + } + } + + #[test] + fn request_envelope_has_audit_meta_and_redacts() { + let r = Redactor::new(); + let ctx = ctx_with_token("TOK123"); + let secrets = super::super::redact::secret_values_of(&ctx.credentials); + let req = build_request(&ctx, &meta(), &r, &secrets); + assert_eq!(req["customer_id"], json!("42")); + // token echoed in a non-sensitive key is masked by value + assert_eq!(req["note"], json!(super::super::redact::REDACTED)); + assert_eq!( + req["_relais_audit"]["auth_injection"], + json!("acs_token->query") + ); + assert_eq!(req["_relais_audit"]["t0"], json!("2026-06-21T00:00:00Z")); + } + + #[test] + fn build_action_uses_real_signet_fields() { + let r = Redactor::new(); + let ctx = ctx_with_token("TOK123"); + let secrets = super::super::redact::secret_values_of(&ctx.credentials); + let req = build_request(&ctx, &meta(), &r, &secrets); + let a = build_action( + &ctx, + req, + "https://api.example", + Some("sub".into()), + "trace".into(), + "call".into(), + ); + assert_eq!(a.tool, "scs.order.create"); + assert_eq!(a.target, "https://api.example"); + assert_eq!(a.transport, "https"); + assert_eq!(a.session.as_deref(), Some("sub")); + assert_eq!(a.call_id.as_deref(), Some("call")); + assert_eq!(a.trace_id.as_deref(), Some("trace")); + assert!(a.parent_receipt_id.is_none()); + assert!(a.params_hash.is_empty()); + } + + #[test] + fn response_envelope_ok_and_err() { + let r = Redactor::new(); + let ok: Result = Ok(Response { + data: json!({ "x": 1 }), + meta: ResponseMeta::default(), + }); + let env = build_response_envelope(&ok, &r, &[]); + assert_eq!(env["transport_ok"], json!(true)); + assert_eq!(env["data"]["x"], json!(1)); + + let err: Result = Err(AdapterError::NotFound("nope".into())); + let env = build_response_envelope(&err, &r, &[]); + assert_eq!(env["transport_ok"], json!(false)); + assert_eq!(env["error"]["kind"], json!("not_found")); + } + + #[test] + fn error_message_is_redacted() { + let r = Redactor::new(); + let tok = "ERRTOKEN_xyz"; + let err: Result = + Err(AdapterError::Auth(format!("bad token {tok}"))); + let env = build_response_envelope(&err, &r, &[tok.to_string()]); + let s = serde_json::to_string(&env).unwrap(); + assert!(!s.contains(tok), "secret leaked via error message: {s}"); + assert_eq!(env["error"]["kind"], json!("auth")); + } + + #[test] + fn relais_audit_key_collision_is_nested_not_overwritten() { + let r = Redactor::new(); + let ctx = ExecContext { + site: "s".into(), + resource: "r".into(), + action: "a".into(), + params: json!({ "_relais_audit": "caller-value", "x": 1 }), + credentials: None, + }; + let req = build_request(&ctx, &meta(), &r, &[]); + // our metadata wins at top level; caller's data preserved under "params" + assert_eq!(req["_relais_audit"]["credential_ref"], json!("kref_test")); + assert_eq!(req["params"]["_relais_audit"], json!("caller-value")); + assert_eq!(req["params"]["x"], json!(1)); + } + + /// The leak guard: a credential token echoed in both a request param and the + /// response body must not survive into the signed Action or the response envelope. + #[test] + fn secret_leak_guard_request_and_response() { + let r = Redactor::new(); + let tok = "SUPER_SECRET_TOKEN_value"; + let ctx = ctx_with_token(tok); + let secrets = super::super::redact::secret_values_of(&ctx.credentials); + + let req = build_request(&ctx, &meta(), &r, &secrets); + let action = build_action( + &ctx, + req, + "https://api.example", + None, + "t".into(), + "c".into(), + ); + let action_json = serde_json::to_string(&action).unwrap(); + assert!( + !action_json.contains(tok), + "token leaked into signed Action" + ); + + let resp: Result = Ok(Response { + data: json!({ "login": { "acs_token": tok }, "echo": tok }), + meta: ResponseMeta::default(), + }); + let env = build_response_envelope(&resp, &r, &secrets); + let env_json = serde_json::to_string(&env).unwrap(); + assert!( + !env_json.contains(tok), + "token leaked into response envelope" + ); + } +} diff --git a/crates/core/src/audit/key.rs b/crates/core/src/audit/key.rs new file mode 100644 index 0000000..e3042bb --- /dev/null +++ b/crates/core/src/audit/key.rs @@ -0,0 +1,227 @@ +//! Gateway signing key + opaque credential-reference store (C3). +//! +//! The key lives under `dir/keys/relais.{key,pub}` via signet's `fs_ops` (so the +//! on-disk format and pubkey string match what verifiers expect). Loading or +//! generating the key does **not** trust it for verification — trust is an explicit, +//! out-of-band step (C6 `trusted_keys.json`). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use ed25519_dalek::SigningKey; +use serde::{Deserialize, Serialize}; +use signet_core::identity::fs_ops::{generate_and_save, load_signing_key}; +use signet_core::SignetError; + +use super::AuditError; + +/// The fixed key name relais uses within the signet dir. +pub const KEY_NAME: &str = "relais"; + +impl From for AuditError { + fn from(e: SignetError) -> Self { + AuditError::Signet(e.to_string()) + } +} + +/// The gateway's Ed25519 signing key and its public identity. +pub struct AuditKey { + signing: SigningKey, + /// Signet-formatted public key, e.g. `ed25519:` — matches `signer.pubkey` + /// in receipts so the C6 trust anchor can compare directly. + pub pubkey: String, + pub owner: String, +} + +impl AuditKey { + /// Load `dir/keys/relais.key`, or generate it on first use. + /// + /// Generation does not imply trust (NF-4): verification still requires an + /// out-of-band trust anchor. + /// + /// **v1 key-at-rest:** with `passphrase = None` (the default) signet stores the + /// signing seed **unencrypted** on disk (protected only by file perms). Pass a + /// passphrase to encrypt it (Argon2id + XChaCha20-Poly1305). Encrypting the + /// audit log at rest is otherwise a deployment concern (design §4.3.5). + pub fn load_or_init( + dir: &Path, + owner: &str, + passphrase: Option<&str>, + ) -> Result { + match load_signing_key(dir, KEY_NAME, passphrase) { + Ok(signing) => { + let pubkey = pubkey_string(&signing); + Ok(Self { + signing, + pubkey, + owner: owner.to_string(), + }) + } + Err(SignetError::KeyNotFound(_)) => { + generate_and_save(dir, KEY_NAME, Some(owner), passphrase, None)?; + let signing = load_signing_key(dir, KEY_NAME, passphrase)?; + let pubkey = pubkey_string(&signing); + Ok(Self { + signing, + pubkey, + owner: owner.to_string(), + }) + } + Err(e) => Err(AuditError::from(e)), + } + } + + pub fn signing(&self) -> &SigningKey { + &self.signing + } +} + +/// What an opaque `credential_ref` resolves to, locally. This map is **never** +/// serialized into a receipt/sidecar and is omitted from exports. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredBinding { + pub site: String, +} + +/// Persists `kref_… -> CredBinding` at `dir/credential_refs.json`. Minting is +/// idempotent per binding, so a credential keeps one stable opaque ref. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CredRefStore { + #[serde(skip)] + path: PathBuf, + map: HashMap, +} + +impl CredRefStore { + /// Load the store, or start empty if the file does not exist. + pub fn load(dir: &Path) -> Result { + let path = dir.join("credential_refs.json"); + let mut store = if path.exists() { + let json = std::fs::read_to_string(&path).map_err(|e| AuditError::Io(e.to_string()))?; + serde_json::from_str::(&json) + .map_err(|e| AuditError::Io(format!("credential_refs.json: {e}")))? + } else { + CredRefStore::default() + }; + store.path = path; + Ok(store) + } + + /// Return the existing opaque ref for `binding`, or mint, persist and return a + /// new one. Idempotent per binding. + pub fn mint(&mut self, binding: CredBinding) -> Result { + if let Some((kref, _)) = self.map.iter().find(|(_, b)| **b == binding) { + return Ok(kref.clone()); + } + let kref = new_ref(); + self.map.insert(kref.clone(), binding); + self.persist()?; + Ok(kref) + } + + fn persist(&self) -> Result<(), AuditError> { + if self.path.as_os_str().is_empty() { + return Ok(()); + } + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AuditError::Io(e.to_string()))?; + } + let json = serde_json::to_string_pretty(self).map_err(|e| AuditError::Io(e.to_string()))?; + std::fs::write(&self.path, json).map_err(|e| AuditError::Io(e.to_string()))?; + // ref → vault-binding map: restrict to the owner (0600), since default umask + // would commonly leave it world-readable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| AuditError::Io(e.to_string()))?; + } + Ok(()) + } +} + +/// Derive the receipt-form public key `ed25519:` **directly from +/// the loaded signing key** (not from the `.pub` file, which signet does not +/// validate against the secret key). This is byte-identical to signet's +/// `format_pubkey`, so it matches `signer.pubkey` in receipts and the C6 trust +/// anchor. +fn pubkey_string(signing: &SigningKey) -> String { + use base64::Engine; + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().as_bytes()) + ) +} + +/// A random, non-reversible opaque handle: `kref_<16 hex>`. +fn new_ref() -> String { + use rand::RngCore; + let mut bytes = [0u8; 8]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("kref_{}", hex::encode(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_or_init_creates_then_reuses() { + let dir = tempfile::tempdir().unwrap(); + let k1 = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + assert!(dir.path().join("keys").join("relais.key").exists()); + assert!(dir.path().join("keys").join("relais.pub").exists()); + assert!(k1.pubkey.starts_with("ed25519:")); + + let k2 = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + assert_eq!(k1.pubkey, k2.pubkey, "reload must reuse the same key"); + assert_eq!(k1.signing().to_bytes(), k2.signing().to_bytes()); + } + + #[test] + fn mint_is_idempotent_per_binding_and_persists() { + let dir = tempfile::tempdir().unwrap(); + let mut store = CredRefStore::load(dir.path()).unwrap(); + let a1 = store.mint(CredBinding { site: "scs".into() }).unwrap(); + let a2 = store.mint(CredBinding { site: "scs".into() }).unwrap(); + let b = store + .mint(CredBinding { + site: "github".into(), + }) + .unwrap(); + assert_eq!(a1, a2, "same binding → same ref"); + assert_ne!(a1, b, "different binding → different ref"); + assert!(a1.starts_with("kref_")); + + // reload from disk sees the persisted refs + let reloaded = CredRefStore::load(dir.path()).unwrap(); + assert_eq!( + reloaded.map.get(&a1), + Some(&CredBinding { site: "scs".into() }) + ); + } + + #[cfg(unix)] + #[test] + fn credential_refs_file_is_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let mut store = CredRefStore::load(dir.path()).unwrap(); + store.mint(CredBinding { site: "scs".into() }).unwrap(); + let mode = std::fs::metadata(dir.path().join("credential_refs.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "credential_refs.json must be owner-only"); + } + + #[test] + fn cred_ref_map_not_in_default_serialization_path() { + // The store serializes the map (for its own file) but the path field is + // skipped; receipts/sidecars never embed a CredRefStore. + let store = CredRefStore::default(); + let json = serde_json::to_string(&store).unwrap(); + assert!(!json.contains("path")); + } +} diff --git a/crates/core/src/audit/mod.rs b/crates/core/src/audit/mod.rs new file mode 100644 index 0000000..8dd5c6d --- /dev/null +++ b/crates/core/src/audit/mod.rs @@ -0,0 +1,210 @@ +//! Cryptographic call auditing via [signet](https://github.com/Prismer-AI/signet). +//! +//! Behind the `audit` feature. Every relais `exec` flows through `Router::exec` +//! (added in C5), which (when a sink is configured) emits one signed, hash-chained +//! signet receipt per gateway action. See +//! `docs/design/signet-audit-integration.md` (design) and +//! `docs/design/signet-audit-impl.md` (implementation plan). +//! +//! This module is a skeleton landed in C1; the redaction/envelope/key/writer/verify +//! pieces arrive in C2–C6. + +pub mod envelope; +pub mod key; +pub mod redact; +pub mod sidecar; +pub mod verify; +pub mod writer; + +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use thiserror::Error; +use tokio::sync::oneshot; +use tokio::time::timeout; + +use crate::error::AdapterError; +use crate::types::{AuthType, Credentials, ExecContext, ReceiptHandle, Response}; +use envelope::{build_action, build_request, build_response_envelope}; +use key::{AuditKey, CredBinding, CredRefStore}; +use redact::{secret_values_of, AuditMeta, Redactor}; +use writer::{spawn_writer, AuditJob, WriterHandle}; + +/// Errors raised by the audit sink. Convert into +/// [`crate::error::AdapterError::AuditUnavailable`] when surfaced to a caller. +#[derive(Debug, Error)] +pub enum AuditError { + #[error("audit io: {0}")] + Io(String), + #[error("signet: {0}")] + Signet(String), + #[error("audit config: {0}")] + Config(String), + #[error("audit unavailable: {0}")] + Unavailable(String), +} + +impl From for crate::error::AdapterError { + fn from(e: AuditError) -> Self { + crate::error::AdapterError::AuditUnavailable(e.to_string()) + } +} + +/// Behaviour when a receipt cannot be committed. +/// +/// * `Open` — deliver the result anyway; log + metric on sink failure. +/// * `Closed` — withhold the response (return an error) if the receipt is not +/// committed. ("The caller gets no result without a receipt.") +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditMode { + Open, + Closed, +} + +/// Sink configuration. `dir` defaults to `RELAIS_SIGNET_DIR` or `~/.relais/signet`. +#[derive(Debug, Clone)] +pub struct AuditConfig { + pub dir: PathBuf, + pub owner: String, + pub mode: AuditMode, + pub capacity: usize, + pub ack_timeout: Duration, +} + +/// The audit sink: owns the single writer task, the opaque credential-ref store and +/// the redactor. Constructed once and held by the [`crate::router::Router`]. +pub struct AuditSink { + writer: WriterHandle, + credrefs: Mutex, + redactor: Redactor, + mode: AuditMode, + ack_timeout: Duration, +} + +impl AuditSink { + /// Build a sink: load-or-init the gateway key, load the credential-ref store and + /// spawn the single writer task. Must be called within a tokio runtime. + pub fn new(cfg: AuditConfig) -> Result { + let key = AuditKey::load_or_init(&cfg.dir, &cfg.owner, None)?; + let credrefs = CredRefStore::load(&cfg.dir)?; + let writer = spawn_writer(cfg.dir.clone(), key, cfg.capacity); + Ok(Self { + writer, + credrefs: Mutex::new(credrefs), + redactor: Redactor::new(), + mode: cfg.mode, + ack_timeout: cfg.ack_timeout, + }) + } + + /// Whether the sink withholds responses on audit failure (response-closed). + pub fn closed(&self) -> bool { + self.mode == AuditMode::Closed + } + + /// Record one gateway action. Returns the receipt handle in closed mode (after + /// the chain append is acknowledged), or `None` in open mode (fire-and-forget). + /// + /// `base_url` is passed in because `ExecContext` does not carry it (it lives on + /// the adapter's `SiteManifest`). + pub async fn record( + &self, + ctx: &ExecContext, + base_url: &str, + result: &Result, + t0: DateTime, + t1: DateTime, + ) -> Result, AuditError> { + // Mint the opaque credential ref in a tight sync scope; never hold the guard + // across an await. + let credential_ref = { + let mut store = self + .credrefs + .lock() + .map_err(|_| AuditError::Io("credential-ref store lock poisoned".into()))?; + store.mint(CredBinding { + site: ctx.site.clone(), + })? + }; + + let secrets = secret_values_of(&ctx.credentials); + let meta = AuditMeta { + auth_injection: describe_injection(&ctx.credentials), + credential_ref, + t0: t0.to_rfc3339(), + t1: t1.to_rfc3339(), + }; + let request = build_request(ctx, &meta, &self.redactor, &secrets); + let response_env = build_response_envelope(result, &self.redactor, &secrets); + let exec_id = new_exec_id(); + let action = build_action( + ctx, + request.clone(), + base_url, + None, + exec_id.clone(), + exec_id, + ); + + match self.mode { + AuditMode::Open => { + let (ack, _rx) = oneshot::channel(); + let job = AuditJob { + action, + response_env, + request, + t0, + t1, + ack, + }; + self.writer.try_enqueue(job)?; + Ok(None) + } + AuditMode::Closed => { + let (ack, rx) = oneshot::channel(); + let job = AuditJob { + action, + response_env, + request, + t0, + t1, + ack, + }; + self.writer.enqueue_timeout(job, self.ack_timeout).await?; + let acked = timeout(self.ack_timeout, rx) + .await + .map_err(|_| AuditError::Unavailable("audit ack timed out".into()))?; + let acked = + acked.map_err(|_| AuditError::Unavailable("audit writer dropped".into()))?; + let out = acked?; + Ok(Some(ReceiptHandle { + id: out.id, + record_hash: Some(out.record_hash), + })) + } + } + } +} + +/// Best-effort, non-secret descriptor of which credential kind was in play. The +/// adapter-specific wire injection (e.g. `acs_token->query`) is not known at the +/// router boundary (NF-8), so this is generic. +fn describe_injection(creds: &Option) -> String { + match creds.as_ref().map(|c| &c.credential_type) { + Some(AuthType::APIKey) => "apikey", + Some(AuthType::OAuth) => "oauth", + Some(AuthType::Cookie) => "cookie", + Some(AuthType::None) | None => "none", + } + .to_string() +} + +/// A random per-exec correlation id (`exec_`), 128 bits of randomness. +fn new_exec_id() -> String { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("exec_{}", hex::encode(bytes)) +} diff --git a/crates/core/src/audit/redact.rs b/crates/core/src/audit/redact.rs new file mode 100644 index 0000000..1162d97 --- /dev/null +++ b/crates/core/src/audit/redact.rs @@ -0,0 +1,328 @@ +//! Redaction of request/response payloads before they enter an audit receipt. +//! +//! Two complementary defences (C2): +//! 1. **Key-name redaction** — values under sensitive keys (`token`, `password`, +//! `*_token`, …) are masked regardless of content. +//! 2. **Secret-value redaction** — the actual credential strings (pulled from +//! [`crate::types::Credentials`]) are masked wherever they appear, under *any* +//! key or as a substring of a larger string. This is what makes the leak guard +//! hold even when an upstream echoes a token back in its response. + +use serde_json::{Map, Value}; + +use crate::types::{CredentialData, Credentials}; + +/// The placeholder written in place of redacted content. +pub const REDACTED: &str = "[REDACTED]"; + +/// Keys whose values are always masked (compared case-insensitively). +const DEFAULT_DENY_EXACT: &[&str] = &[ + "token", + "password", + "secret", + "authorization", + "api_key", + "acs_token", + "cookie", + "cookies", +]; + +/// Key suffixes whose values are always masked (e.g. `access_token`, `refresh_token`). +const DEFAULT_DENY_SUFFIX: &[&str] = &["_token"]; + +/// Non-secret descriptor + opaque credential reference attached to the signed +/// request envelope under `_relais_audit`. `t0`/`t1` are the **true** request +/// start/end (RFC 3339) — they live here, inside the signed `params`, so the +/// receipt's top-level `ts_request` can be a monotonic audit-order time without +/// losing the real request window (see C4). +#[derive(Debug, Clone)] +pub struct AuditMeta { + pub auth_injection: String, + pub credential_ref: String, + pub t0: String, + pub t1: String, +} + +impl AuditMeta { + /// Render as the `_relais_audit` JSON object embedded in the request envelope. + pub fn to_json(&self) -> Value { + serde_json::json!({ + "auth_injection": self.auth_injection, + "credential_ref": self.credential_ref, + "t0": self.t0, + "t1": self.t1, + }) + } +} + +/// Redacts JSON values by key name and by secret value. +#[derive(Debug, Clone)] +pub struct Redactor { + deny_exact: Vec, + deny_suffix: Vec, +} + +impl Default for Redactor { + fn default() -> Self { + Self { + deny_exact: DEFAULT_DENY_EXACT.iter().map(|s| s.to_string()).collect(), + deny_suffix: DEFAULT_DENY_SUFFIX.iter().map(|s| s.to_string()).collect(), + } + } +} + +impl Redactor { + pub fn new() -> Self { + Self::default() + } + + fn key_is_sensitive(&self, key: &str) -> bool { + let k = key.to_ascii_lowercase(); + self.deny_exact.iter().any(|d| d == &k) || self.deny_suffix.iter().any(|s| k.ends_with(s)) + } + + /// Returns `v` with sensitive keys masked and any occurrence of a `secret` + /// (non-empty) masked, recursively — in object **keys** and values, in string + /// leaves (substring), and in numeric/boolean leaves whose textual form is + /// exactly a secret. The result is what gets hashed and stored, so the proof is + /// honest about what was recorded. + pub fn redact_value(&self, v: &Value, secrets: &[String]) -> Value { + self.redact_inner(v, &sorted_secrets(secrets)) + } + + fn redact_inner(&self, v: &Value, secrets: &[&str]) -> Value { + match v { + Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (k, val) in map { + // mask a secret used AS a key name too (keys were skipped before). + // Disambiguate if masking collapses two keys to the same string, so + // a field is never silently dropped (data-integrity, RQ1). + let key = unique_key(&out, mask_secrets(k, secrets)); + if self.key_is_sensitive(k) { + out.insert(key, Value::String(REDACTED.to_string())); + } else { + out.insert(key, self.redact_inner(val, secrets)); + } + } + Value::Object(out) + } + Value::Array(items) => Value::Array( + items + .iter() + .map(|i| self.redact_inner(i, secrets)) + .collect(), + ), + Value::String(s) => Value::String(mask_secrets(s, secrets)), + Value::Number(_) | Value::Bool(_) => { + // a secret like "123" or "true" present as a JSON scalar (HIGH) + let text = v.to_string(); + if secrets.iter().any(|sec| text == *sec) { + Value::String(REDACTED.to_string()) + } else { + v.clone() + } + } + Value::Null => Value::Null, + } + } + + /// Mask secrets in a plain string (e.g. an error message). Used by the response + /// envelope so error text never leaks a credential. + /// + /// **Residual risk (accepted for v1, RQ3):** this is exact-substring masking. A + /// secret that a nested error (`reqwest`/`anyhow`) emits in a *transformed* form + /// — URL-encoded, base64, truncated — can survive, because v1 redaction is + /// denylist + raw-value matching, not format-aware. Tracked in the design's + /// best-effort redaction stance; format-aware redaction is out of scope for v1. + pub fn redact_str(&self, s: &str, secrets: &[String]) -> String { + mask_secrets(s, &sorted_secrets(secrets)) + } +} + +/// Return `key`, or a `key#N` variant that does not yet exist in `out`, so masking +/// two distinct keys to the same string never silently drops a field (RQ1). +fn unique_key(out: &Map, key: String) -> String { + if !out.contains_key(&key) { + return key; + } + let mut n = 2; + loop { + let candidate = format!("{key}#{n}"); + if !out.contains_key(&candidate) { + return candidate; + } + n += 1; + } +} + +/// Non-empty secrets, longest first, so an overlapping shorter secret can't leave +/// suffix material from a longer one (MEDIUM). +fn sorted_secrets(secrets: &[String]) -> Vec<&str> { + let mut v: Vec<&str> = secrets + .iter() + .map(|s| s.as_str()) + .filter(|s| !s.is_empty()) + .collect(); + v.sort_by_key(|b| std::cmp::Reverse(b.len())); + v +} + +/// Replace every occurrence of each secret substring with [`REDACTED`]. +fn mask_secrets(s: &str, secrets: &[&str]) -> String { + let mut out = s.to_string(); + for secret in secrets { + if out.contains(secret) { + out = out.replace(secret, REDACTED); + } + } + out +} + +/// The actual secret strings carried by `credentials` — matched directly against +/// **all** `CredentialData` variants (not via `bearer_token()`, which omits the +/// refresh token). +pub fn secret_values_of(creds: &Option) -> Vec { + let mut out = Vec::new(); + if let Some(c) = creds { + match &c.data { + CredentialData::ApiKey { token } => out.push(token.clone()), + CredentialData::OAuth { + access_token, + refresh_token, + .. + } => { + out.push(access_token.clone()); + if let Some(rt) = refresh_token { + out.push(rt.clone()); + } + } + CredentialData::Cookie { cookies, .. } => { + out.extend(cookies.values().cloned()); + } + } + } + out.retain(|s| !s.is_empty()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn masks_sensitive_keys_case_insensitive_and_suffix() { + let r = Redactor::new(); + let v = json!({ + "Authorization": "Bearer abc", + "API_KEY": "k", + "access_token": "t", + "customer_id": "42", + "nested": { "password": "p", "ok": "keep" } + }); + let out = r.redact_value(&v, &[]); + assert_eq!(out["Authorization"], json!(REDACTED)); + assert_eq!(out["API_KEY"], json!(REDACTED)); + assert_eq!(out["access_token"], json!(REDACTED)); + assert_eq!(out["customer_id"], json!("42")); + assert_eq!(out["nested"]["password"], json!(REDACTED)); + assert_eq!(out["nested"]["ok"], json!("keep")); + } + + #[test] + fn masks_secret_values_under_any_key_and_as_substring() { + let r = Redactor::new(); + let secrets = vec!["SUPERSECRET".to_string()]; + let v = json!({ + "echoed": "SUPERSECRET", + "in_text": "prefix SUPERSECRET suffix", + "arr": ["x", "SUPERSECRET"] + }); + let out = r.redact_value(&v, &secrets); + assert_eq!(out["echoed"], json!(REDACTED)); + assert_eq!(out["in_text"], json!(format!("prefix {REDACTED} suffix"))); + assert_eq!(out["arr"][1], json!(REDACTED)); + } + + #[test] + fn empty_secret_does_not_mask_everything() { + let r = Redactor::new(); + let out = r.redact_value(&json!({"a": "b"}), &["".to_string()]); + assert_eq!(out["a"], json!("b")); + } + + #[test] + fn masks_secret_used_as_object_key() { + let r = Redactor::new(); + let secrets = vec!["SECRETKEY".to_string()]; + let out = r.redact_value(&json!({ "SECRETKEY": "v", "ok": 1 }), &secrets); + let s = serde_json::to_string(&out).unwrap(); + assert!(!s.contains("SECRETKEY"), "secret survived as a key: {s}"); + assert_eq!(out["ok"], json!(1)); + } + + #[test] + fn masks_numeric_scalar_equal_to_secret() { + let r = Redactor::new(); + let secrets = vec!["123456".to_string()]; + let out = r.redact_value(&json!({ "pin": 123456, "qty": 2 }), &secrets); + assert_eq!(out["pin"], json!(REDACTED)); + assert_eq!(out["qty"], json!(2)); + } + + #[test] + fn longest_secret_masked_first() { + let r = Redactor::new(); + // "abc" is a prefix of "abcdef"; masking the longer first avoids leaving "def" + let secrets = vec!["abc".to_string(), "abcdef".to_string()]; + let out = r.redact_value(&json!({ "v": "abcdef" }), &secrets); + assert_eq!(out["v"], json!(REDACTED)); + } + + #[test] + fn masked_key_collision_is_disambiguated_not_dropped() { + let r = Redactor::new(); + // both keys contain the secret → both mask to the same base; keep both. + let secrets = vec!["S".to_string()]; + let out = r.redact_value(&json!({ "Sa": 1, "Sb": 2 }), &secrets); + let obj = out.as_object().unwrap(); + assert_eq!(obj.len(), 2, "a field was silently dropped: {out}"); + } + + #[test] + fn redact_str_masks_error_text() { + let r = Redactor::new(); + let masked = r.redact_str("auth failed for TOKENXYZ", &["TOKENXYZ".to_string()]); + assert!(!masked.contains("TOKENXYZ")); + } + + #[test] + fn secret_values_cover_all_variants() { + use crate::types::AuthType; + use std::collections::HashMap; + + let api = Credentials::api_key("apitok"); + assert_eq!(secret_values_of(&Some(api)), vec!["apitok".to_string()]); + + let oauth = Credentials::oauth("acc", Some("ref".to_string()), None); + let got = secret_values_of(&Some(oauth)); + assert!(got.contains(&"acc".to_string()) && got.contains(&"ref".to_string())); + + let mut cookies = HashMap::new(); + cookies.insert("session".to_string(), "cookieval".to_string()); + let cookie = Credentials { + credential_type: AuthType::Cookie, + data: CredentialData::Cookie { + cookies, + domain: "x".into(), + captured_at: chrono::Utc::now(), + expires_at: None, + }, + }; + assert_eq!( + secret_values_of(&Some(cookie)), + vec!["cookieval".to_string()] + ); + } +} diff --git a/crates/core/src/audit/sidecar.rs b/crates/core/src/audit/sidecar.rs new file mode 100644 index 0000000..ab81cf4 --- /dev/null +++ b/crates/core/src/audit/sidecar.rs @@ -0,0 +1,47 @@ +//! Sidecar preimage store (C4/C6). +//! +//! The hash chain stores only the response *commitment* (`content_hash`), so to keep +//! receipts legible and re-verifiable relais persists the exact redacted +//! `{ request, response }` envelope it hashed, keyed by receipt id, at +//! `dir/sidecars/.json`. `relais audit verify` (C6) recomputes the hash from this +//! file. Receipt ids are `rec_` (signet `derive_id`), so they are filename-safe. + +use std::path::Path; + +use serde_json::Value; + +use super::AuditError; + +/// Atomically write the sidecar for `id` (tmp + rename). +pub fn write(dir: &Path, id: &str, value: &Value) -> Result<(), AuditError> { + let scdir = dir.join("sidecars"); + std::fs::create_dir_all(&scdir).map_err(|e| AuditError::Io(e.to_string()))?; + let path = scdir.join(format!("{id}.json")); + let tmp = scdir.join(format!("{id}.json.tmp")); + let json = serde_json::to_vec(value).map_err(|e| AuditError::Io(e.to_string()))?; + std::fs::write(&tmp, json).map_err(|e| AuditError::Io(e.to_string()))?; + std::fs::rename(&tmp, &path).map_err(|e| AuditError::Io(e.to_string()))?; + Ok(()) +} + +/// Read the sidecar preimage for `id`. +pub fn read(dir: &Path, id: &str) -> Result { + let path = dir.join("sidecars").join(format!("{id}.json")); + let json = std::fs::read_to_string(&path).map_err(|e| AuditError::Io(e.to_string()))?; + serde_json::from_str(&json).map_err(|e| AuditError::Io(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn write_then_read_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let v = json!({ "request": { "a": 1 }, "response": { "transport_ok": true } }); + write(dir.path(), "rec_abc123", &v).unwrap(); + assert!(dir.path().join("sidecars").join("rec_abc123.json").exists()); + assert_eq!(read(dir.path(), "rec_abc123").unwrap(), v); + } +} diff --git a/crates/core/src/audit/verify.rs b/crates/core/src/audit/verify.rs new file mode 100644 index 0000000..035fd79 --- /dev/null +++ b/crates/core/src/audit/verify.rs @@ -0,0 +1,510 @@ +//! Audit verification (C6): chain integrity + per-record signature against a +//! trusted, time-windowed key + sidecar hash recompute. +//! +//! signet's `verify_signatures_with_options` selects records by `AuditFilter` (which +//! has no receipt-id/end-time), so it can't enforce per-receipt rotation windows. +//! Instead this verifies each receipt directly with `verify_compound`, choosing the +//! trusted key valid at that receipt's `ts_request` (design §4.11). Verification is +//! **fail-closed**: an absent or empty trust anchor is an error, never self-trust. + +use std::path::Path; + +use base64::Engine; +use chrono::{DateTime, Utc}; +use ed25519_dalek::VerifyingKey; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use signet_core::CompoundReceipt; + +use super::{sidecar, AuditError}; + +/// Operational status of a trusted key. Window membership (`not_before`/`not_after`) +/// governs verification; status is informational metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum KeyStatus { + Active, + Retired, +} + +/// One accepted gateway public key and the window it was valid for. +#[derive(Debug, Clone, Deserialize)] +pub struct TrustedKey { + /// `ed25519:` (matches `signer.pubkey` in receipts). + pub pubkey: String, + pub status: KeyStatus, + pub not_before: DateTime, + #[serde(default)] + pub not_after: Option>, +} + +/// The out-of-band trust anchor (`dir/trusted_keys.json`). +#[derive(Debug, Clone, Deserialize)] +pub struct TrustAnchor { + pub keys: Vec, +} + +impl TrustAnchor { + /// Construct directly (used by callers that hold keys in memory / tests). + pub fn new(keys: Vec) -> Self { + Self { keys } + } + + /// Load `dir/trusted_keys.json`. **Fail-closed:** a missing or empty anchor is an + /// error — relais never verifies against self-reported receipt keys (NF-4/NF-5). + pub fn load(dir: &Path) -> Result { + let path = dir.join("trusted_keys.json"); + if !path.exists() { + return Err(AuditError::Config( + "no trust anchor (trusted_keys.json); refusing to verify (fail-closed)".into(), + )); + } + let json = std::fs::read_to_string(&path).map_err(|e| AuditError::Io(e.to_string()))?; + let anchor: TrustAnchor = serde_json::from_str(&json) + .map_err(|e| AuditError::Config(format!("trusted_keys.json: {e}")))?; + if anchor.keys.is_empty() { + return Err(AuditError::Config( + "trust anchor has no keys; refusing to verify (fail-closed)".into(), + )); + } + // Validate every pubkey up front, so a malformed anchor key is a hard error + // rather than a silent "no trusted key" at verify time (C6 review Q5). + for k in &anchor.keys { + parse_pubkey(&k.pubkey)?; + } + Ok(anchor) + } + + /// The trusted verifying key matching the receipt's declared `signer_pubkey` whose + /// validity window contains `ts`. Selecting by signer (not first-window-match) + /// avoids falsely rejecting a record signed by another key in an overlapping + /// window, and fails closed if the signer is not in the anchor (C6 review Q3). + fn key_for_signer(&self, signer_pubkey: &str, ts: DateTime) -> Option { + self.keys + .iter() + .find(|k| { + k.pubkey == signer_pubkey + && ts >= k.not_before + && k.not_after.is_none_or(|na| ts <= na) + }) + .and_then(|k| parse_pubkey(&k.pubkey).ok()) + } +} + +/// Outcome of verifying an audit directory. +#[derive(Debug, Clone)] +pub struct VerifyReport { + pub records: usize, + pub chain_ok: bool, + /// The current chain head (`record_hash` of the latest record), if any. Retain it + /// out-of-band and pass it back as `expected_head` next time to detect tail + /// truncation (deleting the newest records leaves a shorter, still-internally-valid + /// chain — final review HIGH). + pub head: Option, + /// Human-readable per-record failures (empty == all good). + pub failures: Vec, +} + +impl VerifyReport { + pub fn ok(&self) -> bool { + self.chain_ok && self.failures.is_empty() + } +} + +/// Verify chain integrity, every record's signature against the windowed trust +/// anchor, and every record's sidecar hash recompute. If `expected_head` is given, +/// also assert the chain head matches it (tail-truncation detection). +pub fn audit_verify( + dir: &Path, + anchor: &TrustAnchor, + expected_head: Option<&str>, +) -> Result { + let chain = + signet_core::audit::verify_chain(dir).map_err(|e| AuditError::Signet(e.to_string()))?; + let chain_ok = chain.valid; + + let records = signet_core::audit::query(dir, &signet_core::audit::AuditFilter::default()) + .map_err(|e| AuditError::Signet(e.to_string()))?; + + // signet `query` returns records OLDEST-first (it reverses at the end), so the + // chain head (most recently appended) is the LAST element. + let head = records.last().map(|r| r.record_hash.clone()); + + let mut failures = Vec::new(); + if let Some(expected) = expected_head { + match &head { + Some(h) if h == expected => {} + Some(h) => failures.push(format!( + "chain head mismatch: expected {expected}, found {h} (possible tail truncation)" + )), + None => failures.push(format!( + "chain head mismatch: expected {expected}, found an empty chain (truncation)" + )), + } + } + for record in &records { + // Only signed v2 compound receipts are produced by relais; skip/flag others. + let receipt: CompoundReceipt = match serde_json::from_value(record.receipt.clone()) { + Ok(r) => r, + Err(e) => { + failures.push(format!("record is not a v2 compound receipt: {e}")); + continue; + } + }; + let id = receipt.id.clone(); + + let ts = match DateTime::parse_from_rfc3339(&receipt.ts_request) { + Ok(t) => t.with_timezone(&Utc), + Err(e) => { + failures.push(format!("{id}: bad ts_request: {e}")); + continue; + } + }; + + // Select the trusted key by the receipt's declared signer, then confirm the + // signature actually verifies under it. verify_compound protects the receipt's + // own fields (action incl. params_hash, response content_hash); the sidecar + // recompute below binds the external preimages to those hashes (C6 review Q2). + let key = match anchor.key_for_signer(&receipt.signer.pubkey, ts) { + Some(k) => k, + None => { + failures.push(format!( + "{id}: signer {} not trusted at {ts}", + receipt.signer.pubkey + )); + continue; + } + }; + + if let Err(e) = signet_core::verify_compound(&receipt, &key) { + failures.push(format!("{id}: signature verification failed: {e}")); + continue; + } + + // Sidecar recompute (byte-exact, sha256: prefixed) for response AND request. + match sidecar::read(dir, &id) { + Ok(sc) => { + if let Some(resp) = sc.get("response") { + match hash_value(resp) { + Ok(h) if h == receipt.response.content_hash => {} + Ok(h) => failures.push(format!( + "{id}: response hash mismatch (sidecar {h} != receipt {})", + receipt.response.content_hash + )), + Err(e) => failures.push(format!("{id}: cannot hash sidecar response: {e}")), + } + } else { + failures.push(format!("{id}: sidecar missing 'response'")); + } + if let Some(req) = sc.get("request") { + match hash_value(req) { + Ok(h) if h == receipt.action.params_hash => {} + Ok(h) => failures.push(format!( + "{id}: request hash mismatch (sidecar {h} != receipt {})", + receipt.action.params_hash + )), + Err(e) => failures.push(format!("{id}: cannot hash sidecar request: {e}")), + } + } else { + failures.push(format!("{id}: sidecar missing 'request'")); + } + } + Err(e) => failures.push(format!("{id}: missing/unreadable sidecar: {e}")), + } + } + + Ok(VerifyReport { + records: records.len(), + chain_ok, + head, + failures, + }) +} + +/// A compact view of one audit record for `relais audit tail`. +#[derive(Debug, Clone)] +pub struct TailEntry { + pub id: String, + pub tool: String, + pub ts: String, + pub signer: String, +} + +/// List recent audit records, optionally filtered by `since` (RFC 3339), a site +/// prefix (`site.`), and a max `limit`. Keeps signet usage inside core so the CLI +/// needs no direct signet dependency. +pub fn tail( + dir: &Path, + site: Option<&str>, + since: Option<&str>, + limit: Option, +) -> Result, AuditError> { + // When filtering by site we must NOT cap the query first, or the newest global N + // records (possibly for other sites) would hide older matching ones. Query + // unbounded, prefix-filter, then truncate to `limit`. + let query_limit = if site.is_some() { None } else { limit }; + let mut filter = signet_core::audit::AuditFilter { + limit: query_limit, + ..Default::default() + }; + if let Some(s) = since { + let dt = DateTime::parse_from_rfc3339(s) + .map_err(|e| AuditError::Config(format!("--since must be RFC 3339: {e}")))? + .with_timezone(&Utc); + filter.since = Some(dt); + } + let records = + signet_core::audit::query(dir, &filter).map_err(|e| AuditError::Signet(e.to_string()))?; + + let prefix = site.map(|s| format!("{s}.")); + let mut out = Vec::new(); + for r in records { + let rcpt = &r.receipt; + let tool = rcpt + .get("action") + .and_then(|a| a.get("tool")) + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + if let Some(p) = &prefix { + if !tool.starts_with(p) { + continue; + } + } + out.push(TailEntry { + id: rcpt + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + tool, + ts: rcpt + .get("ts_request") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + signer: rcpt + .get("signer") + .and_then(|s| s.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }); + } + // Apply the limit after site filtering (query was unbounded in that case). + if site.is_some() { + if let Some(n) = limit { + out.truncate(n); + } + } + Ok(out) +} + +/// `"sha256:" + hex(sha256(JCS(value)))` — byte-identical to how signet computes +/// `content_hash`/`params_hash` (json-canon + sha2 + hex, STANDARD). +fn hash_value(value: &serde_json::Value) -> Result { + let canon = json_canon::to_string(value).map_err(|e| AuditError::Io(e.to_string()))?; + Ok(format!( + "sha256:{}", + hex::encode(Sha256::digest(canon.as_bytes())) + )) +} + +fn parse_pubkey(s: &str) -> Result { + let b64 = s + .strip_prefix("ed25519:") + .ok_or_else(|| AuditError::Config(format!("pubkey must be ed25519:: {s}")))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| AuditError::Config(format!("pubkey base64: {e}")))?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| AuditError::Config("pubkey must be 32 bytes".into()))?; + VerifyingKey::from_bytes(&arr).map_err(|e| AuditError::Config(format!("pubkey: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::key::AuditKey; + use crate::audit::writer::{spawn_writer, AuditJob}; + use serde_json::json; + use signet_core::Action; + use tokio::sync::oneshot; + + fn action() -> Action { + Action { + tool: "site.res.act".into(), + params: json!({ "x": 1 }), + params_hash: String::new(), + target: "https://api.example".into(), + transport: "https".into(), + session: None, + call_id: Some("c".into()), + response_hash: None, + trace_id: Some("t".into()), + parent_receipt_id: None, + } + } + + async fn write_one(dir: &std::path::Path) { + let key = AuditKey::load_or_init(dir, "acme", None).unwrap(); + let h = spawn_writer(dir.to_path_buf(), key, 8); + let (ack, rx) = oneshot::channel(); + let a = action(); + let job = AuditJob { + request: a.params.clone(), + response_env: json!({ "transport_ok": true, "data": { "ok": true } }), + action: a, + t0: Utc::now(), + t1: Utc::now(), + ack, + }; + h.enqueue_timeout(job, std::time::Duration::from_secs(5)) + .await + .unwrap(); + rx.await.unwrap().unwrap(); + } + + fn anchor_for(dir: &std::path::Path) -> TrustAnchor { + let key = AuditKey::load_or_init(dir, "acme", None).unwrap(); + TrustAnchor::new(vec![TrustedKey { + pubkey: key.pubkey, + status: KeyStatus::Active, + not_before: DateTime::parse_from_rfc3339("2000-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + not_after: None, + }]) + } + + #[tokio::test] + async fn verifies_a_clean_chain() { + let dir = tempfile::tempdir().unwrap(); + write_one(dir.path()).await; + let report = audit_verify(dir.path(), &anchor_for(dir.path()), None).unwrap(); + assert!(report.chain_ok); + assert_eq!(report.records, 1); + assert!(report.ok(), "unexpected failures: {:?}", report.failures); + } + + #[tokio::test] + async fn missing_or_empty_anchor_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + assert!(TrustAnchor::load(dir.path()).is_err()); + std::fs::write(dir.path().join("trusted_keys.json"), r#"{"keys":[]}"#).unwrap(); + assert!(TrustAnchor::load(dir.path()).is_err()); + } + + #[tokio::test] + async fn untrusted_key_window_flags_record() { + let dir = tempfile::tempdir().unwrap(); + write_one(dir.path()).await; + // Anchor whose window is entirely in the past → no key valid at receipt ts. + let key = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + let anchor = TrustAnchor::new(vec![TrustedKey { + pubkey: key.pubkey, + status: KeyStatus::Retired, + not_before: DateTime::parse_from_rfc3339("2000-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + not_after: Some( + DateTime::parse_from_rfc3339("2000-01-02T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + }]); + let report = audit_verify(dir.path(), &anchor, None).unwrap(); + assert!(!report.ok()); + assert!(report.failures.iter().any(|f| f.contains("not trusted at"))); + } + + #[tokio::test] + async fn signer_not_in_anchor_is_flagged() { + let dir = tempfile::tempdir().unwrap(); + write_one(dir.path()).await; + // A valid but unrelated key → the receipt's signer pubkey is not in the anchor. + let other = tempfile::tempdir().unwrap(); + let other_key = AuditKey::load_or_init(other.path(), "other", None).unwrap(); + let anchor = TrustAnchor::new(vec![TrustedKey { + pubkey: other_key.pubkey, + status: KeyStatus::Active, + not_before: DateTime::parse_from_rfc3339("2000-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + not_after: None, + }]); + let report = audit_verify(dir.path(), &anchor, None).unwrap(); + assert!(!report.ok()); + assert!(report.failures.iter().any(|f| f.contains("not trusted at"))); + } + + #[tokio::test] + async fn tail_truncation_detected_with_expected_head() { + let dir = tempfile::tempdir().unwrap(); + write_one(dir.path()).await; + write_one(dir.path()).await; // two records + let anchor = anchor_for(dir.path()); + + let before = audit_verify(dir.path(), &anchor, None).unwrap(); + assert_eq!(before.records, 2); + let head = before.head.clone().unwrap(); + + // Delete exactly the head record (by record_hash), regardless of file order. + // Pick the .jsonl (the dir also holds signet's .jsonl.lock). + let adir = dir.path().join("audit"); + let file = std::fs::read_dir(&adir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| p.extension().map(|x| x == "jsonl").unwrap_or(false)) + .unwrap(); + let content = std::fs::read_to_string(&file).unwrap(); + let remaining: Vec = content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter(|l| { + let rec: serde_json::Value = serde_json::from_str(l).unwrap(); + rec.get("record_hash").and_then(|v| v.as_str()) != Some(head.as_str()) + }) + .map(|s| s.to_string()) + .collect(); + std::fs::write(&file, format!("{}\n", remaining.join("\n"))).unwrap(); + + // The shorter chain is still internally valid, but the head no longer matches. + let after = audit_verify(dir.path(), &anchor, Some(&head)).unwrap(); + assert!( + !after.ok(), + "truncation should fail when expected_head is supplied" + ); + assert!(after.failures.iter().any(|f| f.contains("head mismatch"))); + } + + #[test] + fn malformed_anchor_key_rejected_at_load() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("trusted_keys.json"), + r#"{"keys":[{"pubkey":"ed25519:not-base64!!","status":"active","not_before":"2000-01-01T00:00:00Z"}]}"#, + ) + .unwrap(); + assert!(TrustAnchor::load(dir.path()).is_err()); + } + + #[tokio::test] + async fn tampered_sidecar_is_detected() { + let dir = tempfile::tempdir().unwrap(); + write_one(dir.path()).await; + // Corrupt the single sidecar's response. + let scdir = dir.path().join("sidecars"); + let entry = std::fs::read_dir(&scdir).unwrap().next().unwrap().unwrap(); + let mut v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(entry.path()).unwrap()).unwrap(); + v["response"]["data"]["ok"] = json!(false); + std::fs::write(entry.path(), serde_json::to_string(&v).unwrap()).unwrap(); + + let report = audit_verify(dir.path(), &anchor_for(dir.path()), None).unwrap(); + assert!(!report.ok()); + assert!(report.failures.iter().any(|f| f.contains("hash mismatch"))); + } +} diff --git a/crates/core/src/audit/writer.rs b/crates/core/src/audit/writer.rs new file mode 100644 index 0000000..2a71a08 --- /dev/null +++ b/crates/core/src/audit/writer.rs @@ -0,0 +1,377 @@ +//! The single sequential audit writer (C4). +//! +//! One task owns the chain. It signs (`sign_compound`), persists the sidecar, then +//! appends to signet's hash chain — **one job at a time, start to finish, never +//! aborted** (RD-3). The sign+sidecar+append sequence runs under a cross-process +//! exclusive lock over the audit dir, and re-clamps the timestamp against the on-disk +//! latest under that lock, so concurrent processes sharing one dir cannot fork the +//! chain (design §4.7). +//! +//! It also stamps a **monotonic non-decreasing `ts_request`** (seeded from the +//! existing chain), so signet's per-date file selection never files an older date +//! after a newer one — keeping the chain linear across midnight/restart (R2-1). +//! +//! Caller-side timeouts ([`WriterHandle::enqueue_timeout`] and the ack await in C5) +//! bound the *request's wait*, never the append. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; +use signet_core::Action; +use tokio::sync::{mpsc, oneshot}; + +use super::key::AuditKey; +use super::{sidecar, AuditError}; + +/// Signer name recorded in every receipt's `Signer`. +const SIGNER_NAME: &str = "relais"; + +/// A unit of audit work handed to the writer. +pub struct AuditJob { + pub action: Action, + /// The response envelope `sign_compound` hashes; also stored as `sidecar.response`. + pub response_env: Value, + /// `action.params` re-supplied for the sidecar (`sidecar.request`). + pub request: Value, + pub t0: DateTime, + pub t1: DateTime, + pub ack: oneshot::Sender>, +} + +/// What a successful append yields back to the caller. +#[derive(Debug, Clone)] +pub struct ReceiptOut { + pub id: String, + pub record_hash: String, +} + +/// Handle to enqueue work onto the single writer task. +#[derive(Clone)] +pub struct WriterHandle { + tx: mpsc::Sender, +} + +impl WriterHandle { + /// Non-blocking enqueue for response-open mode: never waits; a full or closed + /// channel is an error the caller logs (lossy by design). + pub fn try_enqueue(&self, job: AuditJob) -> Result<(), AuditError> { + self.tx + .try_send(job) + .map_err(|e| AuditError::Unavailable(format!("audit queue: {e}"))) + } + + /// Bounded-wait enqueue for response-closed mode. + pub async fn enqueue_timeout(&self, job: AuditJob, d: Duration) -> Result<(), AuditError> { + self.tx + .send_timeout(job, d) + .await + .map_err(|e| AuditError::Unavailable(format!("audit enqueue timed out: {e}"))) + } +} + +/// Spawn the single writer task and return a handle. Must be called within a tokio +/// runtime. +pub fn spawn_writer(dir: PathBuf, key: AuditKey, capacity: usize) -> WriterHandle { + let (tx, mut rx) = mpsc::channel::(capacity.max(1)); + tokio::spawn(async move { + // Seed monotonic state from the newest existing record so a restart can't + // append an older-dated record after a newer one. + let mut last_ts: Option> = seed_last_ts(&dir); + while let Some(job) = rx.recv().await { + process(&dir, &key, &mut last_ts, job).await; + } + }); + WriterHandle { tx } +} + +/// Seed `last_ts` from the newest existing record (signet `query` is global +/// newest-first; `limit:1` returns the last appended record). +/// +/// Returns the **upper bound** of that record's timestamps, version-aware (v2 → +/// `max(ts_request, ts_response)`; v3 → `ts_response`; else `ts`), so a restart can +/// never let a later `ts_request` regress below the prior record's response time +/// (R2-1 / C4 review Q1, Q5). Empty/corrupt logs degrade to `None` (first job uses +/// its own `t0`); a query error is logged rather than silently swallowed. +fn seed_last_ts(dir: &Path) -> Option> { + let filter = signet_core::audit::AuditFilter { + limit: Some(1), + ..Default::default() + }; + let records = match signet_core::audit::query(dir, &filter) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "audit: cannot read existing chain to seed writer; starting unseeded"); + return None; + } + }; + let rec = &records.first()?.receipt; + let version = rec.get("v").and_then(|v| v.as_u64()).unwrap_or(1); + let fields: &[&str] = match version { + 2 => &["ts_request", "ts_response"], + 3 => &["ts_response"], + _ => &["ts"], + }; + fields + .iter() + .filter_map(|f| rec.get(*f).and_then(|v| v.as_str())) + .filter_map(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .max() +} + +async fn process(dir: &Path, key: &AuditKey, last_ts: &mut Option>, job: AuditJob) { + let dir = dir.to_path_buf(); + let signing = key.signing().clone(); + let owner = key.owner.clone(); + let prev_last = *last_ts; + let AuditJob { + action, + response_env, + request, + t0, + t1, + ack, + } = job; + + // Sign + sidecar + append happen together inside spawn_blocking under a + // cross-process exclusive lock. Timestamps are re-clamped against BOTH the + // in-process monotonic floor and the on-disk latest (read under the lock), so the + // chain stays linear even with multiple processes sharing the dir (design §4.7). + // Never aborted: the writer awaits this before taking the next job. + let res = + tokio::task::spawn_blocking(move || -> Result<(ReceiptOut, DateTime), AuditError> { + let _lock = DirLock::acquire(&dir)?; + + let floor = [prev_last, seed_last_ts(&dir)].into_iter().flatten().max(); + let ts_req = match floor { + Some(f) => t0.max(f), + None => t0, + }; + let ts_resp = t1.max(ts_req); + + let receipt = signet_core::sign_compound( + &signing, + &action, + &response_env, + SIGNER_NAME, + &owner, + &ts_req.to_rfc3339(), + &ts_resp.to_rfc3339(), + ) + .map_err(|e| AuditError::Signet(e.to_string()))?; + + let receipt_value = + serde_json::to_value(&receipt).map_err(|e| AuditError::Io(e.to_string()))?; + let sidecar_value = json!({ "request": request, "response": response_env }); + sidecar::write(&dir, &receipt.id, &sidecar_value)?; + let record = signet_core::audit::append(&dir, &receipt_value) + .map_err(|e| AuditError::Signet(e.to_string()))?; + + Ok(( + ReceiptOut { + id: receipt.id, + record_hash: record.record_hash, + }, + ts_resp, + )) + }) + .await; + + let out = match res { + Ok(Ok((out, ts_resp))) => { + *last_ts = Some(ts_resp); + Ok(out) + } + Ok(Err(e)) => Err(e), + Err(e) => Err(AuditError::Io(format!("audit writer task: {e}"))), + }; + // Log failures even when nobody is listening (open mode drops the receiver), so a + // post-enqueue sign/sidecar/append error is never silent (final review MEDIUM). + if let Err(e) = &out { + tracing::error!(error = %e, "audit record failed to commit"); + } + let _ = ack.send(out); +} + +/// A process-wide exclusive lock over the whole audit dir, held across the +/// sign+sidecar+append sequence so concurrent processes can't fork the chain +/// (released on drop). +struct DirLock { + file: std::fs::File, +} + +impl DirLock { + fn acquire(dir: &Path) -> Result { + std::fs::create_dir_all(dir).map_err(|e| AuditError::Io(e.to_string()))?; + let file = std::fs::File::create(dir.join(".audit.lock")) + .map_err(|e| AuditError::Io(e.to_string()))?; + // std file locking (stable since Rust 1.89): blocks until the lock is held. + file.lock().map_err(|e| AuditError::Io(e.to_string()))?; + Ok(Self { file }) + } +} + +impl Drop for DirLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::key::AuditKey; + + fn action(n: usize) -> Action { + Action { + tool: format!("site.res.act{n}"), + params: json!({ "n": n }), + params_hash: String::new(), + target: "https://api.example".into(), + transport: "https".into(), + session: None, + call_id: Some(format!("call{n}")), + response_hash: None, + trace_id: Some(format!("trace{n}")), + parent_receipt_id: None, + } + } + + async fn enqueue_wait( + h: &WriterHandle, + action: Action, + t0: DateTime, + t1: DateTime, + ) -> Result { + let (ack, rx) = oneshot::channel(); + let job = AuditJob { + request: action.params.clone(), + response_env: json!({ "transport_ok": true, "data": {} }), + action, + t0, + t1, + ack, + }; + h.enqueue_timeout(job, Duration::from_secs(5)) + .await + .unwrap(); + rx.await.unwrap() + } + + #[tokio::test] + async fn writes_unbroken_chain_with_sidecars() { + let dir = tempfile::tempdir().unwrap(); + let key = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + let h = spawn_writer(dir.path().to_path_buf(), key, 64); + + let now = Utc::now(); + let mut ids = Vec::new(); + for n in 0..5 { + let out = enqueue_wait(&h, action(n), now, now).await.unwrap(); + assert!(out.id.starts_with("rec_")); + assert!(!out.record_hash.is_empty()); + ids.push(out.id); + } + + // chain integrity + a sidecar per receipt + let status = signet_core::audit::verify_chain(dir.path()).unwrap(); + assert!(status.valid, "chain should be intact: {status:?}"); + for id in &ids { + assert!( + sidecar::read(dir.path(), id).is_ok(), + "missing sidecar {id}" + ); + } + } + + #[tokio::test] + async fn monotonic_ts_keeps_chain_linear_across_midnight() { + let dir = tempfile::tempdir().unwrap(); + let key = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + let h = spawn_writer(dir.path().to_path_buf(), key, 64); + + // Enqueue a day-2 job FIRST, then a day-1 job (out of date order). The + // monotonic clamp must keep the chain linear (no older-dated file after a + // newer one). + let day2 = DateTime::parse_from_rfc3339("2026-06-22T00:00:30Z") + .unwrap() + .with_timezone(&Utc); + let day1 = DateTime::parse_from_rfc3339("2026-06-21T23:59:30Z") + .unwrap() + .with_timezone(&Utc); + enqueue_wait(&h, action(1), day2, day2).await.unwrap(); + enqueue_wait(&h, action(2), day1, day1).await.unwrap(); + + let status = signet_core::audit::verify_chain(dir.path()).unwrap(); + assert!( + status.valid, + "chain must stay linear across reordered dates: {status:?}" + ); + } + + #[tokio::test] + async fn restart_seeds_from_chain_and_stays_linear() { + let dir = tempfile::tempdir().unwrap(); + + // Writer #1: append a record whose response crosses midnight. + { + let key = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + let h = spawn_writer(dir.path().to_path_buf(), key, 16); + let t0 = DateTime::parse_from_rfc3339("2026-06-21T23:59:59Z") + .unwrap() + .with_timezone(&Utc); + let t1 = DateTime::parse_from_rfc3339("2026-06-22T00:00:05Z") + .unwrap() + .with_timezone(&Utc); + enqueue_wait(&h, action(1), t0, t1).await.unwrap(); + // drop h → writer task drains and ends + } + + // Writer #2 (simulated restart): seeds last_ts from the existing chain, then + // appends a job whose t0 is between the prior ts_request and ts_response. + { + let key = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + let h = spawn_writer(dir.path().to_path_buf(), key, 16); + let t = DateTime::parse_from_rfc3339("2026-06-22T00:00:02Z") + .unwrap() + .with_timezone(&Utc); + enqueue_wait(&h, action(2), t, t).await.unwrap(); + } + + let status = signet_core::audit::verify_chain(dir.path()).unwrap(); + assert!( + status.valid, + "chain must stay linear across restart: {status:?}" + ); + } + + #[tokio::test] + async fn try_enqueue_errs_when_full() { + let dir = tempfile::tempdir().unwrap(); + let key = AuditKey::load_or_init(dir.path(), "acme", None).unwrap(); + let h = spawn_writer(dir.path().to_path_buf(), key, 1); + + // Flood without draining acks; a bounded channel must eventually reject. + let now = Utc::now(); + let mut rejected = false; + for n in 0..256 { + let (ack, _rx) = oneshot::channel(); + let a = action(n); + let job = AuditJob { + request: a.params.clone(), + response_env: json!({ "transport_ok": true }), + action: a, + t0: now, + t1: now, + ack, + }; + if h.try_enqueue(job).is_err() { + rejected = true; + break; + } + } + assert!(rejected, "a bounded queue should reject under flood"); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 7ed55b6..874641e 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -10,6 +10,10 @@ pub enum AdapterError { NotFound(String), #[error("action not supported: {0}")] Unsupported(String), + #[error("site not found: {0}")] + SiteNotFound(String), + #[error("audit unavailable: {0}")] + AuditUnavailable(String), #[error("upstream error: {0}")] Upstream(#[from] reqwest::Error), #[error("{0}")] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index bb96f96..448c109 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,6 @@ pub mod adapter; +#[cfg(feature = "audit")] +pub mod audit; pub mod error; pub mod oauth; pub mod router; diff --git a/crates/core/src/router.rs b/crates/core/src/router.rs index 0e3da5a..f5b36c6 100644 --- a/crates/core/src/router.rs +++ b/crates/core/src/router.rs @@ -1,19 +1,64 @@ use std::collections::HashMap; use crate::adapter::Adapter; -use crate::types::SiteManifest; +use crate::error::AdapterError; +use crate::types::{ExecContext, Response, SiteManifest}; pub struct Router { adapters: HashMap>, + #[cfg(feature = "audit")] + audit: Option, } impl Router { pub fn new() -> Self { Self { adapters: HashMap::new(), + #[cfg(feature = "audit")] + audit: None, } } + /// Attach an audit sink so every [`Router::exec`] emits a signed receipt. + #[cfg(feature = "audit")] + pub fn with_audit(mut self, sink: crate::audit::AuditSink) -> Self { + self.audit = Some(sink); + self + } + + /// Execute an action through the gateway choke point. This is the single path + /// every caller (HTTP server and CLI) goes through, so auditing (when enabled) + /// covers them all. Adapters are never called directly in production. + pub async fn exec(&self, ctx: &ExecContext) -> Result { + let adapter = self + .get(&ctx.site) + .ok_or_else(|| AdapterError::SiteNotFound(ctx.site.clone()))?; + + #[cfg(feature = "audit")] + if let Some(sink) = &self.audit { + let base_url = adapter.manifest().base_url; + let t0 = chrono::Utc::now(); + let mut result = adapter.exec(ctx).await; + let t1 = chrono::Utc::now(); + match sink.record(ctx, &base_url, &result, t0, t1).await { + Ok(handle) => { + if let Ok(resp) = &mut result { + resp.meta.receipt = handle; + } + } + Err(e) => { + if sink.closed() { + return Err(AdapterError::AuditUnavailable(e.to_string())); + } + tracing::error!(error = %e, "audit sink failed (response-open)"); + } + } + return result; + } + + adapter.exec(ctx).await + } + pub fn register(&mut self, adapter: Box) { let id = adapter.manifest().id.clone(); self.adapters.insert(id, adapter); @@ -33,3 +78,104 @@ impl Default for Router { Self::new() } } + +#[cfg(all(test, feature = "audit"))] +mod audit_tests { + use super::*; + use crate::adapter::Adapter; + use crate::audit::{AuditConfig, AuditMode, AuditSink}; + use crate::types::{AuthType, ExecContext, Resource, Response, ResponseMeta}; + use async_trait::async_trait; + use std::time::Duration; + + struct Stub { + fail: bool, + } + + #[async_trait] + impl Adapter for Stub { + fn manifest(&self) -> SiteManifest { + SiteManifest { + id: "stub".into(), + name: "Stub".into(), + base_url: "https://api.example".into(), + auth_type: AuthType::None, + } + } + fn resources(&self) -> Vec { + vec![] + } + async fn exec(&self, _ctx: &ExecContext) -> Result { + if self.fail { + return Err(AdapterError::NotFound("nope".into())); + } + Ok(Response { + data: serde_json::json!({ "ok": true }), + meta: ResponseMeta::default(), + }) + } + } + + fn sink(dir: &std::path::Path, mode: AuditMode) -> AuditSink { + AuditSink::new(AuditConfig { + dir: dir.to_path_buf(), + owner: "acme".into(), + mode, + capacity: 16, + ack_timeout: Duration::from_secs(5), + }) + .unwrap() + } + + fn ctx(site: &str) -> ExecContext { + ExecContext { + site: site.into(), + resource: "r".into(), + action: "a".into(), + params: serde_json::json!({ "x": 1 }), + credentials: None, + } + } + + #[tokio::test] + async fn exec_with_closed_sink_records_and_sets_receipt() { + let dir = tempfile::tempdir().unwrap(); + let mut router = Router::new(); + router.register(Box::new(Stub { fail: false })); + let router = router.with_audit(sink(dir.path(), AuditMode::Closed)); + + let resp = router.exec(&ctx("stub")).await.unwrap(); + let handle = resp.meta.receipt.expect("receipt handle in closed mode"); + assert!(handle.id.starts_with("rec_")); + assert!(handle.record_hash.is_some()); + + let status = signet_core::audit::verify_chain(dir.path()).unwrap(); + assert!(status.valid, "chain should be intact"); + } + + #[tokio::test] + async fn exec_unknown_site_is_site_not_found() { + let router = Router::new(); + let err = router.exec(&ctx("missing")).await.unwrap_err(); + assert!(matches!(err, AdapterError::SiteNotFound(_))); + } + + #[tokio::test] + async fn exec_records_failure_outcome_too() { + let dir = tempfile::tempdir().unwrap(); + let mut router = Router::new(); + router.register(Box::new(Stub { fail: true })); + let router = router.with_audit(sink(dir.path(), AuditMode::Closed)); + + // The adapter error still propagates to the caller... + let err = router.exec(&ctx("stub")).await.unwrap_err(); + assert!(matches!(err, AdapterError::NotFound(_))); + // ...and the failure is still recorded on the chain. + let status = signet_core::audit::verify_chain(dir.path()).unwrap(); + assert!(status.valid); + let records = + signet_core::audit::query(dir.path(), &signet_core::audit::AuditFilter::default()) + .unwrap(); + assert_eq!(records.len(), 1, "a receipt is written even for a failed exec"); + } +} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a0816cf..0c8ccdc 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -58,11 +58,29 @@ pub struct Response { pub meta: ResponseMeta, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ResponseMeta { pub pagination: Option, pub rate_limit: Option, pub cached: bool, + /// Cryptographic audit-receipt handle for this call, when the `audit` feature is + /// enabled and a sink is configured. Always present in the wire type (only its + /// population is feature-gated), so audit and non-audit builds stay compatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt: Option, +} + +/// A reference to a signed, hash-chained audit receipt produced for one `exec`. +/// +/// `record_hash` is `Some` in response-closed mode, where the caller awaits the chain +/// append. In the default response-open mode the caller does not await, so no handle +/// is attached at all (`ResponseMeta.receipt == None`); `record_hash: None` only +/// arises if open mode is configured to wait long enough to learn the receipt id. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReceiptHandle { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub record_hash: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/core/tests/router_test.rs b/crates/core/tests/router_test.rs index 0bab615..890f2b8 100644 --- a/crates/core/tests/router_test.rs +++ b/crates/core/tests/router_test.rs @@ -31,6 +31,7 @@ impl Adapter for MockAdapter { pagination: None, rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/crates/core/tests/types_test.rs b/crates/core/tests/types_test.rs index 12eaf9a..a33608f 100644 --- a/crates/core/tests/types_test.rs +++ b/crates/core/tests/types_test.rs @@ -56,6 +56,7 @@ fn response_meta_includes_pagination() { reset_at: chrono::Utc::now(), }), cached: false, + receipt: None, }, }; assert!(response.meta.pagination.as_ref().unwrap().has_next); diff --git a/crates/llm-fallback/src/lib.rs b/crates/llm-fallback/src/lib.rs index 58a965c..bb5f299 100644 --- a/crates/llm-fallback/src/lib.rs +++ b/crates/llm-fallback/src/lib.rs @@ -146,6 +146,7 @@ impl Adapter for LlmFallbackAdapter { pagination: None, rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index eddcacd..200380b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -6,6 +6,11 @@ license.workspace = true description = "HTTP server (axum) exposing the relais API gateway over /v1/exec." repository.workspace = true +[features] +default = [] +# Propagate cryptographic call auditing to relais-core. +audit = ["relais-core/audit"] + [dependencies] relais-core.workspace = true serde.workspace = true diff --git a/crates/server/src/handlers.rs b/crates/server/src/handlers.rs index 1d523eb..6c88f93 100644 --- a/crates/server/src/handlers.rs +++ b/crates/server/src/handlers.rs @@ -97,15 +97,14 @@ pub async fn exec_action( State(state): State, Json(body): Json, ) -> Result)> { - let adapter = state - .router - .get(&body.site) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": format!("site '{}' not found", body.site)})), - ) - })?; + // Existence check for a clean 404 (router.exec would otherwise map a missing + // site to a 500). The actual execution goes through the router choke point. + if state.router.get(&body.site).is_none() { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("site '{}' not found", body.site)})), + )); + } // Look up credentials from vault for this site. let credentials = state.vault.as_ref().and_then(|vault| { @@ -162,7 +161,7 @@ pub async fn exec_action( credentials, }; - match adapter.exec(&ctx).await { + match state.router.exec(&ctx).await { Ok(response) => Ok(Json(response)), Err(err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index e3453fc..9e3ac55 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -52,6 +52,7 @@ impl Adapter for MockAdapter { pagination: None, rate_limit: None, cached: false, + receipt: None, }, }) } diff --git a/docs/design/signet-audit-impl.md b/docs/design/signet-audit-impl.md new file mode 100644 index 0000000..33ac67f --- /dev/null +++ b/docs/design/signet-audit-impl.md @@ -0,0 +1,516 @@ +# Implementation spec: Signet call auditing + +- **Status:** Draft v4 (GO — cleared Codex impl-review rounds 1, 2 & GO/NO-GO) +- **Companion design:** [`signet-audit-integration.md`](./signet-audit-integration.md) (Draft v4, 4 review rounds) +- **Author:** willamhou (with Claude) +- **Date:** 2026-06-21 + +This is the buildable plan for the design spec. It is organised into **independently +implementable chapters (C1…C8)**. Each chapter lists *files*, *exact +signatures/types*, *tests*, and *acceptance* so it can be implemented and +Codex-reviewed on its own, then this doc is updated before the next chapter. + +> **Progress legend (updated as chapters land):** ☐ not started · ◐ in progress · +> ☑ done & reviewed. +> C1 ☑ · C2 ☑ (RQ3 accepted) · C3 ☑ · C4 ☑ · C5 ☑ · C6 ☑ · C7 ☑ (closed-mode fail-hard via build_exec_router; tail limit-after-filter; server audit feature; --owner) · C8 ☐ (optional, deferred to M3) +> +> **C1–C7 implemented.** Final whole-feature Codex review + self-review + full local +> regression next; then push. +> +> **C5 scope note:** C5 lands the choke point (`Router::exec`) and the sink, and +> routes server+CLI through it, but `build_router()` still returns an **unaudited** +> router — **runtime enablement** (`with_audit(...)` driven by config, plus the +> `audit` feature passthrough on `relais-cli`/`relais-server`) is wired in **C7** +> alongside `relais audit init`. Open mode is **log-only** in v1 (a metric is added +> when metrics infra exists). +> +> **Impl note for C6 (pubkey format):** signet stores the `.pub`/`KeyInfo` pubkey as +> **raw STANDARD base64**, but receipts' `signer.pubkey` is **`ed25519:`-prefixed** +> (`format_pubkey`). `AuditKey.pubkey` is normalised to the `ed25519:` form so it +> matches receipts and `trusted_keys.json` directly. C6 trust comparison must use +> the same prefixed form (or compare raw `VerifyingKey` bytes). + +## 0. Pinned facts & dependencies + +- **`signet-core = "0.10"`** (workspace clone is 0.10.0). **C1 must confirm the + crates.io-published 0.10 API matches** the signatures below (the clone may be + ahead); if not, pin the exact published version and re-verify in C1. +- **Key types are `ed25519_dalek::{SigningKey, VerifyingKey}`** — signet-core does + **not** re-export them (IR-1). Add `ed25519-dalek = "2"` under the audit feature + and use `ed25519_dalek::{SigningKey, VerifyingKey}` everywhere. +- Hash recompute (C6) **must** match signet byte-for-byte → use the **same crates**: + `json-canon = "0.1"`, `hex = "0.4"`, and **`sha2 = "0.10"` which is ALREADY a + non-optional core dep** (used by `vault.rs`) — reuse it, do **not** re-add it as + audit-optional (GO-B2). signet's `canonical::canonicalize` = `json_canon::to_string`. +- All audit code is behind a **`audit` cargo feature** on `relais-core`; default + **off** for v1 (opt-in). +- `tokio::sync::{mpsc, oneshot}` + `tokio::task::spawn_blocking` for the writer (C4). +- `chrono` (already a core dep) for RFC-3339 timestamps. + +## 1. Module layout (target) + +``` +crates/core/ +├── Cargo.toml # + [features] audit; optional deps incl ed25519-dalek (C1) +└── src/ + ├── types.rs # + ReceiptHandle, ResponseMeta.receipt (C1) + ├── error.rs # + SiteNotFound, AuditUnavailable, (M3) PolicyDenied/NeedsApproval (C1) + ├── router.rs # + (gated) audit field, async exec() present in BOTH builds (C5) + └── audit/ # all behind feature = "audit" + ├── mod.rs # AuditSink, AuditConfig, AuditError (C1 skel → C5) + ├── redact.rs # value+key+secret-substring redaction, _relais_audit (C2) + ├── envelope.rs # ExecContext/Response → Action + response envelope (C2) + ├── key.rs # AuditKey load/gen via signet fs_ops; CredRefStore (C3) + ├── writer.rs # single-writer task: sign + sidecar + append, monotonic dates (C4) + ├── sidecar.rs # sidecar preimage persist + path layout (C4/C6) + └── verify.rs # chain + per-record windowed verify + sidecar recompute (C6) +crates/cli/src/commands/audit.rs # relais audit {init,verify,tail,pubkey} (C7) +crates/server/src/handlers.rs # exec → router.exec (C5) +crates/cli/src/commands/exec.rs # exec → router.exec (C5) +.github/workflows/ci.yml # guard: no direct adapter .exec( outside tests (C5) +``` + +--- + +## C1 — Feature scaffold + core types + +**Files:** `crates/core/Cargo.toml`, `crates/core/src/types.rs`, +`crates/core/src/error.rs`, `crates/core/src/audit/mod.rs`, `crates/core/src/lib.rs`, +**and every existing `ResponseMeta { … }` literal** (IR-8/R2-LOW): the implementer +**must `rg "ResponseMeta\s*\{"` to find them all** — known non-test sites include +`crates/adapters/github/src/lib.rs:110`, `crates/adapters/hackernews/src/lib.rs:53`, +**`:173`, `:208`**, `crates/adapters/scs/src/lib.rs:139`, +`crates/adapters/scs-legacy/src/lib.rs` (its `ResponseMeta` sites), +`crates/llm-fallback/src/lib.rs:145`, plus test literals. + +**Cargo:** +```toml +[features] +default = [] +audit = ["dep:signet-core","dep:ed25519-dalek","dep:json-canon","dep:hex","dep:tokio","dep:tracing"] + +[dependencies] +# sha2 = "0.10" is ALREADY a non-optional core dep (vault.rs) — reuse it, do NOT list +# it here as optional (GO-B2). chrono/serde/serde_json/anyhow are already core deps too. +signet-core = { version = "0.10", optional = true } +ed25519-dalek = { version = "2", optional = true } +json-canon = { version = "0.1", optional = true } +hex = { version = "0.4", optional = true } +tokio = { version = "1", features = ["sync","rt","time"], optional = true } # currently dev-only; add as optional runtime dep +tracing = { version = "0.1", optional = true } # used by the audit Router branch (C5, GO-B3) +``` + +**types.rs (additive, wire-compatible — present in ALL builds, F-12):** +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReceiptHandle { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub record_hash: Option, // None in response-open (append not yet acked) — IR-4 +} +// in ResponseMeta: +#[serde(default, skip_serializing_if = "Option::is_none")] +pub receipt: Option, +``` +To avoid touching every literal twice, **add `#[derive(Default)]` to `ResponseMeta`** +and migrate the literals that set all old fields to `..Default::default()` where it +reads cleanly; otherwise add `receipt: None` explicitly. The field is **not** +feature-gated out of the wire type (only its population is). + +**error.rs (additive):** +```rust +#[error("site not found: {0}")] SiteNotFound(String), // Router::exec (currently missing!) +#[error("audit unavailable: {0}")] AuditUnavailable(String), // response-closed sink failure +// M3 only (C8): PolicyDenied(String), NeedsApproval(String) +``` + +**audit/mod.rs:** skeleton behind `#![cfg(feature = "audit")]` — `AuditConfig`, +`AuditSink`, and an `AuditError` (thiserror) that `From`-converts into +`AdapterError::AuditUnavailable`. Compiles with `todo!()` bodies. + +**Tests / acceptance:** `cargo build` (no features) and `--features audit` both +compile; default `cargo test` green after literal updates; serde round-trip of +`ResponseMeta` across builds (no `receipt`/`record_hash` field when `None`); +**a throwaway test calls the published `sign_compound`/`audit::append`/`verify_compound` +signatures to confirm the 0.10 API.** + +--- + +## C2 — Redaction + envelope mapping (pure, no I/O) + +**Files:** `crates/core/src/audit/redact.rs`, `crates/core/src/audit/envelope.rs`. + +**Redaction (`redact.rs`) — masks by key name AND by secret value (IR-6):** +```rust +pub struct Redactor { denylist: Vec } // keys: token,password,secret,authorization, +// api_key,acs_token,cookie, + *_token suffix +impl Redactor { + // `secret_values`: the actual credential strings pulled from ctx.credentials + // (bearer_token(), cookie values). Any string leaf that EQUALS or CONTAINS one + // is masked, regardless of its key — this is what passes the leak guard. + pub fn redact_value(&self, v: &Value, secret_values: &[String]) -> Value; +} +pub fn secret_values_of(creds: &Option) -> Vec; +// Match ALL CredentialData variants DIRECTLY (R2-HIGH/IR-6) — do NOT use +// bearer_token() (it omits refresh_token): +// ApiKey => [token] +// OAuth => [access_token, refresh_token?] // BOTH +// Cookie => cookies.values() // every cookie value +pub struct AuditMeta { pub auth_injection: String, pub credential_ref: String, + pub t0: String, pub t1: String } // TRUE request start/end (RFC3339) +// credential_ref: OPAQUE random handle (kref_…), resolvable only via local CredRefStore (C3). +// NEVER the vault site id or a token hash. +// t0/t1: the true wall-clock request window. They live in `_relais_audit` (inside +// action.params, covered by params_hash → signed/tamper-evident), so the receipt's +// top-level `ts_request` can be the monotonic AUDIT-ORDER time (C4/R2-HIGH) without +// losing the real request time auditors need. +``` + +**Envelope (`envelope.rs`):** +```rust +pub fn build_request(ctx: &ExecContext, meta: &AuditMeta, r: &Redactor, secrets: &[String]) -> Value; +// redact_value(ctx.params) + { "_relais_audit": { auth_injection, credential_ref, t0, t1 } } +pub fn build_response_envelope(result: &Result, r: &Redactor, secrets: &[String]) -> Value; +// Ok => { "transport_ok": true, "data": redact(data), "business_status": "unclassified" } +// Err => { "transport_ok": false, "error": { "kind": "", "message": "" } } +pub fn build_action(ctx: &ExecContext, request: Value, base_url: &str, + session: Option, trace_id: String, call_id: String) -> signet_core::Action; +``` +`build_action` constructs `signet_core::Action` with the **real field names** (IR-2): +```rust +signet_core::Action { + tool: format!("{}.{}.{}", ctx.site, ctx.resource, ctx.action), + params: request, // becomes BOTH Action.params and sidecar.request (same Value) + params_hash: String::new(), // signet fills it in sign_compound + target: base_url.to_string(), // exactly manifest().base_url + transport: "https".into(), + session, call_id: Some(call_id), + response_hash: None, + trace_id: Some(trace_id), + parent_receipt_id: None, // NOT `parent` +} +``` + +**Tests / acceptance:** key-name + `*_token` masking; **secret-value masking** (a +token echoed under an arbitrary key/string is masked); **secret-leak guard**: given +an `ExecContext` whose credential token also appears in a param and in the response +body, the serialized `Action` + request + response envelopes contain none of the +token bytes; `transport_ok` reflects Ok/Err; golden envelope test. + +--- + +## C3 — Key management + +**Files:** `crates/core/src/audit/key.rs`. + +```rust +use ed25519_dalek::SigningKey; // IR-1 +pub struct AuditKey { signing: SigningKey, pub pubkey_b64: String, pub owner: String } +impl AuditKey { + pub fn load_or_init(dir: &Path, owner: &str, passphrase: Option<&str>) -> Result; + pub fn signing(&self) -> &SigningKey; +} +pub struct CredRefStore { /* dir/credential_refs.json */ } +impl CredRefStore { + pub fn load(dir: &Path) -> Result; + pub fn mint(&mut self, binding: CredBinding) -> Result; // random ref +} +``` +- `load_or_init` uses signet `identity::fs_ops::{load_signing_key(dir,"relais",pass), + generate_and_save(dir,"relais",Some(owner),pass,None)}` → keys at + `dir/keys/relais.{key,pub}` (NF-7). **Does NOT auto-trust** for verification (NF-4). +- `CredRefStore` maps opaque `kref_…` → vault binding; the reverse map is **never** + serialized into receipts/sidecars and is omitted from exports. + +**Tests / acceptance:** init creates `dir/keys/relais.key` (0600) + `.pub`; reload +reuses; pubkey stable; `mint` unique; cred-ref map never appears in any receipt JSON. + +--- + +## C4 — Single-writer task: sign + sidecar + append (monotonic), the §4.7 contract + +**Files:** `crates/core/src/audit/writer.rs`, `crates/core/src/audit/sidecar.rs`. + +**The writer OWNS `sign_compound` + sidecar + `append`** (moved out of `record`) so a +single sequential task controls both ordering and timestamps — this fixes the +cross-midnight chain-fork (IR-5) and makes `record_hash` available (IR-4). + +```rust +pub struct AuditJob { + pub action: signet_core::Action, // already built+redacted (C2) + pub response_env: Value, // hashed by sign_compound AND stored as sidecar.response + pub request: Value, // = action.params, stored as sidecar.request + pub t0: DateTime, pub t1: DateTime, + pub ack: oneshot::Sender>, +} +pub struct ReceiptOut { pub id: String, pub record_hash: String } +pub struct WriterHandle { tx: mpsc::Sender } +impl WriterHandle { + pub fn try_enqueue(&self, job) -> Result<(), AuditError>; // open (try_send) + pub async fn enqueue_timeout(&self, job, d: Duration) -> Result<(), AuditError>; // closed +} +pub fn spawn_writer(dir: PathBuf, key: AuditKey, cap: usize) -> WriterHandle; +``` + +**`spawn_writer` seeds `last_ts` from the existing chain BEFORE accepting jobs +(R2-HIGH):** read the newest existing audit record's `ts_request` (via +`query`/last-record scan) and initialise `last_ts` to it. Without this, after a +restart or backward clock skew the first new job could carry a `ts_request` older +than the on-disk tail and append an older-dated record after a newer one — forking +the chain. Seeding makes monotonicity hold across process lifetimes. + +**Writer loop (exact):** for each `AuditJob`, sequentially: +1. **Monotonic AUDIT-ORDER timestamp (IR-5, R2-HIGH):** `ts_req = max(t0, last_ts)`; + `ts_resp = max(t1, ts_req)`; update `last_ts = ts_resp`. Non-decreasing + `ts_request` ⇒ signet's per-date file selection (`extract_timestamp`) never files + an older date after a newer one ⇒ the chain stays linear across midnight/restart. + **Semantics:** the receipt's top-level `ts_request`/`ts_response` are defined as + the *audit ordering/signing* time, **not** necessarily the real request start. + The **true** request window is carried in signed `_relais_audit.t0/t1` (C2), so no + information is lost and key-rotation windows (C6) key on the same monotonic + `ts_request` consistently. (Clamps are sub-second except at a real rollover race.) +2. `receipt = sign_compound(key.signing(), &job.action, &job.response_env, "relais", + &key.owner, &ts_req.to_rfc3339(), &ts_resp.to_rfc3339())?` → has `id`. +3. `sidecar::write(dir, &receipt.id, &json!({ "request": job.request, "response": job.response_env }))` + (atomic tmp+rename), **before** the append. +4. `let rec = spawn_blocking(move || signet_core::audit::append(&dir, &receipt_value)).await??;` + — **never aborted**; sequential processing guarantees order. +5. `let _ = ack.send(Ok(ReceiptOut { id: receipt.id, record_hash: rec.record_hash }));` + — **tolerate a dropped receiver** (open mode ignores the ack): once the append has + succeeded, a `send` failure is **not** an append failure (R2-MEDIUM). + +Caller-side timeouts (C5) bound the request's *wait*, never the append. Bounded +channel: full → `try_enqueue` errs (open, lossy + log), `enqueue_timeout` errs after +deadline (closed). Graceful shutdown drains with a bounded deadline. + +**Cross-process chain ownership (design §4.7, R2-HIGH).** A single in-process writer +prevents intra-process forks, but the **server and CLI are separate processes** and +must not both write one chain via per-date locks. v1 default: **the server owns the +chain; the CLI writes a SEPARATE namespace** (its own subdir, e.g. +`dir/cli/audit/…`, with its own `signer_owner`). The unified-chain alternative is an +opt-in **global cross-process lock over the whole audit dir** (never per-date). C5 +acceptance must assert server and CLI never share one per-date-locked chain. + +**sidecar.rs:** `write(dir,id,&Value)`, `read(dir,id)->Value`, `dir/sidecars/.json`. + +**Tests / acceptance:** 500 concurrent enqueues → single unbroken chain +(`verify_chain` Ok) + a sidecar per id; **cross-midnight test** (feed t0/t1 straddling +00:00 out of order) still yields a linear chain; backpressure errors, no deadlock; +no-abort probe (slow append doesn't let the next start early); append runs on a +blocking thread. + +--- + +## C5 — AuditSink + `Router::exec` + call-site rewire + +**Files:** `crates/core/src/audit/mod.rs`, `crates/core/src/router.rs`, +`crates/server/src/handlers.rs`, `crates/cli/src/commands/exec.rs`, +`.github/workflows/ci.yml`. + +```rust +pub enum AuditMode { Open, Closed } +pub struct AuditConfig { pub dir: PathBuf, pub owner: String, pub mode: AuditMode, + pub capacity: usize, pub ack_timeout: Duration } +pub struct AuditSink { writer: WriterHandle, credrefs: Mutex, + redactor: Redactor, mode: AuditMode, ack_timeout: Duration } +impl AuditSink { + pub fn new(cfg: AuditConfig) -> Result; // load_or_init key + spawn_writer + // base_url passed IN (ExecContext has none) — IR-3 + pub async fn record(&self, ctx: &ExecContext, base_url: &str, + result: &Result, + t0: DateTime, t1: DateTime) -> Result, AuditError>; + pub fn closed(&self) -> bool; +} +``` +`record`: mint the `credential_ref` in a **tight synchronous scope** — +`{ let mut g = self.credrefs.lock().unwrap(); g.mint(binding) }` — and **drop the +guard before any `.await`** (R2-MEDIUM), so the handler future stays `Send` and the +non-async `std::sync::Mutex` is never held across await. Then build +`secrets`/`meta`/`request`/`response_env`/`action` (C2) → assemble `AuditJob` → +- **Open:** `try_enqueue`; on full → log + return `Ok(None)` (best-effort, no handle — + IR-4). On success **do not await** → return `Ok(Some(ReceiptHandle{ id: }))`. Since signing now happens in the writer, **open mode returns + `Ok(None)`** (no id/hash without awaiting). *(If a handle is desired in open mode, + await the ack with a short timeout; default v1 = `None`.)* +- **Closed:** `enqueue_timeout(ack_timeout)` then `await ack` (also bounded) → + `Ok(Some(ReceiptHandle{ id, record_hash: Some(hash) }))`; any failure → + `Err(AuditUnavailable)`. + +**Router (present in BOTH builds; only the field/sink are gated — IR-7):** +```rust +pub struct Router { + adapters: HashMap>, + #[cfg(feature = "audit")] audit: Option, +} +impl Router { + #[cfg(feature = "audit")] pub fn with_audit(mut self, s: AuditSink) -> Self { self.audit = Some(s); self } + pub async fn exec(&self, ctx: &ExecContext) -> Result { + let adapter = self.get(&ctx.site).ok_or_else(|| AdapterError::SiteNotFound(ctx.site.clone()))?; + // base_url + timing live ONLY inside the audit path so non-audit builds emit + // no unused-variable warnings (R2-MEDIUM): + #[cfg(feature = "audit")] + if let Some(sink) = &self.audit { + let base_url = adapter.manifest().base_url; + let t0 = Utc::now(); + let mut result = adapter.exec(ctx).await; + let t1 = Utc::now(); + match sink.record(ctx, &base_url, &result, t0, t1).await { + Ok(h) => if let Ok(resp) = &mut result { resp.meta.receipt = h; }, + Err(e) => if sink.closed() { return Err(AdapterError::AuditUnavailable(e.to_string())); } + else { tracing::error!(error=%e, "audit sink failed (response-open)"); } + } + return result; + } + adapter.exec(ctx).await // non-audit build, or audit feature on but no sink configured + } +} +``` +`exec` exists in both builds; the audit branch is fully `#[cfg]`-gated, so non-audit +builds reduce to today's `adapter.exec(ctx).await` with no dead bindings, and both +call sites compile with and without the feature. + +**Rewire:** `handlers.rs:165` and `exec.rs:76` → `router.exec(&ctx).await`. **CI +guard:** grep for adapter-handle `\.exec(` in `crates/server/src/**` and +`crates/cli/src/**` (production dirs only, excluding `tests/`), failing on a direct +call — scoped to avoid false positives on adapter-internal code. + +**Tests / acceptance:** with a stub adapter + sink, `router.exec` writes one record + +populates `ResponseMeta.receipt` (closed mode); without the feature, behaviour +unchanged; closed-mode forced failure → `AuditUnavailable`, response withheld; +open-mode forced failure → logged, response returned; server + CLI integration green. + +--- + +## C6 — Verification (chain + windowed per-record + sidecar recompute) + +**Files:** `crates/core/src/audit/verify.rs`. + +```rust +use ed25519_dalek::VerifyingKey; // IR-1 +pub struct TrustedKey { pub pubkey_b64: String, pub status: KeyStatus, + pub not_before: DateTime, pub not_after: Option> } +pub struct TrustAnchor { keys: Vec } +impl TrustAnchor { + pub fn load(dir: &Path) -> Result; // ERROR if missing/empty (NF-4) + fn key_for(&self, ts: DateTime) -> Option; +} +pub struct VerifyReport { pub records: usize, pub chain_ok: bool, pub failures: Vec } +pub fn audit_verify(dir: &Path, anchor: &TrustAnchor) -> Result; +``` +`audit_verify`: +1. `verify_chain(dir)?` → `chain_ok`. +2. `let records = query(dir, &AuditFilter::default())?;` (ordered). +3. per record: `ts = record.receipt["ts_request"]`; `key = anchor.key_for(ts)` (fail + if none); **deserialize then verify (IR-9):** + ```rust + let cr: signet_core::CompoundReceipt = serde_json::from_value(record.receipt.clone())?; + signet_core::verify_compound(&cr, &key)?; // or verify_any(&record.receipt.to_string(), &key) + ``` +4. **sidecar recompute (RD-1, exact):** + ```rust + let s = sidecar::read(dir, &cr.id)?; + let canon = json_canon::to_string(&s["response"])?; + let expect = format!("sha256:{}", hex::encode(sha2::Sha256::digest(canon.as_bytes()))); + if expect != cr.response.content_hash { failures.push(...) } + // same prefixed rule: hash s["request"] vs cr.action.params_hash + ``` +5. collect failures → report. + +**Tests / acceptance:** happy path verifies; byte-flip → `chain_ok=false`; sidecar +mutation → recompute failure; missing/empty anchor → `load` errors (no self-trust); +rotation-window positive/negative; **cross-impl pin:** a receipt from real +`sign_compound` with its `response_content` as sidecar recomputes equal to +`content_hash` (guards prefix/JCS/bytes). + +--- + +## C7 — CLI: `relais audit {init,verify,tail,pubkey}` + +**Files:** `crates/cli/src/commands/audit.rs`, CLI arg wiring, `relais-cli` `audit` +feature passthrough. + +- `relais audit init` → `AuditKey::load_or_init` (+ `--owner`, passphrase prompt); + prints pubkey + dir. +- `relais audit pubkey` → prints `dir/keys/relais.pub`. +- `relais audit verify` → `TrustAnchor::load` + `audit_verify`; **non-zero exit on any + failure or empty anchor**. +- `relais audit tail [--site ] [--since ] [--limit N]` → `query` with an + `AuditFilter` (`since`/`tool`/`signer`/`limit`); `--site` maps to a `tool`-prefix + filter applied in relais. + +**Tests / acceptance:** `init`→`verify` (with anchor) on a fresh chain → exit 0; +`verify` no anchor → non-zero + message; `tail` filters; CLI exec path (C5) writes +records `verify` accepts. + +--- + +## C8 — (Optional, M3) Policy gate + JWT attribution + +**Files:** `crates/core/src/audit/policy.rs`, `crates/core/src/router.rs`, +`crates/server/src/handlers.rs`. + +- Pre-exec gate in `Router::exec` **before** `adapter.exec` using + `signet_core::evaluate_policy` + `signet_core::audit::append_violation` (note the + paths — `append_violation` is under `audit`, not a root export — IR-10); + Deny/RequireApproval → `AdapterError::{PolicyDenied,NeedsApproval}` (no exec). v1 + ships an allow-all default + the hook only. +- Server fills `Action.session` with the JWT subject; CLI with the local user. + +**Acceptance:** deny blocks `exec` + writes a violation; allow-all is a no-op. +**Optional** — ship C1–C7 first. + +--- + +## Implementation order & loop protocol + +Build **C1 → C2 → C3 → C4 → C5 → C6 → C7** (C8 optional). Per chapter: +1. Implement it. +2. Update this doc's progress legend (☐→◐→☑) and any signature that changed in reality. +3. Codex-review **the code**; fix; re-review until clean. +4. Next chapter. + +After C7 (and C8 if included): one **final** Codex review + self code-review, then +`cargo test` / `cargo build --features audit` regression, then push. + +### Cross-cutting acceptance (whole feature) +- `cargo build` (default) and `--features audit` clean; `cargo clippy --features audit` clean. +- `cargo test` (default) unchanged; `cargo test --features audit` green. +- Full exec→receipt→verify round-trip (C5+C6) incl. tamper + sidecar + rotation + + cross-midnight negatives. +- Secret-leak guard (C2) passes for request *and* response, incl. value-substring leaks. +- No production path calls `adapter.exec` directly (CI guard, C5). +- Release: **no new crate** — `signet-core` is a transitive dep; existing 8-crate + tag-triggered release (RELEASING.md) unchanged, minor bump. + +--- + +## Appendix — Codex impl-review round 1: findings & resolutions + +| # | Sev | Finding | Resolution in v2 | +|---|-----|---------|------------------| +| IR-1 | BLOCKER | `SigningKey`/`VerifyingKey` not signet root exports | §0/C1 add `ed25519-dalek = "2"`; use `ed25519_dalek::{SigningKey,VerifyingKey}` (C3/C4/C6) | +| IR-2 | BLOCKER | `Action.parent` doesn't exist | C2 `build_action` uses `parent_receipt_id: None` + real `trace_id`/`call_id` names | +| IR-3 | BLOCKER | `record` can't get `base_url` (not on `ExecContext`) | C5 `Router::exec` reads `adapter.manifest().base_url` and passes it into `record(ctx, base_url, …)` | +| IR-4 | BLOCKER | open mode can't populate `record_hash` (append not done) | C4 writer owns sign+append and returns `record_hash` on ack; `ReceiptHandle.record_hash: Option`; open mode returns `None` | +| IR-5 | HIGH | sequential writer ≠ cross-day chain integrity | C4 writer stamps **monotonic non-decreasing `ts_request`** → date files never regress; cross-midnight test | +| IR-6 | HIGH | key-only redaction fails the secret-leak guard | C2 redactor also masks **secret values** from `ctx.credentials` (equals/contains), any key | +| IR-7 | HIGH | Router feature-gating under-specified for no-audit | C5 `Router::exec` exists in **both** builds; only the `audit` field + `with_audit` + record block are `#[cfg]` | +| IR-8 | MEDIUM | `ResponseMeta` literal sites beyond C1's list | C1 file list expanded to all literals; `#[derive(Default)]` + `..Default::default()` migration | +| IR-9 | MEDIUM | `verify_compound` needs `&CompoundReceipt`, not `Value` | C6 `serde_json::from_value::` (or `verify_any(&json_string, key)`) | +| IR-10 | LOW | `append_violation` not a root export | C8 uses `signet_core::audit::append_violation` + `signet_core::evaluate_policy` | + +## Appendix — Codex impl-review round 2: verdicts & v3 closures + +Round-2 verdicts on v2 (RESOLVED kept as-is): IR-1, IR-2, IR-3, IR-4, IR-9, IR-10 +RESOLVED. IR-5/6/7/8 were PARTIAL → closed below. + +| ID | Sev | Problem | v3 closure | +|----|-----|---------|-----------| +| R2-1 | HIGH | Writer `last_ts` not seeded from existing logs → post-restart/skew fork | C4 `spawn_writer` seeds `last_ts` from the newest on-disk record before accepting jobs | +| R2-2 | HIGH | Clamped `ts_request` silently changes signed timestamp meaning | C2/C4: `ts_request` defined as audit-order time; **true `t0`/`t1` stored in signed `_relais_audit`** | +| R2-3 | HIGH | Cross-process chain ownership rule (design §4.7) missing from impl doc | C4: server owns chain; CLI = separate namespace; or global audit-dir lock; C5 acceptance asserts it | +| R2-4 | HIGH | OAuth `refresh_token` escapes value masking (`bearer_token()` omits it) | C2 `secret_values_of` matches all `CredentialData` variants (ApiKey/OAuth access+refresh/Cookie values) | +| R2-5 | MEDIUM | `Mutex` guard could cross `.await` (non-Send/deadlock) | C5 `record` mints in a tight sync scope and drops the guard before any await | +| R2-6 | MEDIUM | Open-mode dropped ack receiver could look like append failure | C4 `let _ = ack.send(...)`; post-append send failure is not an append failure | +| R2-7 | MEDIUM | Non-audit `base_url` binding unused → warning/error | C5 `base_url`+timing computed only inside the `#[cfg(audit)]`+`Some(sink)` branch | +| R2-8 | LOW | `ResponseMeta` literal inventory incomplete | C1: `rg "ResponseMeta\s*\{"`; added hackernews `:173`/`:208` | diff --git a/docs/design/signet-audit-integration.md b/docs/design/signet-audit-integration.md new file mode 100644 index 0000000..eebc7c1 --- /dev/null +++ b/docs/design/signet-audit-integration.md @@ -0,0 +1,609 @@ +# Design: Cryptographic call auditing via Signet + +- **Status:** Draft v4 (revised after Codex review rounds 1, 2 & 3) +- **Author:** willamhou (with Claude) +- **Date:** 2026-06-21 +- **Tracking:** — +- **Upstream:** [`Prismer-AI/signet`](https://github.com/Prismer-AI/signet) — `signet-core` on crates.io + +## 1. Problem + +relais is the gateway every agent action flows through: an agent calls `exec`, +relais injects a vault credential, hits an upstream API, and returns the result. +Today **nothing durable records that this happened.** There is no tamper-evident +trail of *who* asked relais to do *what*, against *which* upstream, with *what +outcome*. For an "agent internet gateway" that performs writes and deletes with +stored credentials, that is the missing accountability layer. + +The ask: **record every API call**, and not as a mutable log a compromised host +can rewrite — as **proof**. + +[Signet](https://github.com/Prismer-AI/signet) is a Rust-first +(`signet-core`, crates.io) cryptographic proof layer for agent tool calls: +each call becomes an **Ed25519-signed receipt**, receipts are **SHA-256 +hash-chained** into an append-only audit log, and the whole chain is +**offline-verifiable** from a public key — altering or deleting any record breaks +the chain. It is the same Rust/crates.io ecosystem as relais, so integration is a +dependency, not a bridge. + +**Goal:** every relais `exec` (one gateway action) emits a signed, hash-chained +receipt committing to the redacted request + response, written through one choke +point, with secrets never entering the receipt — and a CLI/CI path to verify the +chain against an *out-of-band trusted* gateway key. + +### 1.1 Goals + +- A **single choke point** so *every* exec path (HTTP server **and** CLI) is + recorded, with zero per-adapter changes. +- **One CompoundReceipt per relais `exec`** binding the redacted request, a + `sha256(JCS(response_envelope))` commitment, and request/response timestamps + (signet `sign_compound`). Outcome is encoded inside the hashed envelope — + `sign_compound` carries **no** separate signed `Outcome` (F-1). One receipt per + *gateway action*, **not** per internal upstream/provider HTTP call (F-5; §6 Q2). +- A **sidecar preimage store** (§4.6): because the chain stores only the response + *hash*, relais persists the redacted request+response **preimage** keyed by + receipt id, so `relais audit verify` can recompute the hash and an auditor can + actually read what happened (NF-6). The chain proves integrity; the sidecar makes + it legible. +- A hard **redaction boundary**: vault credentials and secret-typed request/response + fields never enter a receipt or sidecar. The receipt attests a **redacted + semantic request**, not literal upstream wire bytes (F-3, NF-2). +- **Hash-chained, append-only** storage written through a **single sequential + writer with a concrete enqueue/ack/timeout contract** so the chain can't fork or + hang the request path (F-6/F-7, NF-1). +- A **response-closed** failure mode (vs default response-open), plus a genuine + **pre-exec gate** for "deny before side effect" (F-2, §4.5/§4.8). +- A **trust-anchored, fail-closed** verification story: `relais audit verify` + **requires an out-of-band trust anchor** (no trust-on-first-use), validates + rotation in relais (signet's verify options are raw key vectors with no time + window), and puts the gateway key in `trusted_agent_pubkeys` — the correct field + for v1/v2 receipts (F-8, NF-4/NF-5, §4.11). +- Optional but designed-for: a **receipt handle** to the caller, **per-agent + attribution** (JWT subject), and **policy gating**. + +### 1.2 Non-goals (v1) + +- **signet's MCP middleware / proxy.** relais *is* the gateway; embedding + `signet-core` directly is cleaner than proxying. +- **Sub-call / fan-out receipts.** One receipt per relais exec; adapters that fan + out (notably `relais-llm-fallback`: page fetch **and** LLM-provider call under one + `exec`) get one gateway receipt. Per-upstream sub-receipts deferred (F-5, §6 Q2). +- **Field-level audit encryption.** signet's `append_encrypted` mutates + `action.params` and only the *signing* key can restore it for verification (NF-3); + encrypting fields with a separate key before `append` would invalidate the + signature. v1 is **redaction-only**; at-rest confidentiality, if needed, is + **transparent filesystem/volume encryption below signet** (signed bytes + unchanged), not field-level (§4.3). +- **Bilateral (v3) receipts.** v1 signs as the gateway only (`sign_compound`, v2). + Upstreams don't counter-sign (§6 R2). +- **Full policy engine.** signet policy hooks are wired as an *optional* pre-exec + gate (§4.8), not a v1 requirement. +- **Replacing operational logging / metrics.** This is the *proof* layer. +- **Remote/centralized audit sink.** v1 is local; shipping is deployment-side (R4). +- **Redacting the caller-facing response.** This feature redacts the *audit log* + only. The response relais returns to the calling agent is unchanged — it is the + agent's own data over its own authenticated channel, and redacting it would break + legitimate uses (an upstream may return a token the agent needs). An upstream that + echoes an injected credential reaches the caller exactly as before this feature (no + new exposure); only the *log* is sanitised. + +## 2. Current state (what we integrate against) + +- **Two exec call sites, no shared choke point.** `Router` (`crates/core/src/router.rs`) + only registers/looks up adapters; each caller calls the adapter directly: + - `crates/server/src/handlers.rs:165` — `adapter.exec(&ctx).await` + - `crates/cli/src/commands/exec.rs:76` — `adapter.exec(&ctx).await` +- **`Adapter::exec(&ExecContext) -> Result`** + (`crates/core/src/adapter.rs`) is the universal unit of work. It stays `pub` + (tests call it directly); the choke point is enforced by routing + a CI guard, not + by hiding the method (F-10, §4.1). +- **`ExecContext { site, resource, action, params: Value, credentials: Option }`** + — carries **`credentials`** (the redaction hazard, §4.3). +- **`Response { data: Value, meta: ResponseMeta }`** — responses themselves may carry + secrets (scs login → `acs_token`), so responses are redacted before hashing too + (F-11, §4.3). +- **Adapters inject auth *after* `ExecContext`** (scs-legacy puts `acs_token` in + query for GET / body for POST). So the sink never sees the injected secret, **and** + cannot attest the literal wire request (F-3). The resolved endpoint path also lives + *inside* adapter code, not in core metadata (NF-8, §4.2). +- **Vault** establishes the `~/.relais` on-host secret-store precedent. + +### 2.1 Relevant signet-core API (exact signatures, verified against the source) + +```rust +// receipt.rs — Action has a FIXED field set; there is no "descriptor" field (NF-2) +pub struct Action { pub tool: String, pub params: serde_json::Value, pub params_hash: String, + pub target: String, pub transport: String, pub session: Option, + pub call_id: Option, pub response_hash: Option, + pub trace_id: Option, pub parent_receipt_id: Option } +pub struct CompoundReceipt { pub v: u8 /*=2*/, pub id: String, pub action: Action, + pub response: Response /* { content_hash, outcome: Option } */, pub signer: Signer, + pub ts_request: String, pub ts_response: String, pub nonce: String, pub sig: String } + +// sign.rs +pub fn sign_compound(key: &SigningKey, action: &Action, response_content: &serde_json::Value, + signer_name: &str, signer_owner: &str, ts_request: &str, ts_response: &str) + -> Result; // sets response.outcome = None (F-1) +pub fn sign(key: &SigningKey, action: &Action, signer_name: &str, signer_owner: &str) + -> Result; + +// audit.rs +pub fn append(dir: &Path, receipt: &serde_json::Value) -> Result; +pub fn append_encrypted(dir: &Path, receipt: &serde_json::Value, signing_key: &SigningKey) + -> Result; // encrypts params UNDER THE SIGNING KEY — not used (NF-3) +pub fn verify_chain(dir: &Path) -> Result; +pub fn verify_signatures(dir: &Path, filter: &AuditFilter) -> Result; +pub fn verify_signatures_with_options(dir: &Path, filter: &AuditFilter, opts: &AuditVerifyOptions) + -> Result; +pub fn query(dir: &Path, filter: &AuditFilter) -> Result, SignetError>; +// AuditFilter has ONLY { since, tool, signer, limit } — no receipt-id, no end-time (NF-4) +pub struct AuditVerifyOptions { // EXACTLY two fields, no others (NF-5/RD-4) + pub trusted_server_pubkeys: Vec, // v3 bilateral only + pub trusted_agent_pubkeys: Vec, // v1/v2 signer constraint — what relais uses +} +// Per-receipt verification (for the rotation-window path, §4.11): +pub fn verify_compound(receipt: &CompoundReceipt, pubkey: &VerifyingKey) -> Result<(), SignetError>; +pub fn verify_any(receipt_json: &str, pubkey: &VerifyingKey) -> Result<(), SignetError>; + +// identity::fs_ops — EXACT signatures; keys live under dir/keys/.{key,pub} (NF-7) +pub fn generate_and_save(dir: &Path, name: &str, owner: Option<&str>, passphrase: Option<&str>, + kdf_params: Option) -> Result; +pub fn load_signing_key(dir: &Path, name: &str, passphrase: Option<&str>) + -> Result; +pub fn export_public_key(dir: &Path, name: &str) -> Result; +pub fn default_signet_dir() -> PathBuf; // SIGNET_HOME or ~/.signet +``` + +Facts that shaped this design: + +- **`sign_compound` always sets `response.outcome = None`** (`sign.rs:236-239`): + outcome lives inside the hashed `response_content`, not a signed field (F-1, §4.2). +- **The chain stores the receipt verbatim, but the response is only a + `content_hash`** — the response *preimage* is not in the chain, so it is + unauditable without a sidecar (NF-6, §4.6). +- **`append` dates v2 receipts from `ts_request`** (`extract_timestamp`, + `audit.rs:135-143`) — OPEN-1 closed (F-13); pinned by an M1 regression test. +- **`append` locks a per-*date* `.jsonl.lock` and rereads the tail under it; + `last_record_hash` scans newest-file-first without a global lock** — concurrent or + multi-process / cross-midnight writes can fork the chain (F-6/F-7, NF-1, §4.7). +- **`append_encrypted` encrypts under the signing key and restoration for + verification needs that key** (`audit.rs:485-511,608-615`) — unusable for a + shareable log; v1 uses redaction (NF-3, §4.3). +- **Identity keys live under `dir/keys/.{key,pub}`** and the fs_ops helpers + take `dir`/`passphrase` and return `KeyInfo`/`PubKeyFile` (NF-7, §4.4). + +## 3. Proposed design — overview + +``` + server handler ─┐ + ├─► Router::exec(ctx) ─► adapter.exec(ctx) ─► Response/Err + cli exec ────┘ │ + [optional pre-exec policy gate §4.8 — before any side effect] + ▼ (audit feature on, sink configured) + AuditSink.record(ctx, &result, t0, t1): + 1. envelope = { request: redact(ctx.params) + _relais_audit{auth_injection, + credential_ref(opaque)}, response: redact_response(...) } §4.2,§4.3 + 2. action = Action{ tool="{site}.{resource}.{action}", params=envelope.request, + target=manifest.base_url, transport, session, trace_id, call_id } + 3. receipt = sign_compound(key, action, envelope.response, "relais", owner, t0, t1) + 4. AuditCommand{ receipt, sidecar=envelope, ack:oneshot } ─► single writer task ─► + persist sidecar(receipt.id) ; audit::append(dir, receipt) §4.6,§4.7 + 5. (optional) ResponseMeta.receipt = { id, record_hash } §4.6,§4.10 +``` + +One埋点, two callers, one receipt per gateway action, integrity in the chain + +legibility in the sidecar. + +## 4. Proposed design — detail + +### 4.1 The choke point: `Router::exec` + +```rust +// crates/core/src/router.rs +impl Router { + pub async fn exec(&self, ctx: &ExecContext) -> Result { + let adapter = self.get(&ctx.site).ok_or(AdapterError::SiteNotFound)?; + // optional pre-exec policy gate (§4.8) runs HERE, before any side effect + let t0 = Utc::now(); + let result = adapter.exec(ctx).await; + let t1 = Utc::now(); + if let Some(sink) = &self.audit { + self.audit_outcome(sink, ctx, &result, t0, t1).await?; // response-open vs -closed §4.5 + } + result + } +} +``` + +- `handlers.rs:165` and `exec.rs:76` switch from `adapter.exec` to `router.exec`. +- **Convention, enforced.** `Adapter::exec` stays `pub`; the choke point is backed by + (a) all *production* paths going through `Router::exec`, (b) a **CI guard** + (grep/lint) forbidding direct `\.exec(` on an adapter outside adapter-internal + tests, (c) **audit-aware test helpers** (F-10). +- **Adapters untouched.** Record after exec so the receipt binds the real outcome. + +### 4.2 Data mapping (`ExecContext`/`Response` → signet) + +| signet `Action` field | relais source | +|---|---| +| `tool` | `"{site}.{resource}.{action}"` (e.g. `scs.order.create`) — the routable identity | +| `params` | the **redacted request envelope** incl. `_relais_audit` (§4.3) | +| `target` | exactly **`adapter.manifest().base_url`** (one URL string) — *not* the resolved endpoint path, which lives in adapter code (NF-8). The site id is already in `tool`, so `target` carries no extra encoding (RD-5). A redacted endpoint template is a later optional adapter-provided field. | +| `transport` | `"https"` (upstream scheme) | +| `session` | server: JWT subject; CLI: local user / `null` | +| `trace_id` / `call_id` | per-request id / relais exec id | + +**Outcome is in the hashed `response_content`** (F-1), and `ok` is named honestly as +**transport-level** (NF-6): + +```jsonc +// response_content (the preimage we hash AND store as a sidecar, §4.6) +{ "transport_ok": true, "data": , + "business_status": "ok" | "error" | "unclassified" } // optional, §6 Q1 +// failure +{ "transport_ok": false, "error": { "kind": "", "message": "" } } +``` + +> **Business-error nuance (Q1).** relais returns HTTP-200-plus-`err_code` bodies as +> `Ok(Response)` verbatim → `transport_ok:true`. The body (with `err_code`) is inside +> the hashed/sidecar'd `data`, so the business failure is *auditable*, and +> `transport_ok` no longer overstates success. Populating `business_status` waits on +> the auto-adapter-pipeline `ResponseMeta.business` work (§6 Q1). + +### 4.3 Redaction boundary — the #1 hazard + +The receipt+sidecar attest a **redacted semantic request and response**, explicitly. + +1. **Credentials structurally excluded.** `Action` is built from `ctx` *without* + `ctx.credentials`; adapters inject the real auth *after* the snapshot. +2. **Audit metadata rides inside `params`, since signet `Action` has no descriptor + field** (NF-2). Under a reserved `params._relais_audit` key: + ```jsonc + "_relais_audit": { + "auth_injection": "acs_token->query", // which rule applied; non-secret + "credential_ref": "kref_3f9c…" // OPAQUE, non-reversible label (NF-2) + } + ``` + The `credential_ref` is **not** the vault site id or a hash of the token (a hash + of a low-entropy token is brute-forceable, and raw vault/site ids leak + tenant/env/prod-vs-staging). It is a random opaque handle minted per credential, + resolvable only via a local, non-exported map. **Exported logs omit + `credential_ref` by default.** +3. **Request redaction.** A configurable redactor masks sensitive keys before + signing — default denylist `token`, `password`, `secret`, `authorization`, + `api_key`, `acs_token`, `cookie`, `*_token`. The hash is over the redacted form. +4. **Response redaction (F-11).** `redact_response` applies the same denylist to + `response.data` before it enters the hashed envelope, **or** the site opts the + response out of retention. A hash over an unredacted secret-bearing body is itself + a weak secret commitment and must not be shared. +5. **At-rest confidentiality, if required, is transparent filesystem/volume + encryption *below* signet** (NF-3) — the bytes signet signs/appends are unchanged, + so `verify_signatures`/`verify_chain` still work with the public key. signet's + `append_encrypted` (encrypt-under-signing-key) is **not** used: it would force + auditors to hold the forging key and would change the signed payload. v1 default + is plain redaction. +6. **Belt-and-braces guard:** a CI assertion that the serialized receipt **and** + sidecar for a representative credential-bearing `ExecContext` contain none of its + secret bytes (request *and* response). + +### 4.4 Key & audit storage layout + +signet's `fs_ops` write keys under `dir/keys/.{key,pub}` (NF-7); mirror that +and add the sidecar store: + +``` +~/.relais/signet/ # RELAIS_SIGNET_DIR overrides; passed as `dir` to fs_ops +├── keys/ +│ ├── relais.key # gateway signing key (generate_and_save, 0600, opt. passphrase) +│ └── relais.pub # verifying key — distributable +├── trusted_keys.json # OUT-OF-BAND trust anchor: accepted pubkeys + status/window (§4.11) +├── credential_refs.json # opaque credential_ref → vault binding (local-only, never exported) +├── audit/ +│ └── 2026-06-21.jsonl # per-date hash-chained log (audit::append) +└── sidecars/ + └── .json # redacted request+response preimage (§4.6) +``` + +- Key generated once via `relais audit init` (not silently on first run — see §4.11 + on TOFU). Private key `0600`, optional passphrase. +- Signing identity: `signer_name="relais"`, `signer_owner=`. + +### 4.5 Failure model (config switch) + +A post-exec receipt cannot un-happen a side effect, so no post-exec mode is truly +"no receipt ⇒ no action." + +- **`response-open` (default).** Deliver the result; on sink failure emit a loud + `error!` + metric. +- **`response-closed`.** If the receipt cannot be committed, the **response is + withheld** (caller gets an error). The upstream effect may already have happened — + this guarantees *the caller gets no result without a receipt*, not *no side effect + without a receipt*. Bounded by the writer timeouts in §4.7 so it can't hang + forever. +- **True pre-exec gating** ("deny before side effect") is **only** §4.8 — a signed + intent/pending record written **before** `adapter.exec`, finalized after, with a + recovery sweep for stuck `pending` entries. + +`RELAIS_AUDIT_MODE=open|closed`, per-site override reserved. + +### 4.6 Sidecar preimage store, receipt handles & verification + +The chain stores only `response.content_hash`; to make receipts **legible and +re-verifiable** (NF-6): + +- **Sidecar store.** The single writer persists `sidecars/.json` = + the exact redacted `{ request, response }` envelope it hashed, written **before** + (or atomically with) the chain append. `sidecar.response` must be the *same + `serde_json::Value`* passed to `sign_compound`, so the recompute is exact. +- **Exact recompute formula (RD-1, the crux).** signet stores the response + commitment as `"sha256:" + hex(sha256(json_canon::to_string(&response_content)))` + (`sign.rs:233-238`, JCS via `json_canon`). `relais audit verify` must reproduce + **byte-for-byte, including the `sha256:` prefix**: + ```rust + let canon = json_canon::to_string(&sidecar.response)?; // same JCS as signet + let expected = format!("sha256:{}", hex::encode(Sha256::digest(canon.as_bytes()))); + assert_eq!(expected, receipt.response.content_hash); + ``` + The request side is checked against `receipt.action.params_hash` using signet's + *same* params-hashing rule (also `sha256:`-prefixed). A raw-digest comparison that + omits the prefix always fails — pinned by an M2 cross-check test against a real + `sign_compound` output. A missing/mismatched sidecar is a reported failure. +- **`ResponseMeta.receipt: Option`** (`{ id, record_hash }`), always + present, serde-defaulted (§4.10) — a proof handle for the caller. +- **CLI / CI verification is trust-anchored & fail-closed** (NF-4/NF-5): + - `relais audit verify` → `verify_chain` + `verify_signatures_with_options` with + the gateway pubkey(s) in **`AuditVerifyOptions.trusted_agent_pubkeys`** (v1/v2 + receipts are checked against *agent* keys; `trusted_server_pubkeys` is v3-only). + **An empty trusted set is a hard error** — no self-reported-key acceptance. + - Rotation/time-window acceptance is evaluated **in relais** over `query()` (signet + options are flat key vectors with no time semantics), selecting the key valid at + each receipt's `ts_request` (§4.11). + - `relais audit tail` / `relais audit init` / `relais audit pubkey` round out UX. + - CI verifies an **exported sample** with a **pinned, out-of-band** trusted pubkey. + +### 4.7 Single sequential writer — concrete contract (F-6/F-7, NF-1) + +```rust +struct AuditCommand { receipt: serde_json::Value, sidecar: Envelope, ack: oneshot::Sender } +// one writer task owns the chain: +// - recv AuditCommand from a BOUNDED mpsc (capacity N) +// - persist sidecar, then audit::append(dir, &receipt) on a spawn_blocking thread +// (append takes a file lock + does blocking I/O — never on the async reactor) +// - send AuditResult back on `ack` +``` + +- **The append is never aborted (RD-3).** `audit::append` takes a file lock and is + not cancellable; aborting a timed-out append and letting the next command run would + race two writers on one chain. So the **single writer task processes one + `AuditCommand` at a time, start to finish, in order** — the append always runs to + completion. Timeouts apply to the **caller's wait for the `ack`**, *not* to the + writer's execution. +- **Enqueue:** `response-open` uses `try_send`; on full channel → drop-with-`error!` + + metric (never blocks the request). `response-closed` uses `send_timeout`; on + timeout → deterministic `AdapterError::AuditUnavailable`. +- **Caller ack timeout:** the request may stop *waiting* after a bounded deadline + (response-closed → `AuditUnavailable`), but the in-flight append still completes in + order on the writer; the receipt is not lost and no second writer starts. If the + writer is durably wedged, a health signal trips the sink (open → log, closed → + refuse new calls) rather than abandoning an append. +- **Crash/shutdown:** writer panics are surfaced (sender dropped → enqueue error); + graceful shutdown **drains the channel with a bounded deadline** before exit; a + dropped `ack` (writer gone) maps to audit failure, never a silent success. +- **Single chain owner across processes** (F-7): the server owns the chain. The CLI + either (a) writes a **separate chain namespace** (own `signer_owner` + dir) — the + v1 default, fork-proof — or (b) opts into a **global cross-process lock over the + whole audit dir** (never per-date). Date rollover must not create independent lock + domains. + +### 4.8 Optional: policy gating (pre-exec) + +``` +if policy configured: // runs before adapter.exec + eval = evaluate_policy(action_intent, signer, policy) + Deny → append_violation(...); return AdapterError::PolicyDenied (no exec) + RequireApproval → append_violation(...); return AdapterError::NeedsApproval (no exec) + Allow → (optional signed pending record) exec, then sign_compound +``` + +The only mode that prevents a side effect. Natural fit with `Method::{Write,Delete}` +(default policy can require approval for destructive methods). v1 ships the hook + a +trivial allow-all policy. + +### 4.9 Optional: per-agent attribution (server) + +- **v1:** JWT subject into `Action.session`/`trace_id` (inside the signed payload). +- **Later:** signet **delegation chains** (`sign_authorized`) — needs agents to hold + signet identities (§6 R3). + +### 4.10 Required core-type changes (`crates/core/src/types.rs`) + +Additive and **wire-compatible across audit / non-audit builds** (F-12) — field +always present, only its *population* is feature-gated: + +```rust +pub struct ResponseMeta { + pub pagination: Option, + pub rate_limit: Option, + pub cached: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt: Option, +} +pub struct ReceiptHandle { pub id: String, pub record_hash: String } +``` + +- **`Router` gains `audit: Option`** + async `Router::exec`. `AuditSink` + lives in `relais-core` behind the `audit` feature. +- No change to `Adapter`, `ExecContext`, `Credentials`, `AuthType`. + +### 4.11 Trust anchor & key rotation (F-8, NF-4, NF-5) + +signet `verify_signatures*` accept **raw key vectors with no status/time window**, +and v1/v2 receipts are only constrained when `trusted_agent_pubkeys` is non-empty +(else self-reported keys pass). So relais owns trust: + +- **No trust-on-first-use.** `relais audit init` generates a key but does **not** + auto-trust it for verification. Verification requires an **out-of-band trust + anchor**: `trusted_keys.json` must be provisioned deliberately (copied to the + verifier / pinned in CI). `relais audit verify` **fails closed** if no explicit + anchor is supplied. +- **Field correctness (NF-5).** Gateway keys (v1/v2 signer) go in + `trusted_agent_pubkeys`; `trusted_server_pubkeys` stays empty (v3-only). +- **Rotation in relais (NF-4, RD-2).** `trusted_keys.json` records each pubkey with + an `active`/`retired` status and a validity window. signet can't enforce windows, + **and `verify_signatures_with_options` selects records by `AuditFilter` (only + `{since, tool, signer, limit}` — no receipt-id or end-time)**, so it can't be aimed + at one receipt. The windowed path therefore does **not** go through that call. + Instead `relais audit verify`: + 1. `query()` (or reads the chain) to enumerate records in order, plus + `verify_chain` for hash-link integrity; + 2. per record, selects the trusted key whose window contains the receipt's + `ts_request` (rejecting if none); + 3. verifies that single receipt directly with **`verify_compound` / `verify_any`** + against the selected key; + 4. recomputes the sidecar hash (§4.6). + + `verify_signatures_with_options(trusted_agent_pubkeys = all currently-active keys)` + remains the fast path when rotation windows aren't needed. A new key's activation is + a **rotation event signed by the prior key** (or an offline root) appended to the + chain. +- **Custody caveat (R6):** a host-local key proves "this gateway", not "an + untampered host"; theft enables *future* forgery but not undetected past-rewrite. + HSM/remote-signer out of scope. + +## 5. Phasing + +1. **M1 — core sink + choke point.** `audit` feature on `relais-core`; `AuditSink` + (key load/gen via fs_ops with correct `dir`/layout, request+response redactor, + `_relais_audit` metadata + opaque `credential_ref`, outcome envelope, + `sign_compound`); the **writer task with the §4.7 contract**; sidecar store + (§4.6); `Router::exec` + server/CLI rewired + CI guard (F-10); response-open/closed + switch; redaction guard test (§4.3.6) + v2 `ts_request` dating regression test. +2. **M2 — verification, trust & UX.** `relais audit verify|tail|init|pubkey`; + `trusted_keys.json` + **fail-closed** `verify_signatures_with_options` + (`trusted_agent_pubkeys`) + relais-side rotation-window check; sidecar hash + recompute in verify; `ResponseMeta.receipt`; CI verify job with a pinned + out-of-band pubkey. Dogfood against scs adapters end-to-end. +3. **M3 — policy, attribution & rotation events (optional).** Pre-exec policy gate + + pending/intent records (§4.8); JWT subject in receipts (§4.9 v1); signed key + rotation events (§4.11); design note for delegation chains and per-upstream + sub-receipts (§6 Q2). + +## 6. Risks / open questions + +- **Q1 — business-error classification.** Keep HTTP-200-`err_code` as + `transport_ok:true` (v1), add `business_status` once `ResponseMeta.business` + lands. *(open; v1 = passthrough, body auditable via sidecar)* +- **Q2 — receipt granularity.** Per-exec vs per-upstream-call (matters for + `llm-fallback` fetch+LLM). v1 = per exec. *(open)* +- **Q3 — verify trust model.** Out-of-band trusted `trusted_agent_pubkeys`, fail + closed on empty, relais-side rotation window. *(decided)* +- **OPEN-2 — response-open honesty.** Post-exec receipts can be lost after a side + effect; only the pre-exec gate (§4.8) prevents it. *(open, named honestly)* +- **R2 — bilateral receipts.** Upstreams won't counter-sign. *(deferred)* +- **R3 — per-agent delegation.** Needs agent-held signet identities. *(deferred)* +- **R4 — log/sidecar growth & shipping.** Per-date jsonl + per-receipt sidecars grow + unbounded; rotation/retention/shipping are deployment concerns. *(open)* +- **R8 — tail truncation.** Deleting the newest record(s) leaves a shorter, + internally-valid chain. Mid-chain edits are detected; tail truncation needs an + out-of-band checkpoint. **Mitigation:** `audit_verify` returns the chain `head` + and accepts an `expected_head` (`relais audit verify --head `); operators + who retain the head off-host detect truncation. *(mitigated; off-host retention + is deployment-side)* +- **R9 — cross-process writers.** Multiple processes (server + CLI) sharing one + audit dir are serialised by a **dir-wide exclusive lock** held across + sign+sidecar+append, with the timestamp re-clamped to the on-disk latest under the + lock — so the chain stays linear across processes. *(addressed)* +- **R5 — performance.** Ed25519 + JCS per call + a single serialized writer caps + throughput; batching within the writer's total order is allowed, cross-writer + batching is not. Measure in M1. *(open)* +- **R6 — key custody.** Host-local key ⇒ forgeable-going-forward if stolen; past + chain still tamper-evident. *(open)* + +## 7. Alternatives considered + +- **signet MCP proxy in front of relais** — relais is already the choke point; + proxying adds a hop + a process. Direct embedding is simpler. +- **Plain structured logging** — mutable, forgeable, not offline-verifiable. +- **Per-adapter receipts** — duplicated, easy to forget, misses the CLI path. +- **Sign request only (`sign`)** — loses the response commitment. +- **`append_encrypted` under the signing key** — rejected (NF-3): forces auditors to + hold the forging key and breaks public-key-only verification. Use redaction or + transparent below-signet encryption. +- **No sidecar (hash-only chain)** — rejected (NF-6): the response body would be + unauditable; you could prove integrity but never read what came back. + +--- + +## Appendix A — Codex review round 1: findings & resolutions + +| # | Sev | Finding | Resolution | +|---|-----|---------|------------| +| F-1 | BLOCKER | `sign_compound` sets `outcome=None`; signed-Outcome unimplementable | §4.2 outcome in hashed `response_content`; §2.1 documents it | +| F-2 | HIGH | "fail-closed" still post-side-effect | §4.5 renamed response-open/closed; pre-exec gate §4.8 | +| F-3 | HIGH | Redacted params ≠ actual wire request | §4.3 redacted *semantic* request + injection descriptor | +| F-4 | HIGH | `append_encrypted` under signing key breaks pubkey verify | §4.3.5 redaction-only / below-signet encryption (see NF-3) | +| F-5 | HIGH | One exec ≠ every external call (llm-fallback) | §1.1/§1.2 one receipt per gateway action; sub-receipts deferred | +| F-6 | HIGH | `append` per-day lock + tail reread on hot path | §4.7 single sequential writer off the request path | +| F-7 | HIGH | Multi-process writers fork the chain | §4.7 single chain owner / global (not per-date) lock | +| F-8 | HIGH | Key rotation/trust underspecified; self-reported keys | §4.11 trusted anchor + relais-side rotation (see NF-4/5) | +| F-9 | MEDIUM | API signatures imprecise | §2.1 exact signatures (identity fixed in NF-7) | +| F-10 | MEDIUM | Choke point convention-only | §4.1 keep `pub` + CI guard + audit test helpers | +| F-11 | MEDIUM | Response redaction unspecified | §4.3.4 `redact_response` before hashing | +| F-12 | MEDIUM | `ResponseMeta.receipt` wire-compat | §4.10 serde-default, present in all builds | +| F-13 | LOW | OPEN-1 not real | §2.1 closed; v2 dated by `ts_request`, M1 regression test | + +## Appendix B — Codex review round 2: verdicts & v3 closures + +Round-2 verdicts on v2 (RESOLVED kept as-is in v3): + +| # | R2 verdict | v3 closure | +|---|-----------|-----------| +| F-1, F-5, F-7, F-10, F-11, F-12, F-13 | RESOLVED | — | +| F-2 | RESOLVED | — | +| F-3 | PARTIAL | NF-2: `_relais_audit` under `params` (no signet descriptor field); opaque `credential_ref` | +| F-4 | PARTIAL | NF-3: drop field-level audit encryption; transparent below-signet encryption only | +| F-6 | PARTIAL | NF-1: concrete writer contract (enqueue/ack/timeout/`spawn_blocking`/drain) | +| F-8 | PARTIAL | NF-4/NF-5: out-of-band anchor, fail-closed, relais-side rotation window, `trusted_agent_pubkeys` | +| F-9 | PARTIAL | NF-7: identity signatures + `dir/keys/.{key,pub}` layout corrected | + +New problems round 2 introduced/surfaced, closed in v3: + +| ID | Sev | Problem | v3 closure | +|----|-----|---------|-----------| +| NF-1 | HIGH | Response-closed writer could hang/lose acks | §4.7 `AuditCommand{ack:oneshot}`, bounded `send_timeout`, append timeout, `spawn_blocking`, bounded shutdown drain | +| NF-2 | HIGH | No signet descriptor field; credential refs leak | §4.3.2 `params._relais_audit` + opaque non-reversible `credential_ref`, omitted from exports | +| NF-3 | HIGH | Separate audit encryption breaks signet verify | §4.3.5 redaction-only v1; transparent filesystem encryption below signet if needed | +| NF-4 | HIGH | Trust bootstrap/rotation not specified (TOFU hole) | §4.11 out-of-band anchor required; `verify` fails closed on empty; rotation window enforced in relais over `query()` | +| NF-5 | HIGH | Wrong trusted-key field → silent v2 self-trust | §4.6/§4.11 gateway key → `trusted_agent_pubkeys`; empty set = hard error | +| NF-6 | HIGH | Outcome envelope is hash-only; `ok:true` misstates business failure | §4.6 sidecar preimage store + verify recompute; §4.2 `transport_ok` + `business_status` | +| NF-7 | HIGH | Identity API + key layout wrong | §2.1/§4.4 `generate_and_save(dir,name,owner,passphrase,kdf)->KeyInfo`; `dir/keys/.{key,pub}` | +| NF-8 | MEDIUM | Router can't know resolved upstream path | §4.2 `target` = site id + `base_url` only; endpoint template a later optional adapter field | + +**Still open (tracked, not closed):** Q1 (business classification), Q2 (receipt +granularity), OPEN-2 (response-open honesty), R2 (bilateral), R3 (delegation), R4 +(growth/shipping), R5 (perf ceiling), R6 (key custody). + +## Appendix C — Codex review round 3: verdicts & v4 closures + +Round-3 verdicts on v3: + +| # | R3 verdict | v4 closure | +|---|-----------|-----------| +| NF-2, NF-3, NF-5, NF-7, NF-8 | RESOLVED | — | +| NF-1 | PARTIAL | RD-3: append is **never aborted**; single writer runs each append to completion in order; only the *caller's ack wait* times out (§4.7) | +| NF-4 | PARTIAL | RD-2: windowed verify can't use `verify_signatures_with_options` (`AuditFilter` has no receipt-id/end-time); §4.11 switches to per-record `verify_compound`/`verify_any` over `query()` | +| NF-6 | PARTIAL | RD-1: §4.6 now gives the exact `"sha256:" + hex(sha256(JCS(...)))` recompute, prefix included | + +New problems round 3 surfaced, closed in v4: + +| ID | Sev | Problem | v4 closure | +|----|-----|---------|-----------| +| RD-1 | BLOCKER | Sidecar recompute omitted the literal `sha256:` prefix → verify always fails | §4.6 exact formula `"sha256:"+hex(sha256(json_canon::to_string))`, same rule for `params_hash`; M2 cross-check test | +| RD-2 | HIGH | Rotation-window verify not buildable via `verify_signatures_with_options` (filter can't target one receipt) | §4.11 per-record `query()` + `verify_compound`/`verify_any` with the window-selected key | +| RD-3 | HIGH | `spawn_blocking` append + timeout has no safe in-flight/abort semantics → 2 writers race | §4.7 append never aborted; single sequential writer; timeout only on caller's ack wait | +| RD-4 | LOW | `AuditVerifyOptions` trailing `..` implied non-existent fields | §2.1 spelled as the exact two-field struct, no `..` | +| RD-5 | LOW | `target` single-string encoding unspecified | §4.2 `target` = exactly `manifest().base_url`; site id stays in `tool` | + +After three rounds the open items are all explicitly-deferred risks (Q1/Q2/OPEN-2/ +R2–R6), not unresolved blockers. **The spec is ready to proceed to the +implementation doc.**