From 1229d70d7132d01bb21eff8e77d956702aa852e0 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 19:42:21 +0100 Subject: [PATCH 01/11] fix(key): gate plaintext private-key export behind interactive confirmation key export --format pem dumped the unencrypted signing key to stdout with only AUTHS_PASSPHRASE, so any process holding the env var could exfiltrate it silently. Add a pure authorize_key_export policy (plaintext-private export requires interactive + confirmed), unit-tested RED->GREEN, and enforce it at the CLI: pem export now prompts a confirmation and is refused non-interactively. Public/encrypted exports unaffected. (account-setup A2) Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/commands/key.rs | 25 ++++++ .../src/domains/signing/export_policy.rs | 88 +++++++++++++++++++ crates/auths-sdk/src/domains/signing/mod.rs | 2 + 3 files changed, 115 insertions(+) create mode 100644 crates/auths-sdk/src/domains/signing/export_policy.rs diff --git a/crates/auths-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index 6697b71f9..09715d6da 100644 --- a/crates/auths-cli/src/commands/key.rs +++ b/crates/auths-cli/src/commands/key.rs @@ -3,8 +3,10 @@ use clap::{Parser, Subcommand}; use serde::Serialize; use std::ffi::CString; use std::fs; +use std::io::IsTerminal; use std::path::PathBuf; +use auths_sdk::domains::signing::export_policy::{ExportSensitivity, authorize_key_export}; use auths_sdk::error::AgentError; use auths_sdk::ffi::ffi; use auths_sdk::keychain::EncryptedFileStorage; @@ -149,6 +151,29 @@ pub fn handle_key(cmd: KeyCommand) -> Result<()> { passphrase, format, } => { + // A plaintext private-key export hands out a usable secret in the clear, so it requires an + // interactive confirmation; refused non-interactively so a script holding AUTHS_PASSPHRASE + // cannot silently exfiltrate the signing key. Public/encrypted exports are not gated. + let sensitivity = match format { + ExportFormat::Pem => ExportSensitivity::PlaintextPrivate, + ExportFormat::Pub => ExportSensitivity::Public, + ExportFormat::Enc => ExportSensitivity::Encrypted, + }; + let interactive = std::io::stdin().is_terminal(); + let confirmed = if sensitivity == ExportSensitivity::PlaintextPrivate && interactive { + dialoguer::Confirm::new() + .with_prompt( + "Export your UNENCRYPTED private key to stdout? Anyone who reads it controls \ + this identity. Continue? [y/N]", + ) + .default(false) + .interact() + .context("Failed to read export confirmation")? + } else { + false + }; + authorize_key_export(sensitivity, interactive, confirmed).map_err(|e| anyhow!("{e}"))?; + let passphrase = match passphrase { Some(p) => p, None => prompt_export_passphrase()?, diff --git a/crates/auths-sdk/src/domains/signing/export_policy.rs b/crates/auths-sdk/src/domains/signing/export_policy.rs new file mode 100644 index 000000000..dedca361f --- /dev/null +++ b/crates/auths-sdk/src/domains/signing/export_policy.rs @@ -0,0 +1,88 @@ +//! Export-authorization policy. +//! +//! A plaintext private-key export is the one export that hands out a usable secret in the clear, so it +//! requires an explicit interactive confirmation and is refused non-interactively — a script holding +//! only `AUTHS_PASSPHRASE` (a CI step, a malicious wrapper) must not be able to exfiltrate the signing +//! key silently. Public and (at-rest-encrypted) exports are not gated. + +use thiserror::Error; + +/// What a key export reveals, for authorization purposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportSensitivity { + /// An unencrypted private key (OpenSSH `pem`) — a usable secret in the clear. + PlaintextPrivate, + /// A public key — not a secret. + Public, + /// Encrypted key bytes (`enc`) — a secret, but protected at rest. + Encrypted, +} + +/// Refusal reason when an export is not authorized. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ExportDenied { + /// A plaintext private-key export was requested without an interactive confirmation. + #[error( + "exporting an unencrypted private key requires an interactive confirmation; \ + it is refused non-interactively so AUTHS_PASSPHRASE alone cannot authorize a key dump (AUTHS-E5910)" + )] + ConfirmationRequired, +} + +/// Authorize a key export. +/// +/// A `PlaintextPrivate` export is allowed only when the session is `interactive` **and** the user +/// `confirmed`; every other combination is refused, so a non-interactive caller cannot silently +/// exfiltrate the signing key. Public and encrypted exports are always authorized. +/// +/// Args: +/// * `sensitivity`: what the export reveals. +/// * `interactive`: whether a human is present (stdin is a TTY). +/// * `confirmed`: whether the human explicitly confirmed the plaintext dump. +/// +/// Usage: +/// ```ignore +/// authorize_key_export(ExportSensitivity::PlaintextPrivate, stdin_is_tty, user_confirmed)?; +/// ``` +pub fn authorize_key_export( + sensitivity: ExportSensitivity, + interactive: bool, + confirmed: bool, +) -> Result<(), ExportDenied> { + match sensitivity { + ExportSensitivity::PlaintextPrivate if !(interactive && confirmed) => { + Err(ExportDenied::ConfirmationRequired) + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plaintext_private_export_refused_when_not_interactively_confirmed() { + // a script holding only AUTHS_PASSPHRASE (non-interactive) must NOT get the plaintext key + assert_eq!( + authorize_key_export(ExportSensitivity::PlaintextPrivate, false, false), + Err(ExportDenied::ConfirmationRequired), + ); + // non-interactive but "confirmed" is still refused — a script is never a TTY + assert_eq!( + authorize_key_export(ExportSensitivity::PlaintextPrivate, false, true), + Err(ExportDenied::ConfirmationRequired), + ); + } + + #[test] + fn plaintext_private_export_allowed_when_interactively_confirmed() { + assert!(authorize_key_export(ExportSensitivity::PlaintextPrivate, true, true).is_ok()); + } + + #[test] + fn public_and_encrypted_exports_are_never_gated() { + assert!(authorize_key_export(ExportSensitivity::Public, false, false).is_ok()); + assert!(authorize_key_export(ExportSensitivity::Encrypted, false, false).is_ok()); + } +} diff --git a/crates/auths-sdk/src/domains/signing/mod.rs b/crates/auths-sdk/src/domains/signing/mod.rs index f86a5006e..2dc31d4f6 100644 --- a/crates/auths-sdk/src/domains/signing/mod.rs +++ b/crates/auths-sdk/src/domains/signing/mod.rs @@ -2,6 +2,8 @@ pub mod ci_env; pub mod error; +/// Export-authorization policy (plaintext private-key export requires interactive confirmation). +pub mod export_policy; /// Platform-specific signing implementations pub mod platform; pub mod service; From c4c3ccaf73f5f244633bafbe9a6bf9d7b560df5d Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 19:50:56 +0100 Subject: [PATCH 02/11] fix(init): require explicit confirmation before --force replaces a root identity init --force returned ForceNew with no confirmation and without even checking for an existing identity, so a wrapper or CI re-running it silently minted a new root and repointed signing. Add a pure authorize_identity_replacement policy (unit-tested RED->GREEN) and gate the CLI: over an existing identity, --force now prompts interactively, or requires --confirm-replace when there is no TTY; a fresh setup is unaffected. (account-setup A1) Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/commands/init/mod.rs | 9 +++ crates/auths-cli/src/commands/init/prompts.rs | 21 +++++++ crates/auths-sdk/src/domains/identity/mod.rs | 2 + .../src/domains/identity/replace_policy.rs | 59 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 crates/auths-sdk/src/domains/identity/replace_policy.rs diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index f67f2086a..6698d063a 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -115,6 +115,11 @@ pub struct InitCommand { #[clap(long)] pub force: bool, + /// Confirm replacing an existing root identity when running non-interactively. Required with + /// `--force` over an existing identity when there is no TTY to confirm at. + #[clap(long)] + pub confirm_replace: bool, + /// Preview agent configuration without creating files or identities #[clap(long)] pub dry_run: bool, @@ -640,6 +645,7 @@ mod tests { profile: None, key_alias: DEFAULT_KEY_ALIAS.to_string(), force: false, + confirm_replace: false, dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), register: false, @@ -667,6 +673,7 @@ mod tests { profile: Some(InitProfile::Ci), key_alias: "ci-key".to_string(), force: true, + confirm_replace: false, dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), register: false, @@ -702,6 +709,7 @@ mod tests { profile: None, key_alias: DEFAULT_KEY_ALIAS.to_string(), force: false, + confirm_replace: false, dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), register: false, @@ -721,6 +729,7 @@ mod tests { profile: None, key_alias: DEFAULT_KEY_ALIAS.to_string(), force: false, + confirm_replace: false, dry_run: false, registry: DEFAULT_REGISTRY_URL.to_string(), register: false, diff --git a/crates/auths-cli/src/commands/init/prompts.rs b/crates/auths-cli/src/commands/init/prompts.rs index 245196a1e..750dfdf01 100644 --- a/crates/auths-cli/src/commands/init/prompts.rs +++ b/crates/auths-cli/src/commands/init/prompts.rs @@ -9,6 +9,7 @@ use auths_sdk::keychain::IdentityDID; use auths_sdk::ports::IdentityStorage; use auths_sdk::signing::PassphraseProvider; use auths_sdk::storage::RegistryIdentityStorage; +use auths_sdk::domains::identity::replace_policy::authorize_identity_replacement; use auths_sdk::types::{GitSigningScope, IdentityConflictPolicy}; use super::InitCommand; @@ -58,6 +59,26 @@ pub(crate) fn prompt_for_conflict_policy( out: &Output, ) -> Result { if cmd.force { + // --force only replaces something when an identity already exists; on a fresh setup it is a + // no-op. Replacing an existing root repoints signing, so require an explicit confirmation — + // an interactive prompt, or --confirm-replace non-interactively — never a silent swap. + let identity_storage = RegistryIdentityStorage::new(registry_path.to_path_buf()); + if identity_storage.load_identity().is_ok() { + let confirmed = if interactive { + Confirm::new() + .with_prompt( + "--force will REPLACE your existing root identity with a new one. The old keys \ + are not deleted, but signing repoints to the new root. Continue?", + ) + .default(false) + .interact()? + } else { + cmd.confirm_replace + }; + authorize_identity_replacement(confirmed).map_err(|e| { + anyhow!("{e}\n\nRe-run interactively to confirm, or pass --confirm-replace to replace non-interactively.") + })?; + } return Ok(IdentityConflictPolicy::ForceNew); } diff --git a/crates/auths-sdk/src/domains/identity/mod.rs b/crates/auths-sdk/src/domains/identity/mod.rs index c6655ef64..664445300 100644 --- a/crates/auths-sdk/src/domains/identity/mod.rs +++ b/crates/auths-sdk/src/domains/identity/mod.rs @@ -10,6 +10,8 @@ pub mod local; pub mod provision; /// Identity registration on remote registries pub mod registration; +/// Identity-replacement authorization (`init --force` requires explicit confirmation) +pub mod replace_policy; /// Identity key rotation pub mod rotation; /// Identity services diff --git a/crates/auths-sdk/src/domains/identity/replace_policy.rs b/crates/auths-sdk/src/domains/identity/replace_policy.rs new file mode 100644 index 000000000..a957ba3fc --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/replace_policy.rs @@ -0,0 +1,59 @@ +//! Identity-replacement authorization. +//! +//! `auths init --force` replaces the existing root identity (mints a new root and repoints signing), so +//! it requires an explicit confirmation: an interactive confirmation when a human is present, or an +//! explicit token non-interactively. A wrapper or CI invocation re-running `init --force` must not be +//! able to silently rotate the user onto a new (possibly attacker-chosen) root. + +use thiserror::Error; + +/// Refusal reason when an identity replacement is not authorized. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum IdentityReplaceDenied { + /// `--force` was requested without an interactive confirmation (or explicit token). + #[error( + "replacing an existing root identity requires an explicit confirmation; \ + it is refused non-interactively so `init --force` cannot silently mint a new root (AUTHS-E5012)" + )] + ConfirmationRequired, +} + +/// Authorize replacing an existing root identity (`init --force` / `ForceNew`). +/// +/// Allowed only when `confirmed` — set by an interactive prompt the user accepted, or by an explicit +/// non-interactive token (`--confirm-replace`). Otherwise refused, so a non-interactive `init --force` +/// cannot silently replace the user's root. +/// +/// Args: +/// * `confirmed`: whether the replacement was explicitly confirmed (interactive prompt or token). +/// +/// Usage: +/// ```ignore +/// authorize_identity_replacement(user_confirmed)?; +/// ``` +pub fn authorize_identity_replacement(confirmed: bool) -> Result<(), IdentityReplaceDenied> { + if confirmed { + Ok(()) + } else { + Err(IdentityReplaceDenied::ConfirmationRequired) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn force_replace_refused_without_confirmation() { + // a non-interactive `init --force` with no confirmation must be refused + assert_eq!( + authorize_identity_replacement(false), + Err(IdentityReplaceDenied::ConfirmationRequired), + ); + } + + #[test] + fn force_replace_allowed_when_confirmed() { + assert!(authorize_identity_replacement(true).is_ok()); + } +} From b36246cd77a963c2ac173764bc2628051db32398 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:00:53 +0100 Subject: [PATCH 03/11] fix(ci): generate a random per-identity CI passphrase, remove the hardcoded constant The CI profile encrypted its file-backed key with DEFAULT_CI_PASSPHRASE = "Ci-ephemeral-pass1!", a constant public in the source tree, so anyone could decrypt any CI identity's exported key offline. Replace it with generate_ci_passphrase() (ring SystemRandom, 192-bit, unique per identity, unit-tested RED->GREEN). The value is already echoed into the env block, so the follow-up auths sign still works. (account-setup A3) Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/commands/init/gather.rs | 4 +- crates/auths-sdk/src/domains/ci/mod.rs | 2 +- crates/auths-sdk/src/domains/ci/types.rs | 43 ++++++++++++++++--- .../auths-sdk/src/domains/identity/types.rs | 2 +- crates/auths-sdk/src/types.rs | 2 +- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index 582c7ba84..41531ba1f 100644 --- a/crates/auths-cli/src/commands/init/gather.rs +++ b/crates/auths-cli/src/commands/init/gather.rs @@ -80,8 +80,8 @@ pub(crate) fn gather_ci_config( let (passphrase, passphrase_source) = match std::env::var("AUTHS_PASSPHRASE") { Ok(p) => (p, "the AUTHS_PASSPHRASE environment variable"), Err(_) => ( - auths_sdk::types::DEFAULT_CI_PASSPHRASE.to_string(), - "the built-in CI default passphrase", + auths_sdk::types::generate_ci_passphrase(), + "a generated per-identity ephemeral passphrase", ), }; preflight_passphrase(&passphrase, passphrase_source)?; diff --git a/crates/auths-sdk/src/domains/ci/mod.rs b/crates/auths-sdk/src/domains/ci/mod.rs index 0f03b3716..1624be2b7 100644 --- a/crates/auths-sdk/src/domains/ci/mod.rs +++ b/crates/auths-sdk/src/domains/ci/mod.rs @@ -7,4 +7,4 @@ pub mod environment; pub mod types; pub use environment::map_ci_environment; -pub use types::{CiEnvironment, CiIdentityConfig, DEFAULT_CI_PASSPHRASE}; +pub use types::{CiEnvironment, CiIdentityConfig, generate_ci_passphrase}; diff --git a/crates/auths-sdk/src/domains/ci/types.rs b/crates/auths-sdk/src/domains/ci/types.rs index a92f08048..64be92ac9 100644 --- a/crates/auths-sdk/src/domains/ci/types.rs +++ b/crates/auths-sdk/src/domains/ci/types.rs @@ -3,13 +3,27 @@ use std::fmt; use std::path::PathBuf; -/// Default passphrase used to encrypt the file-backed CI key when `AUTHS_PASSPHRASE` -/// is not set in the environment. +use ring::rand::{SecureRandom, SystemRandom}; + +/// Generate a strong, random, per-identity passphrase for encrypting a CI identity's file-backed key +/// when `AUTHS_PASSPHRASE` is not supplied. +/// +/// Each CI identity gets its own secret, so obtaining one CI identity's encrypted key reveals nothing +/// about another's — unlike the former shared constant, which was public in the source tree. The value +/// is echoed once into the generated env block (see [`CiIdentityConfig`]) so the follow-up `auths sign` +/// can decrypt the key headlessly within the same job. /// -/// CI identities are ephemeral and low-value, so this default keeps the documented -/// quickstart copy-paste runnable. Production pipelines should export their own -/// `AUTHS_PASSPHRASE` (a managed secret) before running `auths init --profile ci`. -pub const DEFAULT_CI_PASSPHRASE: &str = "Ci-ephemeral-pass1!"; +/// Usage: +/// ```ignore +/// let passphrase = generate_ci_passphrase(); +/// ``` +pub fn generate_ci_passphrase() -> String { + let rng = SystemRandom::new(); + let mut bytes = [0u8; 24]; + #[allow(clippy::expect_used)] // INVARIANT: OS CSPRNG (ring SystemRandom) failure is unrecoverable, like a poisoned mutex. + rng.fill(&mut bytes).expect("system CSPRNG unavailable"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} /// CI platform environment. /// @@ -76,3 +90,20 @@ impl fmt::Debug for CiIdentityConfig { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ci_passphrase_is_unique_per_call() { + // a per-identity secret: two CI inits must not share a passphrase + assert_ne!(generate_ci_passphrase(), generate_ci_passphrase()); + } + + #[test] + fn ci_passphrase_is_strong() { + let p = generate_ci_passphrase(); + assert!(p.len() >= 32, "passphrase too short ({} chars)", p.len()); + } +} diff --git a/crates/auths-sdk/src/domains/identity/types.rs b/crates/auths-sdk/src/domains/identity/types.rs index 94b5f3717..6324c093e 100644 --- a/crates/auths-sdk/src/domains/identity/types.rs +++ b/crates/auths-sdk/src/domains/identity/types.rs @@ -321,7 +321,7 @@ impl IdentityConfig { ci_environment: CiEnvironment::Unknown, registry_path, keychain_file, - passphrase: crate::domains::ci::types::DEFAULT_CI_PASSPHRASE.to_string(), + passphrase: crate::domains::ci::types::generate_ci_passphrase(), }) } diff --git a/crates/auths-sdk/src/types.rs b/crates/auths-sdk/src/types.rs index b58ee93ff..17f28d716 100644 --- a/crates/auths-sdk/src/types.rs +++ b/crates/auths-sdk/src/types.rs @@ -1,7 +1,7 @@ //! Re-exports of domain configuration types for backwards compatibility. // Re-export all identity config types -pub use crate::domains::ci::types::{CiEnvironment, CiIdentityConfig, DEFAULT_CI_PASSPHRASE}; +pub use crate::domains::ci::types::{CiEnvironment, CiIdentityConfig, generate_ci_passphrase}; pub use crate::domains::identity::types::{ CreateAgentIdentityConfig, CreateAgentIdentityConfigBuilder, CreateDeveloperIdentityConfig, CreateDeveloperIdentityConfigBuilder, IdentityConfig, IdentityConflictPolicy, From 56ad1f21f85a89fd1042906e4a5b3cf94a4adc3b Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:08:52 +0100 Subject: [PATCH 04/11] fix(init): honor --repo so init does not silently reuse the default identity init --repo ignored the override: get_auths_repo_path() always returned the default ~/.auths, so the existing-identity check ran against HOME and reused the HOME root instead of creating an isolated identity under . Add a pure resolve_registry_path(override) helper (unit-tested RED->GREEN) and thread it through the developer init (gather + registration), matching the CI path which already honored --repo. (account-setup A4) Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/commands/init/gather.rs | 4 +-- crates/auths-cli/src/commands/init/mod.rs | 8 ++--- crates/auths-sdk/src/paths.rs | 36 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index 41531ba1f..953143c11 100644 --- a/crates/auths-cli/src/commands/init/gather.rs +++ b/crates/auths-cli/src/commands/init/gather.rs @@ -24,6 +24,7 @@ pub(crate) fn gather_developer_config( interactive: bool, out: &Output, cmd: &InitCommand, + registry_path: &Path, ) -> Result<( Box, CreateDeveloperIdentityConfig, @@ -34,9 +35,8 @@ pub(crate) fn gather_developer_config( out.print_success("Prerequisites OK"); out.newline(); - let registry_path = get_auths_repo_path()?; let alias = prompt_for_alias(interactive, cmd)?; - let conflict_policy = prompt_for_conflict_policy(interactive, cmd, ®istry_path, out)?; + let conflict_policy = prompt_for_conflict_policy(interactive, cmd, registry_path, out)?; let git_scope = prompt_for_git_scope(interactive)?; let mut builder = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked(&alias)) diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index 6698d063a..6374b4865 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -38,7 +38,7 @@ use gather::{ submit_registration, }; use guided::GuidedSetup; -use helpers::{get_auths_repo_path, offer_shell_completions}; +use helpers::offer_shell_completions; use prompts::{prompt_platform_verification, prompt_profile}; const DEFAULT_KEY_ALIAS: &str = "main"; @@ -311,8 +311,8 @@ fn run_developer_setup( // GATHER guide.section("Prerequisites & Configuration"); - let (keychain, mut config) = gather_developer_config(interactive, out, cmd)?; - let registry_path = get_auths_repo_path()?; + let registry_path = auths_sdk::paths::resolve_registry_path(ctx.repo_path.clone())?; + let (keychain, mut config) = gather_developer_config(interactive, out, cmd, ®istry_path)?; ensure_registry_dir(®istry_path)?; let sign_binary_path = which::which("auths-sign").ok(); @@ -438,7 +438,7 @@ fn run_developer_setup( // REGISTRATION & DISPLAY guide.section("Registration & Summary"); let registered = submit_registration( - &get_auths_repo_path()?, + ®istry_path, &cmd.registry, proof_url, !cmd.register, // skip unless --register is explicitly passed diff --git a/crates/auths-sdk/src/paths.rs b/crates/auths-sdk/src/paths.rs index 0e9bf18a2..9a27c125f 100644 --- a/crates/auths-sdk/src/paths.rs +++ b/crates/auths-sdk/src/paths.rs @@ -1,3 +1,39 @@ //! Re-exports of path utilities from `auths-core`. +use std::path::PathBuf; + +use auths_core::paths::AuthsHomeError; + pub use auths_core::paths::{auths_home, auths_home_with_config}; + +/// Resolve the registry (local storage) directory for a command, honoring an explicit `--repo` override. +/// +/// When `override_path` is `Some` (the user passed `--repo `), that directory is used verbatim, so +/// the command's identity store and existing-identity check are scoped to it rather than silently +/// falling back to the default `~/.auths`. When `None`, the default home is used. +/// +/// Args: +/// * `override_path`: the `--repo` override, if the user supplied one. +/// +/// Usage: +/// ```ignore +/// let registry_path = resolve_registry_path(ctx.repo_path.clone())?; +/// ``` +pub fn resolve_registry_path(override_path: Option) -> Result { + match override_path { + Some(p) => Ok(p), + None => auths_home(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_registry_path_honors_override() { + // --repo must scope storage to , not silently fall back to the default home + let custom = PathBuf::from("/tmp/auths-custom-repo-xyz"); + assert_eq!(resolve_registry_path(Some(custom.clone())).unwrap(), custom); + } +} From 62ad09dce6a299fe5c6798e1208387440c0384eb Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:16:05 +0100 Subject: [PATCH 05/11] style: rustfmt the account-setup fixes (import ordering) Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/commands/init/prompts.rs | 2 +- crates/auths-cli/src/commands/key.rs | 3 ++- crates/auths-sdk/src/domains/ci/types.rs | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/auths-cli/src/commands/init/prompts.rs b/crates/auths-cli/src/commands/init/prompts.rs index 750dfdf01..c16ce42d3 100644 --- a/crates/auths-cli/src/commands/init/prompts.rs +++ b/crates/auths-cli/src/commands/init/prompts.rs @@ -5,11 +5,11 @@ use dialoguer::{Confirm, Input, Select}; use std::path::Path; use std::sync::Arc; +use auths_sdk::domains::identity::replace_policy::authorize_identity_replacement; use auths_sdk::keychain::IdentityDID; use auths_sdk::ports::IdentityStorage; use auths_sdk::signing::PassphraseProvider; use auths_sdk::storage::RegistryIdentityStorage; -use auths_sdk::domains::identity::replace_policy::authorize_identity_replacement; use auths_sdk::types::{GitSigningScope, IdentityConflictPolicy}; use super::InitCommand; diff --git a/crates/auths-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index 09715d6da..097f760cd 100644 --- a/crates/auths-cli/src/commands/key.rs +++ b/crates/auths-cli/src/commands/key.rs @@ -172,7 +172,8 @@ pub fn handle_key(cmd: KeyCommand) -> Result<()> { } else { false }; - authorize_key_export(sensitivity, interactive, confirmed).map_err(|e| anyhow!("{e}"))?; + authorize_key_export(sensitivity, interactive, confirmed) + .map_err(|e| anyhow!("{e}"))?; let passphrase = match passphrase { Some(p) => p, diff --git a/crates/auths-sdk/src/domains/ci/types.rs b/crates/auths-sdk/src/domains/ci/types.rs index 64be92ac9..c1de8dcb2 100644 --- a/crates/auths-sdk/src/domains/ci/types.rs +++ b/crates/auths-sdk/src/domains/ci/types.rs @@ -20,7 +20,8 @@ use ring::rand::{SecureRandom, SystemRandom}; pub fn generate_ci_passphrase() -> String { let rng = SystemRandom::new(); let mut bytes = [0u8; 24]; - #[allow(clippy::expect_used)] // INVARIANT: OS CSPRNG (ring SystemRandom) failure is unrecoverable, like a poisoned mutex. + #[allow(clippy::expect_used)] + // INVARIANT: OS CSPRNG (ring SystemRandom) failure is unrecoverable, like a poisoned mutex. rng.fill(&mut bytes).expect("system CSPRNG unavailable"); bytes.iter().map(|b| format!("{b:02x}")).collect() } From 49866518396fc638ecd2275c440098525c1c842c Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:47:43 +0100 Subject: [PATCH 06/11] test(auths-keri): broaden the keripy KEL-verdict differential to all tamper variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live cross-check against keripy 1.3.4 exercised only two inputs (a valid accept and one forged signature). Broaden it to every universal KERI rejection — forged signature, unmet signing threshold, a non-self-addressing inception, and an altered rotation prior pointer — each a byte-level tamper of the committed fixture so keripy stays the independent oracle, asserting keripy refuses each exactly as auths does. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .../tests/cases/keripy_kel_oracle.rs | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs index 7b5ae5c1c..9b8803614 100644 --- a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs +++ b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs @@ -106,9 +106,39 @@ fn altered_prior_pointer_is_rejected() { ); } -/// Live differential: feed the same committed bytes to keripy's own ingestion path and -/// confirm it reaches the identical accept/reject verdict. Gated on `KERIPY_INTEROP=1`; -/// skipped (not failed) when keripy is unavailable. +/// A wrong-but-well-formed 44-char CESR SAID (Blake3 `E` code), for length-preserving +/// tampers that keep the event's version-string byte length valid. +const WRONG_SAID: &str = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +/// Overwrite the 44-char SAID value of `field` (e.g. `d`, `p`) in a KERI event's raw +/// JSON, preserving length so the declared version-string size stays correct. +fn replace_field_said(raw: &[u8], field: &str, new_said: &str) -> Vec { + assert_eq!(new_said.len(), 44, "a CESR Blake3 SAID is 44 chars"); + let needle = format!("\"{field}\":\""); + let pos = raw + .windows(needle.len()) + .position(|w| w == needle.as_bytes()) + .unwrap_or_else(|| panic!("field {field:?} not found in event")); + let start = pos + needle.len(); + let mut out = raw.to_vec(); + out[start..start + 44].copy_from_slice(new_said.as_bytes()); + out +} + +/// Concatenate `(event_json, attachment)` pairs into a single CESR ingestion stream. +fn cesr_stream(parts: &[(&[u8], &[u8])]) -> Vec { + let mut out = Vec::new(); + for (event, att) in parts { + out.extend_from_slice(event); + out.extend_from_slice(att); + } + out +} + +/// Live differential: feed the same committed bytes — valid and tampered — to keripy's own +/// ingestion path and confirm it reaches the identical accept/reject verdict auths does for +/// every universal KERI rejection. Gated on `KERIPY_INTEROP=1`; skipped (not failed) when +/// keripy is unavailable. #[test] fn keripy_subprocess_agrees_on_accept_and_reject() { if std::env::var("KERIPY_INTEROP").ok().as_deref() != Some("1") { @@ -125,35 +155,49 @@ fn keripy_subprocess_agrees_on_accept_and_reject() { let rot_raw = fixture("rot_remove.rot.json"); let rot_att = fixture("rot_remove.rot.att"); - // Valid stream: keripy must advance the AID to sn=1 (accept). - let mut valid = Vec::new(); - valid.extend_from_slice(&icp_raw); - valid.extend_from_slice(&icp_att); - valid.extend_from_slice(&rot_raw); - valid.extend_from_slice(&rot_att); + // Valid stream: keripy must advance the AID to sn=1 (accept), agreeing with auths. + let valid = cesr_stream(&[(&icp_raw, &icp_att), (&rot_raw, &rot_att)]); assert_eq!( keripy_highest_sn(&valid), Some(1), "keripy must accept the valid KEL to sn=1, matching auths" ); - // Forged inception signature: flip a byte inside the icp attachment's siger payload. - let mut bad = Vec::new(); - let mut icp_att_bad = icp_att.clone(); - // The siger payload begins after the 4-byte `-AAC` counter; flip a base64 char there. - let flip = 6.min(icp_att_bad.len() - 1); - icp_att_bad[flip] = if icp_att_bad[flip] == b'A' { - b'B' - } else { - b'A' - }; - bad.extend_from_slice(&icp_raw); - bad.extend_from_slice(&icp_att_bad); - assert_ne!( - keripy_highest_sn(&bad), - Some(1), - "keripy must not accept a forged-signature inception, matching auths" - ); + // Forged inception signature: flip a base64 char inside the icp siger payload (after + // the 4-byte `-AAC` counter). + let mut forged_att = icp_att.clone(); + let flip = 6.min(forged_att.len() - 1); + forged_att[flip] = if forged_att[flip] == b'A' { b'B' } else { b'A' }; + + // Unmet inception threshold: the icp commits kt=2; drop a siger and re-count `-AAC`→`-AAB` + // so only one of the two signatures remains. + let mut one_sig_att = b"-AAB".to_vec(); + one_sig_att.extend_from_slice(&icp_att[4..4 + 88]); + + // Each tamper is a universal KERI rejection; keripy must refuse every one (never reach + // sn=1), exactly as auths' offline verdict above does. + let rejects: [(&str, Vec); 4] = [ + ("forged inception signature", cesr_stream(&[(&icp_raw, &forged_att)])), + ("unmet inception threshold", cesr_stream(&[(&icp_raw, &one_sig_att)])), + ( + "non-self-addressing inception d", + cesr_stream(&[(&replace_field_said(&icp_raw, "d", WRONG_SAID), &icp_att)]), + ), + ( + "altered rotation prior pointer p", + cesr_stream(&[ + (&icp_raw, &icp_att), + (&replace_field_said(&rot_raw, "p", WRONG_SAID), &rot_att), + ]), + ), + ]; + for (label, stream) in &rejects { + assert_ne!( + keripy_highest_sn(stream), + Some(1), + "keripy must reject the KEL with a {label}, matching auths" + ); + } } fn keripy_importable() -> bool { From aa197068d551dbb2078364d2b9759954785f6a06 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:51:24 +0100 Subject: [PATCH 07/11] test(auths-keri): pin the CESR length table to cesride's encoding The matter_full_len length-guard table mirrors cesride's matter sizage for the codes auths parses. Add a test that encodes a primitive under each code and asserts the table's length matches what cesride emits, so a cesride version bump that shifts a primitive's size fails loudly instead of drifting the guard against the decoder. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/cesr_encode.rs | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/auths-keri/src/cesr_encode.rs b/crates/auths-keri/src/cesr_encode.rs index c066b6be5..5dc8836f7 100644 --- a/crates/auths-keri/src/cesr_encode.rs +++ b/crates/auths-keri/src/cesr_encode.rs @@ -177,4 +177,44 @@ mod tests { let commitment = encode_blake3_digest(blake3::hash(qb64.as_bytes()).as_bytes()).unwrap(); assert_eq!(commitment, "EF_M_u7ASVHXfI8QzdWLq3V9ocSKqxkbujXGbi9QMtP9"); } + + #[test] + fn matter_full_len_matches_cesride_encoding() { + use cesride::{Cigar, Matter}; + // The full-size table mirrors cesride's matter sizage for the codes auths + // parses. Pin each entry to what cesride actually emits, so a cesride bump + // that shifts a primitive's size fails here loudly instead of drifting the + // length-guard against the decoder. + let cases: [(&str, String); 7] = [ + ("D", encode_verkey(&[0u8; 32], matter::Codex::Ed25519).unwrap()), + ("B", encode_verkey(&[0u8; 32], matter::Codex::Ed25519N).unwrap()), + ("1AAJ", encode_verkey(&[0u8; 33], matter::Codex::ECDSA_256r1).unwrap()), + ("1AAI", encode_verkey(&[0u8; 33], matter::Codex::ECDSA_256r1N).unwrap()), + ("E", encode_blake3_digest(&[0u8; 32]).unwrap()), + ( + "0B", + Cigar::new_with_raw(&[0u8; 64], None, Some(matter::Codex::Ed25519_Sig)) + .and_then(|c| c.qb64()) + .unwrap(), + ), + ( + "0I", + Cigar::new_with_raw(&[0u8; 64], None, Some(matter::Codex::ECDSA_256r1_Sig)) + .and_then(|c| c.qb64()) + .unwrap(), + ), + ]; + for (code, encoded) in cases { + assert!( + encoded.starts_with(code), + "{code} primitive must encode under its own code, got {encoded:?}" + ); + assert_eq!( + matter_full_len(code), + Some(encoded.len()), + "matter_full_len({code:?}) drifted from cesride's {} chars", + encoded.len() + ); + } + } } From 6cf29377250d45f50286eedca411d816ea26f434 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:51:24 +0100 Subject: [PATCH 08/11] ci: run the keripy KEL-verdict differential in the conformance job The signed-KEL differential against keripy is gated on KERIPY_INTEROP, which was set in no job, so the reject cases ran as a self-oracle. Run the Rust differential in the conformance job under uv (which puts keri 1.3.4 on PATH for the test's python subprocess) with the live cross-check enabled. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fbfc5229..fa03c8e87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,6 +388,15 @@ jobs: run: uv run pytest -v --junitxml=../../conformance-results.xml env: AUTHS_BIN: ${{ github.workspace }}/target/debug/auths + # Differential the signed-KEL verdict against keripy's own ingestion path: the + # Rust test shells out to python3, so run it under `uv run` (the conformance + # venv puts keri 1.3.4 on PATH) with the live cross-check enabled. Without this + # the cross-check is gated off and the reject cases are a self-oracle. + - name: KEL-verdict differential vs keripy 1.3.4 + working-directory: tests/conformance + env: + KERIPY_INTEROP: "1" + run: uv run cargo test --manifest-path ../../Cargo.toml -p auths-keri --test integration keripy_subprocess_agrees_on_accept_and_reject -- --nocapture - name: Upload conformance results if: always() uses: actions/upload-artifact@v4 From 80313936e812cebb93a53831ee1d9d7592e70ca6 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 20:51:47 +0100 Subject: [PATCH 09/11] style: rustfmt the CESR length-table test Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/cesr_encode.rs | 20 +++++++++++++++---- .../tests/cases/keripy_kel_oracle.rs | 10 ++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/auths-keri/src/cesr_encode.rs b/crates/auths-keri/src/cesr_encode.rs index 5dc8836f7..11f15092a 100644 --- a/crates/auths-keri/src/cesr_encode.rs +++ b/crates/auths-keri/src/cesr_encode.rs @@ -186,10 +186,22 @@ mod tests { // that shifts a primitive's size fails here loudly instead of drifting the // length-guard against the decoder. let cases: [(&str, String); 7] = [ - ("D", encode_verkey(&[0u8; 32], matter::Codex::Ed25519).unwrap()), - ("B", encode_verkey(&[0u8; 32], matter::Codex::Ed25519N).unwrap()), - ("1AAJ", encode_verkey(&[0u8; 33], matter::Codex::ECDSA_256r1).unwrap()), - ("1AAI", encode_verkey(&[0u8; 33], matter::Codex::ECDSA_256r1N).unwrap()), + ( + "D", + encode_verkey(&[0u8; 32], matter::Codex::Ed25519).unwrap(), + ), + ( + "B", + encode_verkey(&[0u8; 32], matter::Codex::Ed25519N).unwrap(), + ), + ( + "1AAJ", + encode_verkey(&[0u8; 33], matter::Codex::ECDSA_256r1).unwrap(), + ), + ( + "1AAI", + encode_verkey(&[0u8; 33], matter::Codex::ECDSA_256r1N).unwrap(), + ), ("E", encode_blake3_digest(&[0u8; 32]).unwrap()), ( "0B", diff --git a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs index 9b8803614..1a8ae2a9e 100644 --- a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs +++ b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs @@ -177,8 +177,14 @@ fn keripy_subprocess_agrees_on_accept_and_reject() { // Each tamper is a universal KERI rejection; keripy must refuse every one (never reach // sn=1), exactly as auths' offline verdict above does. let rejects: [(&str, Vec); 4] = [ - ("forged inception signature", cesr_stream(&[(&icp_raw, &forged_att)])), - ("unmet inception threshold", cesr_stream(&[(&icp_raw, &one_sig_att)])), + ( + "forged inception signature", + cesr_stream(&[(&icp_raw, &forged_att)]), + ), + ( + "unmet inception threshold", + cesr_stream(&[(&icp_raw, &one_sig_att)]), + ), ( "non-self-addressing inception d", cesr_stream(&[(&replace_field_said(&icp_raw, "d", WRONG_SAID), &icp_att)]), From 8e19d166ed5c344cb839477b22a523ee31a19f6e Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 21:01:53 +0100 Subject: [PATCH 10/11] deps: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185) quinn-proto 0.11.14 is vulnerable to remote memory exhaustion via unbounded out-of-order stream reassembly. 0.11.15 fixes it. Lockfile-only patch bump; quinn-proto reaches the tree through auths-keri's QUIC transport. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d31dc99a2..82efd8949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,7 +1780,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -5382,7 +5382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn", @@ -5446,9 +5446,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", From a5f4078d6be9e41f87933470cabea69aa6b1afed Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 21:29:11 +0100 Subject: [PATCH 11/11] test(auths-keri): fail the keripy differential in CI when keripy is unreachable The cross-check skipped silently when keripy was not importable, so a venv or PATH break would quietly revert the reject cases to a self-oracle while the job stayed green. Add a KERIPY_REQUIRED flag (set by the conformance job) that turns the skips into hard failures, so the differential either runs against keripy or fails the build. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .github/workflows/ci.yml | 1 + crates/auths-keri/tests/cases/keripy_kel_oracle.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa03c8e87..f84096b36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,6 +396,7 @@ jobs: working-directory: tests/conformance env: KERIPY_INTEROP: "1" + KERIPY_REQUIRED: "1" run: uv run cargo test --manifest-path ../../Cargo.toml -p auths-keri --test integration keripy_subprocess_agrees_on_accept_and_reject -- --nocapture - name: Upload conformance results if: always() diff --git a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs index 1a8ae2a9e..9661e0ce9 100644 --- a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs +++ b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs @@ -141,11 +141,17 @@ fn cesr_stream(parts: &[(&[u8], &[u8])]) -> Vec { /// keripy is unavailable. #[test] fn keripy_subprocess_agrees_on_accept_and_reject() { + // `KERIPY_REQUIRED=1` (set by the CI conformance job) turns the otherwise-silent + // skips into hard failures: a venv/PATH break that left keripy unreachable would + // otherwise revert the reject cases to a self-oracle while the job stayed green. + let required = std::env::var("KERIPY_REQUIRED").ok().as_deref() == Some("1"); if std::env::var("KERIPY_INTEROP").ok().as_deref() != Some("1") { + assert!(!required, "KERIPY_REQUIRED=1 but KERIPY_INTEROP is not set"); eprintln!("[SKIP] KERIPY_INTEROP != 1; not invoking keripy"); return; } if !keripy_importable() { + assert!(!required, "KERIPY_REQUIRED=1 but keripy is not importable"); eprintln!("[SKIP] keripy not importable"); return; }