From e518f07d3af39f80276ddc64b79d5e2b490306df Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 21:03:55 +0100 Subject: [PATCH 1/8] witness-node: sync-registry subcommand (fetch refs/auths/*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anchor role resolves party keys from the tree at refs/auths/registry, but a plain `git clone` fetches only refs/heads/* — so an operator who cloned the registry still 422s every submission (this is the RT-2 root cause: auths-w1's /data/registry has no refs/auths/registry). Add `witness-node sync-registry --from --registry `: init the dir, force-fetch +refs/auths/*:refs/auths/*, and confirm the result is resolvable (registry_ready) before returning. HTTPS in-process via git2's own transport (the workspace git2 is transportless; opt in here as packages/auths-node does). Mirrors the SDK's fetch_registry. Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- Cargo.lock | 35 ++++++++++++- crates/auths-witness-node/Cargo.toml | 4 ++ .../src/bin/witness_node.rs | 31 +++++++++++ crates/auths-witness-node/src/lib.rs | 1 + crates/auths-witness-node/src/sync.rs | 52 +++++++++++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 crates/auths-witness-node/src/sync.rs diff --git a/Cargo.lock b/Cargo.lock index e62f015c..11e02128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,7 @@ dependencies = [ "chrono", "clap", "ed25519-dalek", + "git2", "hex", "http-body-util", "parking_lot", @@ -3487,6 +3488,9 @@ dependencies = [ "libc", "libgit2-sys", "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", ] [[package]] @@ -4366,6 +4370,7 @@ dependencies = [ "cc", "libc", "libz-sys", + "openssl-sys", "pkg-config", ] @@ -4939,12 +4944,40 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -6091,7 +6124,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.7.0", diff --git a/crates/auths-witness-node/Cargo.toml b/crates/auths-witness-node/Cargo.toml index b6800245..9203a606 100644 --- a/crates/auths-witness-node/Cargo.toml +++ b/crates/auths-witness-node/Cargo.toml @@ -43,6 +43,10 @@ thiserror.workspace = true # Only the OS-backed `OsRng` is used (the workspace bans `thread_rng`/`random`). rand = { workspace = true } hex = "0.4" +# Registry sync: the workspace git2 is deliberately transportless; the node +# opts into HTTPS to fetch the parties' public `refs/auths/*` registry in-process +# (same posture as packages/auths-node). A plain `git clone` would miss that ref. +git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2", "https", "vendored-openssl"] } # The serve surface: hardened HTTP ingest for the anchor role (body cap, # concurrency limit, timeout — the same posture as the KEL witness server). axum = { version = "0.8" } diff --git a/crates/auths-witness-node/src/bin/witness_node.rs b/crates/auths-witness-node/src/bin/witness_node.rs index 54540be0..41865dc1 100644 --- a/crates/auths-witness-node/src/bin/witness_node.rs +++ b/crates/auths-witness-node/src/bin/witness_node.rs @@ -42,6 +42,21 @@ enum Command { Serve(ServeArgs), /// Probe a node's /health endpoint (container healthcheck entrypoint). Healthcheck(HealthcheckArgs), + /// Fetch the parties' public registry into `--registry` (the `refs/auths/*` + /// namespace the anchor role resolves keys from). Run before `serve`, or as + /// an init step — a plain `git clone` does NOT bring these refs. + SyncRegistry(SyncRegistryArgs), +} + +#[derive(Parser)] +struct SyncRegistryArgs { + /// Registry git URL to fetch the parties' public KELs from (must expose + /// `refs/auths/*`), e.g. the aggregated first-party registry. + #[arg(long, value_name = "URL")] + from: String, + /// Local registry dir to populate — the same path passed to `serve --registry`. + #[arg(long, value_name = "DIR")] + registry: PathBuf, } #[derive(Parser)] @@ -208,6 +223,22 @@ async fn main() -> std::process::ExitCode { std::process::ExitCode::FAILURE } }, + Command::SyncRegistry(args) => { + match auths_witness_node::sync::sync_registry(&args.from, &args.registry) { + Ok(()) => { + eprintln!( + "witness-node: synced registry from {} → {}", + args.from, + args.registry.display() + ); + std::process::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("witness-node: sync-registry: {e:#}"); + std::process::ExitCode::FAILURE + } + } + } Command::Serve(args) => { for role in &args.roles { match role.as_str() { diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index ac969b54..ded192e2 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -46,6 +46,7 @@ pub mod registry; pub mod signer; pub mod sqlite_store; pub mod standup; +pub mod sync; pub mod vocabulary; pub use anchor_role::{ diff --git a/crates/auths-witness-node/src/sync.rs b/crates/auths-witness-node/src/sync.rs new file mode 100644 index 00000000..226d7798 --- /dev/null +++ b/crates/auths-witness-node/src/sync.rs @@ -0,0 +1,52 @@ +//! Registry sync: populate the witness's `--registry` with the parties' public +//! KELs by fetching the custom `refs/auths/*` namespace. +//! +//! The anchor role resolves a submitter's keys from the tree at +//! `refs/auths/registry` (see [`crate::registry`]). A plain `git clone` only +//! fetches `refs/heads/*` + tags, so it leaves that ref — and therefore every +//! identity — absent, which is why an operator who "cloned" the registry still +//! 422s every submission. This fetches the `refs/auths/*` namespace explicitly, +//! in-process (git2's own HTTPS transport — no `git` binary needed), mirroring +//! the SDK's `fetch_registry`. + +use std::path::Path; + +use crate::registry::registry_ready; + +/// Fetch or refresh the parties' public registry at `registry` from `url`. +/// +/// Idempotent: creates and initializes the repo if absent, then force-fetches +/// `refs/auths/*` (the namespace [`crate::registry`] reads). Confirms the sync +/// produced a resolvable registry (`refs/auths/registry` present) before +/// returning, so a wrong URL or an empty remote fails loudly here rather than +/// as a later 422 on every submission. +/// +/// Args: +/// * `url`: the aggregated registry's git URL (must expose `refs/auths/*`). +/// * `registry`: the local dir the node serves with `--registry`. +/// +/// Usage: +/// ```ignore +/// sync_registry("https://github.com/auths-dev/registry", Path::new("/data/registry"))?; +/// ``` +pub fn sync_registry(url: &str, registry: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(registry) + .map_err(|e| anyhow::anyhow!("create registry dir {}: {e}", registry.display()))?; + let repo = git2::Repository::init(registry) + .map_err(|e| anyhow::anyhow!("init registry dir {}: {e}", registry.display()))?; + let mut remote = repo + .remote_anonymous(url) + .map_err(|e| anyhow::anyhow!("open remote {url}: {e}"))?; + // Force-fetch the custom namespace the backend reads. NOT a plain clone. + remote + .fetch(&["+refs/auths/*:refs/auths/*"], None, None) + .map_err(|e| anyhow::anyhow!("fetch refs/auths/* from {url}: {e}"))?; + drop(remote); + registry_ready(registry).map_err(|e| { + anyhow::anyhow!( + "registry synced from {url} but is not resolvable \ + (does the remote expose refs/auths/registry?): {e}" + ) + })?; + Ok(()) +} From 12f925495c7355c08d84949e3d87a870cf2001d3 Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 21:24:06 +0100 Subject: [PATCH 2/8] xtask: generate the verdict contract manifest + drift gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verdict strings are defined once, in a code() method per emitting enum, but downstream consumers hardcode copies — so a rename silently breaks them (a consistent → self-consistent rename broke the @auths-dev/mcp release gate). Add `xtask gen-contracts`: scan each enum's inherent code() for the kebab literals it emits and write them, namespaced by emitter, to schemas/contracts-v1.json. Gate it in CI with the same generate-then- `git diff --exit-code` idiom the JSON schemas use, so a rename becomes a visible, must-commit change. Unit tests assert the extraction against source (they run in CI where I can compile). Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- .github/workflows/ci.yml | 4 + crates/xtask/src/gen_contracts.rs | 224 ++++++++++++++++++++++++++++++ crates/xtask/src/main.rs | 9 ++ schemas/contracts-v1.json | 61 ++++++++ 4 files changed, 298 insertions(+) create mode 100644 crates/xtask/src/gen_contracts.rs create mode 100644 schemas/contracts-v1.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 480a3430..5a767ff1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,10 @@ jobs: run: | cargo run -p xtask -- generate-schemas git diff --exit-code schemas/ + - name: Regenerate the verdict manifest and check for drift + run: | + cargo run -p xtask -- gen-contracts + git diff --exit-code schemas/contracts-v1.json - name: Validate fixtures against schemas run: cargo run -p xtask -- validate-schemas diff --git a/crates/xtask/src/gen_contracts.rs b/crates/xtask/src/gen_contracts.rs new file mode 100644 index 00000000..8f92bd70 --- /dev/null +++ b/crates/xtask/src/gen_contracts.rs @@ -0,0 +1,224 @@ +//! Generate the verdict-code contract manifest from the verifiers' `code()` +//! methods. +//! +//! Every verdict string a relying party can receive is defined once, in an +//! inherent `code()` on the emitting enum. Downstream consumers — the docs +//! verdict allowlist, the `@auths-dev/mcp` release gate, the marketing site — +//! hardcode copies today, so a rename in one place silently breaks them (a +//! `consistent → self-consistent` rename did exactly that). This scans each +//! `code()` for the literals it can emit and writes them, namespaced by +//! emitter, to `schemas/contracts-v1.json` — the one artifact those consumers +//! assert against, gated by the same generate-then-`git diff` idiom the JSON +//! schemas use. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +/// A verdict family — the emitting enum and the source file its `code()` lives +/// in. The `key` namespaces vocabulary that overlaps across layers (e.g. +/// `revoked` is emitted by both `gate` and `commit`). +struct Family { + key: &'static str, + enum_name: &'static str, + file: &'static str, +} + +const FAMILIES: &[Family] = &[ + Family { + key: "audit", + enum_name: "AuditVerdict", + file: "crates/auths-mcp-core/src/audit.rs", + }, + Family { + key: "call", + enum_name: "CallVerdict", + file: "crates/auths-evidence/src/types.rs", + }, + Family { + key: "commit", + enum_name: "CommitVerdict", + file: "crates/auths-verifier/src/commit_kel.rs", + }, + Family { + key: "gate", + enum_name: "Verdict", + file: "crates/auths-mcp-core/src/gate.rs", + }, + Family { + key: "log", + enum_name: "LogVerdict", + file: "crates/auths-evidence/src/types.rs", + }, + Family { + key: "paymode", + enum_name: "BudgetRequired", + file: "crates/auths-mcp-core/src/paymode.rs", + }, +]; + +const MANIFEST_PATH: &str = "schemas/contracts-v1.json"; + +#[derive(serde::Serialize)] +struct Manifest { + version: &'static str, + verdicts: BTreeMap>, +} + +/// Regenerate the verdict manifest from source, or (in check mode) fail if the +/// committed manifest is stale. +/// +/// Args: +/// * `workspace_root`: the repo root the family paths resolve against. +/// * `check`: when true, do not write — exit non-zero if the committed manifest +/// drifted from source (the CI gate). +/// +/// Usage: +/// ```ignore +/// gen_contracts::run(workspace_root(), false)?; // regenerate the manifest +/// ``` +pub fn run(workspace_root: &Path, check: bool) -> Result<()> { + let manifest = build_manifest(workspace_root)?; + let json = serde_json::to_string_pretty(&manifest).context("serialize manifest")?; + let content = format!("{json}\n"); + let path = workspace_root.join(MANIFEST_PATH); + if check { + let committed = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + if committed != content { + bail!("{MANIFEST_PATH} is out of date — run `cargo xtask gen-contracts` to regenerate"); + } + println!("gen-contracts: {MANIFEST_PATH} is up to date"); + } else { + std::fs::write(&path, &content).with_context(|| format!("write {}", path.display()))?; + println!("gen-contracts: wrote {MANIFEST_PATH}"); + } + Ok(()) +} + +/// Read every family's source and collect its verdict codes into the manifest. +fn build_manifest(workspace_root: &Path) -> Result { + let mut verdicts = BTreeMap::new(); + for family in FAMILIES { + let text = std::fs::read_to_string(workspace_root.join(family.file)) + .with_context(|| format!("read {}", family.file))?; + let codes = family_codes(&text, family.enum_name); + if codes.is_empty() { + bail!( + "no verdict codes parsed for {} in {} — did the code() shape change?", + family.enum_name, + family.file + ); + } + verdicts.insert(family.key.to_string(), codes); + } + Ok(Manifest { + version: "contracts/v1", + verdicts, + }) +} + +/// The inherent-impl type a line opens (`impl Foo {` → `Some("Foo")`); `None` +/// for trait impls (`impl Bar for Foo`) and non-impl lines. +fn inherent_impl_target(line: &str) -> Option<&str> { + let rest = line.trim().strip_prefix("impl ")?; + if rest.contains(" for ") { + return None; + } + rest.split(|c: char| c.is_whitespace() || c == '{' || c == '<') + .next() + .filter(|name| !name.is_empty()) +} + +/// The kebab-case verdict code a line carries (`… => "chain-break"` or a bare +/// `"budget-required"`), if any. Only lowercase kebab tokens qualify, so message +/// strings and doc text inside a `code()` body never leak in. +fn kebab_code(line: &str) -> Option<&str> { + let start = line.find('"')? + 1; + let rest = &line[start..]; + let end = rest.find('"')?; + let lit = &rest[..end]; + let is_kebab = !lit.is_empty() + && lit.starts_with(|c: char| c.is_ascii_lowercase()) + && lit + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + is_kebab.then_some(lit) +} + +/// Collect, in source order, the codes the named enum's inherent `code()` can +/// emit. Tracks the current inherent impl so a file with two verdict enums +/// (e.g. `CallVerdict` and `LogVerdict`) resolves each independently. +fn family_codes(text: &str, enum_name: &str) -> Vec { + let mut current_impl: Option<&str> = None; + let mut collecting = false; + let mut depth = 0i32; + let mut codes = Vec::new(); + for line in text.lines() { + if !collecting { + if let Some(target) = inherent_impl_target(line) { + current_impl = Some(target); + } + if current_impl != Some(enum_name) || !line.contains("fn code(&self)") { + continue; + } + collecting = true; + depth = 0; + } + depth += line.matches('{').count() as i32; + depth -= line.matches('}').count() as i32; + if let Some(code) = kebab_code(line) { + codes.push(code.to_string()); + } + if depth <= 0 { + collecting = false; + current_impl = None; + } + } + codes +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace_root() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + #[test] + fn every_family_parses_to_a_non_empty_kebab_set() { + let manifest = build_manifest(&workspace_root()).expect("build manifest"); + assert_eq!(manifest.verdicts.len(), FAMILIES.len()); + for (key, codes) in &manifest.verdicts { + assert!(!codes.is_empty(), "no codes for family {key}"); + for code in codes { + assert!( + code.starts_with(|c: char| c.is_ascii_lowercase()) + && code + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "non-kebab code {code:?} in {key}" + ); + } + } + } + + #[test] + fn the_renames_that_bit_us_are_reflected() { + let manifest = build_manifest(&workspace_root()).expect("build manifest"); + let audit = &manifest.verdicts["audit"]; + assert!(audit.contains(&"self-consistent".to_string())); + assert!(audit.contains(&"chain-break".to_string())); + // The offline audit is `self-consistent`, never a bare `consistent`. + assert!(!audit.contains(&"consistent".to_string())); + // The whole-log evidence judge, however, IS `consistent`. + assert!(manifest.verdicts["log"].contains(&"consistent".to_string())); + assert_eq!( + manifest.verdicts["paymode"], + vec!["budget-required".to_string()] + ); + assert!(manifest.verdicts["gate"].contains(&"allowed".to_string())); + } +} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index dad9d225..b5d200cd 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -21,6 +21,7 @@ mod check_error_docs_published; mod check_paste_integrity; mod check_rfc6979; mod check_verify_path_completeness; +mod gen_contracts; mod gen_docs; mod gen_error_docs; mod gen_schema; @@ -54,6 +55,13 @@ enum Command { GenerateSchemas, /// Validate test fixture JSON files against committed schemas ValidateSchemas, + /// Regenerate the verdict-code manifest (schemas/contracts-v1.json) from the + /// verifiers' `code()` methods. Pass `--check` to fail if it is stale. + GenContracts { + /// Fail instead of writing if the manifest is stale (CI mode). + #[arg(long)] + check: bool, + }, /// Regenerate error code docs and CLI registry from `AuthsErrorInfo` impls. /// Pass `--check` to fail if any output is stale (CI gate). GenErrorDocs { @@ -140,6 +148,7 @@ fn main() -> anyhow::Result<()> { Command::GenDocs { check } => gen_docs::run(workspace_root(), check), Command::GenerateSchemas => schemas::generate(workspace_root()), Command::ValidateSchemas => schemas::validate(workspace_root()), + Command::GenContracts { check } => gen_contracts::run(workspace_root(), check), Command::GenErrorDocs { check } => gen_error_docs::run(workspace_root(), check), Command::TestIntegration { filter } => test_integration::run(filter.as_deref()), Command::CheckClippySync => check_clippy_sync::run(workspace_root()), diff --git a/schemas/contracts-v1.json b/schemas/contracts-v1.json new file mode 100644 index 00000000..93ddf533 --- /dev/null +++ b/schemas/contracts-v1.json @@ -0,0 +1,61 @@ +{ + "version": "contracts/v1", + "verdicts": { + "audit": [ + "self-consistent", + "tampered-proof", + "cost-mismatch", + "counterparty-mismatch", + "budget-mismatch", + "chain-break", + "revoked" + ], + "call": [ + "authorized", + "unauthorized", + "expired", + "out-of-scope", + "out-of-counterparty", + "over-budget", + "unverifiable" + ], + "commit": [ + "valid", + "unsigned", + "ssh-signature-invalid", + "gpg-unsupported", + "device-kel-invalid", + "root-kel-invalid", + "root-not-pinned", + "root-abandoned", + "not-delegated-by-claimed-root", + "delegation-seal-not-found", + "device-revoked", + "signed-after-revocation", + "outside-agent-scope", + "agent-expired", + "signer-key-mismatch", + "signed-by-superseded-key", + "witness-quorum-not-met" + ], + "gate": [ + "allowed", + "outside-agent-scope", + "usage-cap-exceeded", + "metered-amount-required", + "usage-counter-rolled-back", + "agent-expired", + "revoked", + "proof-unauthentic", + "stale" + ], + "log": [ + "consistent", + "inconsistent", + "unverifiable" + ], + "paymode": [ + "budget-required" + ] + } +} From 621ba9b51db2367b82594f6e29604d4a0cd5c81e Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 21:26:18 +0100 Subject: [PATCH 3/8] sdk(node): ship the verdict manifest in conformance/verdicts.json Extend sync-conformance to copy schemas/contracts-v1.json into the package's conformance/ dir (alongside statuses.json), so a JS/TS consumer can pin every verdict string to the exact contract the installed SDK version was built from. Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- packages/auths-node/scripts/sync-conformance.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/auths-node/scripts/sync-conformance.mjs b/packages/auths-node/scripts/sync-conformance.mjs index 2e202e23..beb2a5fd 100644 --- a/packages/auths-node/scripts/sync-conformance.mjs +++ b/packages/auths-node/scripts/sync-conformance.mjs @@ -22,6 +22,12 @@ for (const name of VECTORS) { copyFileSync(join(fixtures, name), join(out, name)); } +// The verdict manifest, generated in the workspace by `xtask gen-contracts` +// from the source `code()` methods and gated against drift in CI. Shipping it +// here lets a JS/TS consumer assert every verdict string against the exact +// contract the SDK version they installed was built from. +copyFileSync(resolve(pkg, '../../schemas/contracts-v1.json'), join(out, 'verdicts.json')); + const dts = readFileSync(join(pkg, 'index.d.ts'), 'utf8'); function enumValues(name) { const match = dts.match(new RegExp(`export declare const enum ${name} \\{([\\s\\S]*?)\\n\\}`)); From a2202636d862cc9f804004efdba9afdd7dea0951 Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 21:29:38 +0100 Subject: [PATCH 4/8] deny: allow git2 in auths-witness-node (registry sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The witness reads party keys from refs/auths/*, which it now fetches with git2 (A1). auths-witness-node is a server/adapter binary — exactly the storage/adapter layer the git2 ban already permits — so add it to the wrappers allowlist. Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- deny.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 99a787f1..1b2b8793 100644 --- a/deny.toml +++ b/deny.toml @@ -67,7 +67,8 @@ deny = [ "auths-test-utils", "auths-api", "auths-rp", - ], reason = "git2 must stay in storage/adapter layer; auths-sdk and auths-api git2 are dev-deps only; auth-server uses git2 via the local-git identity resolver; auths-rp's git2 is the optional git-sync registry-fetch adapter (feature-gated off by default)" }, + "auths-witness-node", + ], reason = "git2 must stay in storage/adapter layer; auths-sdk and auths-api git2 are dev-deps only; auth-server uses git2 via the local-git identity resolver; auths-rp's git2 is the optional git-sync registry-fetch adapter (feature-gated off by default); witness-node uses git2 to sync the party registry (refs/auths/*) it fail-closes on" }, ] [advisories] From 67e2d9d6d1b355722ee43821891c7c51d72260ce Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 21:36:52 +0100 Subject: [PATCH 5/8] xtask: gen-contracts skips comment lines in code() bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit code() has a `// "self-consistent" …` note above the arm; the scanner pulled the literal out of the comment too, emitting a phantom duplicate. Skip comment-only lines (no code, no real brace scope), and add a per-family uniqueness assertion so a future leak fails the unit test, not just the CI diff. Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/xtask/src/gen_contracts.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/xtask/src/gen_contracts.rs b/crates/xtask/src/gen_contracts.rs index 8f92bd70..45bfa2d5 100644 --- a/crates/xtask/src/gen_contracts.rs +++ b/crates/xtask/src/gen_contracts.rs @@ -166,6 +166,12 @@ fn family_codes(text: &str, enum_name: &str) -> Vec { collecting = true; depth = 0; } + // A comment line inside the body carries no code and no real brace + // scope — skip it, so a `// "self-consistent" …` note above an arm can't + // leak a phantom literal (nor its prose braces skew the depth count). + if line.trim_start().starts_with("//") { + continue; + } depth += line.matches('{').count() as i32; depth -= line.matches('}').count() as i32; if let Some(code) = kebab_code(line) { @@ -193,6 +199,12 @@ mod tests { assert_eq!(manifest.verdicts.len(), FAMILIES.len()); for (key, codes) in &manifest.verdicts { assert!(!codes.is_empty(), "no codes for family {key}"); + let unique: std::collections::BTreeSet<_> = codes.iter().collect(); + assert_eq!( + unique.len(), + codes.len(), + "duplicate code in family {key} — a comment leak or a repeated arm? {codes:?}" + ); for code in codes { assert!( code.starts_with(|c: char| c.is_ascii_lowercase()) From 22c1fbbc934a93626ecad69724df6cc639122459 Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 21:37:04 +0100 Subject: [PATCH 6/8] deploy(witness): sync the registry with refs/auths/*, not git clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RT-2 root cause: every deploy path told operators to populate the party registry with `git clone`, which brings only refs/heads/* — so the refs/auths/* KELs the anchor role resolves keys from never arrived, and every submission 422'd. Point all four paths (Compose, README bare-metal, Helm, Terraform) at the new `witness-node sync-registry` command, run before/alongside serve. Also fix stale/broken serve flags that never matched the binary: - Helm + Terraform passed `--anchor-store` (no such flag) and omitted --data-dir/--registry/--witness-name; correct them and add a registry init container (Helm) / pre-serve sync (Terraform). - Terraform ran the anchor store on `--tmpfs /data` — an amnesiac witness that co-signs a fork after any restart. Move it to a durable host dir. - Compose mounted the registry :ro, which blocks the in-place sync; make it writable (serve only reads it). Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- deploy/witness/README.md | 11 +++++-- deploy/witness/docker-compose.yml | 16 +++++++--- deploy/witness/helm/templates/deployment.yaml | 31 +++++++++++++++++-- deploy/witness/helm/values.yaml | 9 ++++++ .../witness/terraform/aws/user_data.sh.tftpl | 23 ++++++++++++-- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/deploy/witness/README.md b/deploy/witness/README.md index a8f106ed..54ae90ad 100644 --- a/deploy/witness/README.md +++ b/deploy/witness/README.md @@ -36,6 +36,8 @@ Install the node binary, then run it directly: ```bash # From a release: extract witness-node from the v0.1.x tarball onto PATH, or build it: cargo build --release -p auths-witness-node +# Populate the registry with the parties' public KELs (fetches `refs/auths/*`): +./target/release/witness-node sync-registry --from --registry ./registry ./target/release/witness-node serve --roles anchor,kel,cosign \ --bind 0.0.0.0:3333 --data-dir ./wdata --registry ./registry --witness-name my-w1 ``` @@ -45,8 +47,13 @@ cargo build --release -p auths-witness-node ```bash cd deploy/witness # A witness resolves submitter keys against a local copy of the parties' public -# registry. Sync it first; WITNESS_REGISTRY defaults to ./registry. -git clone ./registry +# registry. Sync it first (WITNESS_REGISTRY defaults to ./registry) using the +# same image — this fetches the `refs/auths/*` namespace the anchor role reads +# keys from. A plain `git clone` brings only `refs/heads/*`, so the party KELs +# never arrive and every anchor 422s. +WITNESS_REGISTRY=$PWD/registry docker compose run --rm witness \ + sync-registry --from --registry /registry +# Re-run the sync to pick up new or rotated parties. WITNESS_SEED=$(openssl rand -hex 32) WITNESS_REGISTRY=$PWD/registry docker compose up # health: http://127.0.0.1:3333/health # anchors: POST http://127.0.0.1:3333/v1/anchor diff --git a/deploy/witness/docker-compose.yml b/deploy/witness/docker-compose.yml index ac49f6a1..e09193de 100644 --- a/deploy/witness/docker-compose.yml +++ b/deploy/witness/docker-compose.yml @@ -33,11 +33,17 @@ services: # registry, used to resolve current keys for party signatures. # # First boot has no registry yet: default to ./registry beside this file, - # and sync the parties' public registry into it before anchoring — - # git clone ./registry - # The node's readiness gate refuses to serve until this holds a real git - # repo, so an empty default fails loudly instead of 422-ing every request. - - ${WITNESS_REGISTRY:-./registry}:/registry:ro + # and sync the parties' public registry into it before anchoring — with + # the same image, so no host tooling is needed: + # docker compose run --rm witness \ + # sync-registry --from --registry /registry + # This fetches `refs/auths/*` (a plain `git clone` brings only + # `refs/heads/*`, so the party KELs never arrive and every anchor 422s). + # Re-run it to pick up new/rotated parties. The node's readiness gate + # refuses to serve until this holds a real registry, so an empty default + # fails loudly instead of 422-ing every request. Mounted read-write so the + # sync step can populate it in place; `serve` only reads from it. + - ${WITNESS_REGISTRY:-./registry}:/registry healthcheck: test: ["CMD", "/usr/local/bin/witness-node", "healthcheck", "--url", "http://127.0.0.1:3333/health"] interval: 30s diff --git a/deploy/witness/helm/templates/deployment.yaml b/deploy/witness/helm/templates/deployment.yaml index 43ad5f48..2d604fd1 100644 --- a/deploy/witness/helm/templates/deployment.yaml +++ b/deploy/witness/helm/templates/deployment.yaml @@ -22,6 +22,22 @@ spec: securityContext: runAsNonRoot: true readOnlyRootFilesystem: true + initContainers: + # Fetch the parties' public registry (`refs/auths/*`) into an ephemeral + # volume before the node serves — the anchor role fails closed without + # it. Re-runs on every pod start, so a restart always has current keys. + - name: sync-registry + image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ .Values.image.tag }}{{ end }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - sync-registry + - --from + - {{ required "registry.url is required — the parties' public registry to fetch refs/auths/* from" .Values.registry.url | quote }} + - --registry + - /registry + volumeMounts: + - name: registry + mountPath: /registry containers: - name: witness image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ .Values.image.tag }}{{ end }}" @@ -32,8 +48,12 @@ spec: - {{ join "," .Values.roles | quote }} - --bind - "0.0.0.0:{{ .Values.bind.port }}" - - --anchor-store - - /data/anchors.db + - --data-dir + - /data + - --registry + - /registry + - --witness-name + - {{ .Values.witnessName | quote }} env: - name: WITNESS_SEED {{- if .Values.seed.existingSecret }} @@ -58,7 +78,14 @@ spec: volumeMounts: - name: anchor-store mountPath: /data + - name: registry + mountPath: /registry + readOnly: true volumes: - name: anchor-store persistentVolumeClaim: claimName: {{ .Release.Name }}-anchor-store + # Derived data (re-fetched by the init container each start), so it + # rides an emptyDir — only the anchor store must be durable. + - name: registry + emptyDir: {} diff --git a/deploy/witness/helm/values.yaml b/deploy/witness/helm/values.yaml index a9a1609b..cfc49863 100644 --- a/deploy/witness/helm/values.yaml +++ b/deploy/witness/helm/values.yaml @@ -12,6 +12,15 @@ roles: - kel - cosign +# The witness's public name, carried in cosignatures and checkpoints. +witnessName: witness + +# The parties' public registry. An init container fetches `refs/auths/*` from +# this URL into an ephemeral volume before the node serves, so the anchor role +# can resolve submitter keys. Required — the node fails closed without it. +registry: + url: "" + bind: port: 3333 diff --git a/deploy/witness/terraform/aws/user_data.sh.tftpl b/deploy/witness/terraform/aws/user_data.sh.tftpl index cd473e9e..93d97ddd 100644 --- a/deploy/witness/terraform/aws/user_data.sh.tftpl +++ b/deploy/witness/terraform/aws/user_data.sh.tftpl @@ -7,9 +7,28 @@ install -m 600 /dev/stdin /etc/auths-witness.env < Date: Mon, 20 Jul 2026 21:48:06 +0100 Subject: [PATCH 7/8] test(witness): match compose registry mount by var, not :ro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry mount is now read-write so the in-place sync can populate it; the compose-default test located the line by the literal `:/registry:ro`, so dropping :ro made it fail to find the line. Match on ${WITNESS_REGISTRY} instead and assert the /registry mount target explicitly — the test's real subject (a `:-` default, never `:?`) is unchanged. Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-witness-node/tests/cases/config.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/auths-witness-node/tests/cases/config.rs b/crates/auths-witness-node/tests/cases/config.rs index e8a3ebe3..06921476 100644 --- a/crates/auths-witness-node/tests/cases/config.rs +++ b/crates/auths-witness-node/tests/cases/config.rs @@ -22,8 +22,12 @@ fn compose_default_registry_interpolates_without_env() { std::fs::read_to_string(workspace_root.join("deploy/witness/docker-compose.yml")).unwrap(); let registry_line = compose .lines() - .find(|line| line.contains(":/registry:ro")) + .find(|line| line.contains("${WITNESS_REGISTRY")) .expect("registry volume line present"); + assert!( + registry_line.contains(":/registry"), + "registry must mount at /registry, got: {registry_line}" + ); assert!( registry_line.contains("${WITNESS_REGISTRY:-"), "registry mount must interpolate a default, got: {registry_line}" From ceb60ec0f8eacc169661d3c7d9a9f426c460e306 Mon Sep 17 00:00:00 2001 From: claude-release Date: Mon, 20 Jul 2026 22:25:56 +0100 Subject: [PATCH 8/8] test(e2e): make the unsigned-commit test commit genuinely unsigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_unsigned_commit_advice_is_kel_native has been red since #396: it asserts `auths verify` gives KEL-native advice for an UNSIGNED commit, but make_commit runs a bare `git commit` — and the init_identity fixture (`auths init --profile developer`) configures global commit signing plus a commit-trailer hook, so the commit is signed and verify reports it verified instead of advising. Give make_commit a `sign` flag; with sign=False it ignores global/system git config (neither commit.gpgsign nor the hooks path apply) and passes --no-gpg-sign, producing a genuinely bare commit. Pre-existing failure, unrelated to this branch's changes — folded in so CI goes green. Co-Authored-By: Claude Fable 5 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- tests/e2e/helpers/git.py | 27 +++++++++++++++++++++++---- tests/e2e/test_git_signing.py | 3 ++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/e2e/helpers/git.py b/tests/e2e/helpers/git.py index d97d04a4..4ec976d2 100644 --- a/tests/e2e/helpers/git.py +++ b/tests/e2e/helpers/git.py @@ -1,5 +1,6 @@ """Git helpers for Auths E2E tests.""" +import os import subprocess from pathlib import Path @@ -62,8 +63,17 @@ def configure_signing( ) -def make_commit(repo_path: Path, message: str, env: dict[str, str]) -> str: - """Create a file, stage it, commit, and return the commit SHA.""" +def make_commit( + repo_path: Path, message: str, env: dict[str, str], sign: bool = True +) -> str: + """Create a file, stage it, commit, and return the commit SHA. + + ``auths init`` (via the ``init_identity`` fixture) configures *global* git + signing and installs a commit-trailer hook, so a bare ``git commit`` is + signed. Pass ``sign=False`` to make a genuinely unsigned commit — it ignores + global/system git config (so neither ``commit.gpgsign`` nor the hooks path + apply) and adds ``--no-gpg-sign`` — for exercising the unsigned-commit path. + """ import uuid filename = f"file-{uuid.uuid4().hex[:8]}.txt" @@ -75,10 +85,19 @@ def make_commit(repo_path: Path, message: str, env: dict[str, str]) -> str: check=True, capture_output=True, ) + commit_cmd = ["git", "commit", "-m", message] + commit_env = env + if not sign: + commit_cmd = ["git", "commit", "--no-gpg-sign", "-m", message] + commit_env = { + **env, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + } subprocess.run( - ["git", "commit", "-m", message], + commit_cmd, cwd=repo_path, - env=env, + env=commit_env, check=True, capture_output=True, ) diff --git a/tests/e2e/test_git_signing.py b/tests/e2e/test_git_signing.py index 873c6f82..98dee9b3 100644 --- a/tests/e2e/test_git_signing.py +++ b/tests/e2e/test_git_signing.py @@ -143,7 +143,8 @@ def test_headless_sign_then_verify_passes(self, auths_bin, tmp_path): def test_unsigned_commit_advice_is_kel_native(self, auths_bin, init_identity, git_repo): # Verifying an unsigned commit must speak the KEL-native flow, never the # retired SSH/GPG advice that produces a commit auths still rejects. - sha = make_commit(git_repo, "unsigned commit", init_identity) + # `init_identity` configures global signing, so force a bare commit. + sha = make_commit(git_repo, "unsigned commit", init_identity, sign=False) result = run_auths(auths_bin, ["verify", sha], cwd=git_repo, env=init_identity) combined = (result.stdout + result.stderr).lower() assert "git commit -s" not in combined