diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fbfc5229..f84096b36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,6 +388,16 @@ 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" + 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() uses: actions/upload-artifact@v4 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", diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index 582c7ba84..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)) @@ -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-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index f67f2086a..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"; @@ -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, @@ -306,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(); @@ -433,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 @@ -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..c16ce42d3 100644 --- a/crates/auths-cli/src/commands/init/prompts.rs +++ b/crates/auths-cli/src/commands/init/prompts.rs @@ -5,6 +5,7 @@ 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; @@ -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-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index 6697b71f9..097f760cd 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,30 @@ 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-keri/src/cesr_encode.rs b/crates/auths-keri/src/cesr_encode.rs index c066b6be5..11f15092a 100644 --- a/crates/auths-keri/src/cesr_encode.rs +++ b/crates/auths-keri/src/cesr_encode.rs @@ -177,4 +177,56 @@ 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() + ); + } + } } diff --git a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs index 7b5ae5c1c..9661e0ce9 100644 --- a/crates/auths-keri/tests/cases/keripy_kel_oracle.rs +++ b/crates/auths-keri/tests/cases/keripy_kel_oracle.rs @@ -106,16 +106,52 @@ 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() { + // `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; } @@ -125,35 +161,55 @@ 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 { 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..c1de8dcb2 100644 --- a/crates/auths-sdk/src/domains/ci/types.rs +++ b/crates/auths-sdk/src/domains/ci/types.rs @@ -3,13 +3,28 @@ 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 +91,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/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()); + } +} 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/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; 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); + } +} 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,