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/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(()) +} 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}" diff --git a/crates/xtask/src/gen_contracts.rs b/crates/xtask/src/gen_contracts.rs new file mode 100644 index 00000000..45bfa2d5 --- /dev/null +++ b/crates/xtask/src/gen_contracts.rs @@ -0,0 +1,236 @@ +//! 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; + } + // 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) { + 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}"); + 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()) + && 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/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] 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 < 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