diff --git a/.config/nextest.toml b/.config/nextest.toml index c36c3ec2..a17108bf 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -23,6 +23,11 @@ slow-timeout = { period = "30s", terminate-after = 1 } fail-fast = false retries = 2 +# reqwest/rustls spawns background TLS threads on crate load; allow the leak. +[[profile.default.overrides]] +filter = 'package(auths-infra-http)' +leak-timeout = "500ms" + # Integration tests: serial execution to avoid global state races. # Matches any test binary built from tests/ dirs. [[profile.default.overrides]] diff --git a/.gitignore b/.gitignore index 5b157b50..432d354e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,7 @@ my-artifact.txt.auths.json # Stale E2E test artifacts (nested git repos created by test runs) tests/e2e/.auths-ci/ .capsec-cache + +# jnkn +.jnkn/ +jnkn.db diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b07148e5..72c1f9a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,13 @@ repos: types: [rust] pass_filenames: false + - id: cargo-fmt-packages + name: cargo fmt (packages/) + entry: bash -c 'for d in packages/auths-node packages/auths-python packages/auths-verifier-swift; do [ -f "$d/Cargo.toml" ] && cargo fmt --manifest-path "$d/Cargo.toml" --all; done' + language: system + types: [rust] + pass_filenames: false + - id: cargo-clippy name: cargo clippy entry: cargo clippy --all-targets --all-features -- -D warnings @@ -35,6 +42,13 @@ repos: types: [rust] pass_filenames: false + - id: cargo-clippy-packages + name: cargo clippy (packages/) + entry: bash -c 'for d in packages/auths-node packages/auths-python packages/auths-verifier-swift; do [ -f "$d/Cargo.toml" ] && CARGO_TARGET_DIR=../../target cargo clippy --manifest-path "$d/Cargo.toml" --all-targets -- -D warnings || exit 1; done' + language: system + types: [rust] + pass_filenames: false + # - id: gen-docs # name: cargo xtask gen-docs (auto-fix) # entry: bash -c 'cargo run --package xtask -- gen-docs && git add docs/cli/commands/' diff --git a/Cargo.lock b/Cargo.lock index 8ee42fc1..3ff86853 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,7 @@ dependencies = [ "open", "pkcs8 0.10.2", "predicates 2.1.5", + "rand 0.9.2", "reqwest", "ring", "rpassword", @@ -747,21 +748,25 @@ dependencies = [ "base64", "chrono", "dashmap", + "flate2", "git2", "hex", "html-escape", "json-canon", "parking_lot", + "rand 0.9.2", "reqwest", "ring", "serde", "serde_json", "sha2", "ssh-key", + "tar", "tempfile", "thiserror 2.0.18", "url", "uuid", + "walkdir", "zeroize", ] diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index b304a7cc..796efb8d 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -55,6 +55,7 @@ git2.workspace = true dirs = "6.0.0" chrono = "0.4.40" jsonschema = { version = "0.45.0", default-features = false } +rand = "0.9" rpassword = "7.3.1" log = "0.4.27" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 6fc9a8f1..ecda482c 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -9,6 +9,7 @@ use crate::commands::approval::ApprovalCommand; use crate::commands::artifact::ArtifactCommand; use crate::commands::audit::AuditCommand; use crate::commands::auth::AuthCommand; +use crate::commands::ci::CiCommand; use crate::commands::commit::CommitCmd; use crate::commands::completions::CompletionsCommand; use crate::commands::config::ConfigCommand; @@ -37,7 +38,6 @@ use crate::commands::trust::TrustCommand; use crate::commands::unified_verify::UnifiedVerifyCommand; use crate::commands::whoami::WhoamiCommand; use crate::commands::witness::WitnessCommand; -use crate::config::OutputFormat; fn cli_styles() -> Styles { Styles::styled() @@ -65,16 +65,6 @@ pub struct AuthsCli { #[clap(long, help = "Show all commands including advanced ones")] pub help_all: bool, - #[clap( - long, - value_enum, - default_value = "text", - global = true, - hide = true, - help = "Output format (text or json)" - )] - pub format: OutputFormat, - #[clap(short = 'j', long, global = true, help = "Emit machine-readable JSON")] pub json: bool, @@ -111,6 +101,9 @@ pub enum RootCommand { Config(ConfigCommand), Completions(CompletionsCommand), + // ── CI/CD ── + Ci(CiCommand), + // ── Advanced (visible via --help-all) ── #[command(hide = true)] Reset(ResetCommand), diff --git a/crates/auths-cli/src/commands/ci/forge_backend.rs b/crates/auths-cli/src/commands/ci/forge_backend.rs new file mode 100644 index 00000000..39a18d0b --- /dev/null +++ b/crates/auths-cli/src/commands/ci/forge_backend.rs @@ -0,0 +1,140 @@ +//! Forge backend implementations for setting CI secrets. + +use anyhow::{Context, Result, anyhow}; +use auths_sdk::domains::ci::forge::Forge; +use std::io::Write; +use std::process::{Command, Stdio}; + +/// Abstraction over forge-specific secret-setting operations. +/// +/// Usage: +/// ```ignore +/// let backend = backend_for_forge(&forge); +/// backend.set_secret("AUTHS_CI_TOKEN", &token_json)?; +/// backend.print_ci_template(); +/// ``` +pub trait ForgeBackend { + /// Set a CI secret/variable on the forge. + fn set_secret(&self, name: &str, value: &str) -> Result<()>; + + /// Human-readable forge name. + fn name(&self) -> &str; + + /// Print CI workflow template for this forge. + fn print_ci_template(&self); +} + +/// GitHub backend — sets secrets via `gh secret set`. +pub struct GitHubBackend { + pub owner_repo: String, +} + +impl ForgeBackend for GitHubBackend { + fn set_secret(&self, name: &str, value: &str) -> Result<()> { + // Check gh is available and authenticated (strip GH_TOKEN to avoid stale tokens) + let auth_status = Command::new("gh") + .args(["auth", "status"]) + .env_remove("GH_TOKEN") + .env_remove("GITHUB_TOKEN") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("gh CLI not found — install it from https://cli.github.com")?; + + if !auth_status.success() { + return Err(anyhow!( + "gh CLI is not authenticated. Run `gh auth login` first." + )); + } + + let mut child = Command::new("gh") + .args(["secret", "set", name, "--repo", &self.owner_repo]) + .env_remove("GH_TOKEN") + .env_remove("GITHUB_TOKEN") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to spawn gh secret set")?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(value.as_bytes()) + .context("Failed to write secret to gh stdin")?; + } + + let output = child + .wait_with_output() + .context("Failed to wait for gh secret set")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("gh secret set failed: {}", stderr.trim())); + } + + Ok(()) + } + + fn name(&self) -> &str { + "GitHub" + } + + fn print_ci_template(&self) { + println!("Add to your release workflow:"); + println!(); + println!(" - uses: auths-dev/attest-action@v1"); + println!(" with:"); + println!(" token: ${{{{ secrets.AUTHS_CI_TOKEN }}}}"); + println!(" files: 'dist/*.tar.gz'"); + } +} + +/// Fallback backend for unsupported forges — prints values for manual setup. +pub struct ManualBackend { + pub forge_name: String, +} + +impl ForgeBackend for ManualBackend { + fn set_secret(&self, _name: &str, _value: &str) -> Result<()> { + // No-op — values are printed by the caller on failure + Ok(()) + } + + fn name(&self) -> &str { + &self.forge_name + } + + fn print_ci_template(&self) { + println!("Set AUTHS_CI_TOKEN as a masked CI variable in your forge's settings."); + println!("See https://docs.auths.dev/ci for forge-specific instructions."); + } +} + +/// Create the appropriate backend for a detected forge. +/// +/// Args: +/// * `forge`: The detected forge variant. +/// +/// Usage: +/// ```ignore +/// let backend = backend_for_forge(&forge); +/// ``` +pub fn backend_for_forge(forge: &Forge) -> Box { + match forge { + Forge::GitHub { owner_repo } => Box::new(GitHubBackend { + owner_repo: owner_repo.clone(), + }), + Forge::GitLab { .. } => Box::new(ManualBackend { + forge_name: "GitLab".into(), + }), + Forge::Bitbucket { .. } => Box::new(ManualBackend { + forge_name: "Bitbucket".into(), + }), + Forge::Radicle { .. } => Box::new(ManualBackend { + forge_name: "Radicle".into(), + }), + Forge::Unknown { .. } => Box::new(ManualBackend { + forge_name: "Unknown".into(), + }), + } +} diff --git a/crates/auths-cli/src/commands/ci/mod.rs b/crates/auths-cli/src/commands/ci/mod.rs new file mode 100644 index 00000000..ee2fe3f0 --- /dev/null +++ b/crates/auths-cli/src/commands/ci/mod.rs @@ -0,0 +1,105 @@ +//! CI/CD integration commands — setup and rotate CI signing secrets. + +pub mod forge_backend; +pub mod rotate; +pub mod setup; + +use anyhow::Result; +use clap::{Args, Subcommand}; +use std::sync::Arc; + +use auths_core::signing::PassphraseProvider; +use auths_id::storage::layout; + +use crate::commands::executable::ExecutableCommand; +use crate::config::CliConfig; + +/// CI/CD integration (setup, rotate secrets). +#[derive(Args, Debug, Clone)] +#[command( + about = "CI/CD integration — set up and rotate CI signing secrets.", + after_help = "Examples: + auths ci setup # Auto-detect forge, set AUTHS_CI_TOKEN + auths ci setup --repo owner/repo + # Specify target repo + auths ci rotate # Refresh token, reuse device key + auths ci rotate --max-age-secs 7776000 + # Rotate with 90-day TTL + +Related: + auths device — Manage device authorizations + auths key — Manage cryptographic keys + auths init — Set up identity" +)] +pub struct CiCommand { + #[command(subcommand)] + pub command: CiSubcommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum CiSubcommand { + /// Set up CI secrets for release artifact signing and verification. + Setup { + /// Target repo. Accepts `owner/repo`, HTTPS URL, or SSH URL. + /// Defaults to git remote origin. + #[arg(long)] + repo: Option, + + /// Max age for the verification bundle in seconds (default: 1 year). + #[arg(long, default_value = "31536000")] + max_age_secs: u64, + + /// Disable auto-generated passphrase and prompt interactively instead. + #[arg(long)] + manual_passphrase: bool, + }, + + /// Rotate an existing CI token (regenerate bundle, reuse device key). + Rotate { + /// Target repo override. + #[arg(long)] + repo: Option, + + /// Max age for the verification bundle in seconds (default: 1 year). + #[arg(long, default_value = "31536000")] + max_age_secs: u64, + + /// Disable auto-generated passphrase and prompt interactively instead. + #[arg(long)] + manual_passphrase: bool, + }, +} + +impl ExecutableCommand for CiCommand { + fn execute(&self, ctx: &CliConfig) -> Result<()> { + let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?; + let pp: Arc = Arc::clone(&ctx.passphrase_provider); + + match &self.command { + CiSubcommand::Setup { + repo, + max_age_secs, + manual_passphrase, + } => setup::run_setup( + repo.clone(), + *max_age_secs, + !manual_passphrase, + pp, + &ctx.env_config, + &repo_path, + ), + CiSubcommand::Rotate { + repo, + max_age_secs, + manual_passphrase, + } => rotate::run_rotate( + repo.clone(), + *max_age_secs, + !manual_passphrase, + pp, + &ctx.env_config, + &repo_path, + ), + } + } +} diff --git a/crates/auths-cli/src/commands/ci/rotate.rs b/crates/auths-cli/src/commands/ci/rotate.rs new file mode 100644 index 00000000..fbea3a1e --- /dev/null +++ b/crates/auths-cli/src/commands/ci/rotate.rs @@ -0,0 +1,198 @@ +//! `auths ci rotate` — refresh CI token without regenerating the device key. + +use anyhow::{Context, Result, anyhow}; +use std::path::Path; +use std::sync::Arc; + +use auths_core::config::EnvironmentConfig; +use auths_core::signing::PassphraseProvider; +use auths_core::storage::keychain::{KeyAlias, get_platform_keychain}; +use auths_crypto::did_key::ed25519_pubkey_to_did_key; +use auths_sdk::domains::ci::bundle::{build_identity_bundle, generate_ci_passphrase}; +use auths_sdk::domains::ci::forge::Forge; +use auths_sdk::domains::ci::token::CiToken; +use ring::signature::KeyPair; +use zeroize::Zeroizing; + +use crate::commands::ci::forge_backend::backend_for_forge; +use crate::commands::ci::setup::warn_short_ttl; +use crate::subprocess::git_stdout; + +/// CI device key alias (same as setup). +const CI_DEVICE_ALIAS: &str = "ci-release-device"; + +/// Run the `auths ci rotate` flow. +/// +/// Regenerates the file keychain, identity bundle, and verify bundle, +/// but reuses the existing CI device key (no new key generation or device linking). +/// +/// Args: +/// * `repo_override`: Optional forge repo. Auto-detected from git remote if `None`. +/// * `max_age_secs`: TTL for the verify bundle in seconds. +/// * `auto_passphrase`: If `true`, generate a random hex passphrase. +/// * `_passphrase_provider`: CLI passphrase provider (unused for rotate, kept for consistency). +/// * `_env_config`: Environment configuration. +/// * `repo_path`: Path to the auths registry. +/// +/// Usage: +/// ```ignore +/// run_rotate(None, 31536000, true, &pp, &env, &repo)?; +/// ``` +pub fn run_rotate( + repo_override: Option, + max_age_secs: u64, + auto_passphrase: bool, + _passphrase_provider: Arc, + _env_config: &EnvironmentConfig, + repo_path: &Path, +) -> Result<()> { + println!(); + println!("\x1b[0;36m╔════════════════════════════════════════════════════════════╗\x1b[0m"); + println!( + "\x1b[0;36m║\x1b[0m\x1b[1m CI Token Rotation \x1b[0m\x1b[0;36m║\x1b[0m" + ); + println!("\x1b[0;36m╚════════════════════════════════════════════════════════════╝\x1b[0m"); + println!(); + + // Verify CI device key exists + let keychain = get_platform_keychain()?; + let aliases = keychain + .list_aliases() + .context("Failed to list key aliases")?; + + let has_ci_key = aliases.iter().any(|a| *a == CI_DEVICE_ALIAS); + if !has_ci_key { + return Err(anyhow!( + "No CI device key found. Run `auths ci setup` first." + )); + } + + // Find identity key alias + let identity_key_alias = aliases + .first() + .ok_or_else(|| anyhow!("No keys found in keychain"))? + .to_string(); + + // Handle passphrase + let ci_pass = if auto_passphrase { + let pass = generate_ci_passphrase(); + println!("\x1b[2mAuto-generated new CI passphrase (64-char hex).\x1b[0m"); + Zeroizing::new(pass) + } else { + let pass = rpassword::prompt_password("New CI device passphrase: ") + .context("Failed to read passphrase")?; + let confirm = rpassword::prompt_password("Confirm passphrase: ") + .context("Failed to read confirmation")?; + if pass != confirm { + return Err(anyhow!("Passphrases do not match")); + } + Zeroizing::new(pass) + }; + + // Regenerate file keychain + println!("\x1b[2mRegenerating file keychain...\x1b[0m"); + let keychain_b64 = super::setup::create_file_keychain(keychain.as_ref(), &ci_pass)?; + println!("\x1b[0;32m\u{2713}\x1b[0m File keychain regenerated"); + + // Derive device DID (for display) + let key_alias = KeyAlias::new_unchecked(CI_DEVICE_ALIAS); + let (_, _, encrypted_key) = keychain + .load_key(&key_alias) + .context("Failed to load CI device key")?; + let pkcs8 = auths_core::crypto::signer::decrypt_keypair(&encrypted_key, &ci_pass) + .context("Failed to decrypt CI device key")?; + let kp = auths_id::identity::helpers::load_keypair_from_der_or_seed(&pkcs8)?; + let pub_bytes: [u8; 32] = kp + .public_key() + .as_ref() + .try_into() + .map_err(|_| anyhow!("Public key is not 32 bytes"))?; + let device_did = ed25519_pubkey_to_did_key(&pub_bytes); + + // Repackage identity repo + println!("\x1b[2mRepackaging identity repo...\x1b[0m"); + let identity_repo_b64 = + build_identity_bundle(repo_path).map_err(|e| anyhow!("Bundle failed: {e}"))?; + println!("\x1b[0;32m\u{2713}\x1b[0m Identity repo packaged"); + + // Re-export verify bundle + let identity_storage = + auths_storage::git::RegistryIdentityStorage::new(repo_path.to_path_buf()); + let identity = auths_id::storage::identity::IdentityStorage::load_identity(&identity_storage) + .context("Failed to load identity")?; + let identity_did_str = identity.controller_did.to_string(); + + let verify_bundle_json = + super::setup::build_verify_bundle(&identity_did_str, &pub_bytes, repo_path, max_age_secs)?; + + // Assemble new CiToken + #[allow(clippy::disallowed_methods)] + let now = chrono::Utc::now(); + let token = CiToken::new( + ci_pass.to_string(), + keychain_b64, + identity_repo_b64, + verify_bundle_json, + now.to_rfc3339(), + max_age_secs, + ); + let token_json = token + .to_json() + .map_err(|e| anyhow!("Token serialization: {e}"))?; + + warn_short_ttl(max_age_secs); + if token.is_large() { + eprintln!( + "\x1b[1;33mWarning:\x1b[0m CI token is ~{} KB, approaching GitHub's 48 KB secret limit.", + token.estimated_size() / 1024 + ); + eprintln!(" Consider reducing the identity repo size or splitting secrets."); + } + + // Detect forge + update secret + let forge = match repo_override { + Some(url) => Forge::from_url(&url), + None => { + let url = git_stdout(&["remote", "get-url", "origin"]) + .context("No git remote origin found. Use --repo to specify.")?; + Forge::from_url(&url) + } + }; + + let backend = backend_for_forge(&forge); + println!(); + println!( + "Detected forge: {} ({})", + backend.name(), + forge.repo_identifier() + ); + + match backend.set_secret("AUTHS_CI_TOKEN", &token_json) { + Ok(()) => println!( + "\x1b[0;32m\u{2713}\x1b[0m AUTHS_CI_TOKEN updated on {}", + forge.repo_identifier() + ), + Err(e) => { + eprintln!("\x1b[1;33mCould not update secret automatically: {e}\x1b[0m"); + println!(); + println!("Update AUTHS_CI_TOKEN manually:"); + println!(); + println!("{token_json}"); + } + } + + println!(); + #[allow(clippy::disallowed_methods)] + let expiry = chrono::Utc::now() + chrono::Duration::seconds(max_age_secs as i64); + println!( + "New token expires: {} ({} from now)", + expiry.format("%Y-%m-%d"), + super::setup::humanize_duration(max_age_secs) + ); + println!( + "To revoke: auths device revoke --device-did {} --key {}", + device_did, identity_key_alias + ); + + Ok(()) +} diff --git a/crates/auths-cli/src/commands/ci/setup.rs b/crates/auths-cli/src/commands/ci/setup.rs new file mode 100644 index 00000000..07ae572e --- /dev/null +++ b/crates/auths-cli/src/commands/ci/setup.rs @@ -0,0 +1,380 @@ +//! `auths ci setup` — one-command CI signing setup. + +use anyhow::{Context, Result, anyhow}; +use std::path::Path; +use std::sync::Arc; + +use auths_core::config::EnvironmentConfig; +use auths_core::signing::PassphraseProvider; +use auths_core::storage::encrypted_file::EncryptedFileStorage; +use auths_core::storage::keychain::{ + IdentityDID, KeyAlias, KeyRole, KeyStorage, get_platform_keychain, +}; +use auths_crypto::did_key::ed25519_pubkey_to_did_key; +use auths_id::storage::attestation::AttestationSource; +use auths_id::storage::identity::IdentityStorage; +use auths_sdk::domains::ci::bundle::{build_identity_bundle, generate_ci_passphrase}; +use auths_sdk::domains::ci::forge::Forge; +use auths_sdk::domains::ci::token::CiToken; +use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage}; +use auths_verifier::IdentityBundle; +use ring::signature::KeyPair; +use zeroize::Zeroizing; + +use crate::commands::ci::forge_backend::backend_for_forge; +use crate::factories::storage::build_auths_context; +use crate::subprocess::git_stdout; + +/// CI device key alias used by `auths ci setup`. +const CI_DEVICE_ALIAS: &str = "ci-release-device"; + +/// Run the `auths ci setup` flow. +/// +/// Args: +/// * `repo_override`: Optional forge repo (e.g., `owner/repo`). Auto-detected from git remote if `None`. +/// * `max_age_secs`: TTL for the verify bundle in seconds. +/// * `auto_passphrase`: If `true`, generate a random hex passphrase. Otherwise prompt interactively. +/// * `passphrase_provider`: CLI passphrase provider for key operations. +/// * `env_config`: Environment configuration for keychain backend selection. +/// * `repo_path`: Path to the auths registry (typically `~/.auths`). +/// +/// Usage: +/// ```ignore +/// run_setup(None, 31536000, true, &pp, &env, &repo)?; +/// ``` +pub fn run_setup( + repo_override: Option, + max_age_secs: u64, + auto_passphrase: bool, + passphrase_provider: Arc, + env_config: &EnvironmentConfig, + repo_path: &Path, +) -> Result<()> { + println!(); + println!("\x1b[0;36m╔════════════════════════════════════════════════════════════╗\x1b[0m"); + println!( + "\x1b[0;36m║\x1b[0m\x1b[1m CI Release Signing Setup (One-Time) \x1b[0m\x1b[0;36m║\x1b[0m" + ); + println!("\x1b[0;36m╚════════════════════════════════════════════════════════════╝\x1b[0m"); + println!(); + + // Step 1: Verify identity exists + let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf()); + let identity = identity_storage + .load_identity() + .context("No auths identity found. Run `auths init` first.")?; + + let identity_did_str = identity.controller_did.to_string(); + + // Step 2: Find primary key alias + let keychain = get_platform_keychain()?; + let aliases = keychain + .list_aliases() + .context("Failed to list key aliases")?; + let identity_key_alias = aliases + .first() + .ok_or_else(|| anyhow!("No keys found in keychain. Run `auths init` first."))? + .to_string(); + + println!("\x1b[1mIdentity:\x1b[0m \x1b[0;36m{identity_did_str}\x1b[0m"); + println!("\x1b[1mKey alias:\x1b[0m \x1b[0;36m{identity_key_alias}\x1b[0m"); + println!(); + + // Step 3: Check for existing CI device key + let reuse = aliases.iter().any(|a| *a == CI_DEVICE_ALIAS); + if reuse { + println!("\x1b[2mFound existing {CI_DEVICE_ALIAS} key \u{2014} will reuse it.\x1b[0m"); + } + + // Step 4: Handle passphrase + let ci_pass = if auto_passphrase { + let pass = generate_ci_passphrase(); + println!("\x1b[2mAuto-generated CI passphrase (64-char hex).\x1b[0m"); + Zeroizing::new(pass) + } else { + let pass = rpassword::prompt_password("CI device passphrase: ") + .context("Failed to read passphrase")?; + let confirm = rpassword::prompt_password("Confirm passphrase: ") + .context("Failed to read confirmation")?; + if pass != confirm { + return Err(anyhow!("Passphrases do not match")); + } + Zeroizing::new(pass) + }; + + // Step 5: Generate or reuse CI device key + file keychain + let keychain_b64 = if !reuse { + println!(); + println!("\x1b[2mGenerating CI device key...\x1b[0m"); + + let seed: [u8; 32] = rand::random(); + let seed_z = Zeroizing::new(seed); + + #[allow(clippy::disallowed_methods)] + let identity_did = IdentityDID::new_unchecked(identity_did_str.clone()); + auths_sdk::keys::import_seed( + &seed_z, + &ci_pass, + CI_DEVICE_ALIAS, + &identity_did, + keychain.as_ref(), + ) + .map_err(|e| anyhow!("Failed to import CI device key: {e}"))?; + + println!("\x1b[0;32m\u{2713}\x1b[0m CI device key imported"); + create_file_keychain(keychain.as_ref(), &ci_pass)? + } else { + println!( + "\x1b[2mReusing existing {CI_DEVICE_ALIAS} key \u{2014} regenerating file keychain...\x1b[0m" + ); + create_file_keychain(keychain.as_ref(), &ci_pass)? + }; + + // Step 6: Derive device DID + let key_alias = KeyAlias::new_unchecked(CI_DEVICE_ALIAS); + let (_, _, encrypted_key) = keychain + .load_key(&key_alias) + .context("Failed to load CI device key")?; + let pkcs8 = auths_core::crypto::signer::decrypt_keypair(&encrypted_key, &ci_pass) + .context("Failed to decrypt CI device key")?; + let kp = auths_id::identity::helpers::load_keypair_from_der_or_seed(&pkcs8)?; + let pub_bytes: [u8; 32] = kp + .public_key() + .as_ref() + .try_into() + .map_err(|_| anyhow!("Public key is not 32 bytes"))?; + let device_did = ed25519_pubkey_to_did_key(&pub_bytes); + println!("\x1b[0;32m\u{2713}\x1b[0m Device DID: \x1b[0;36m{device_did}\x1b[0m"); + + // Step 7: Link device (if not already linked) + if !reuse { + link_ci_device( + &identity_key_alias, + &device_did, + repo_path, + env_config, + Arc::clone(&passphrase_provider), + )?; + } + + // Step 8: Package identity repo + println!("\x1b[2mPackaging identity repo...\x1b[0m"); + let identity_repo_b64 = + build_identity_bundle(repo_path).map_err(|e| anyhow!("Bundle failed: {e}"))?; + println!("\x1b[0;32m\u{2713}\x1b[0m Identity repo packaged"); + + // Step 9: Export verify bundle + let verify_bundle_json = + build_verify_bundle(&identity_did_str, &pub_bytes, repo_path, max_age_secs)?; + + // Step 10: Assemble CiToken + #[allow(clippy::disallowed_methods)] + let now = chrono::Utc::now(); + let token = CiToken::new( + ci_pass.to_string(), + keychain_b64, + identity_repo_b64, + verify_bundle_json, + now.to_rfc3339(), + max_age_secs, + ); + let token_json = token + .to_json() + .map_err(|e| anyhow!("Token serialization: {e}"))?; + + // TTL warning + warn_short_ttl(max_age_secs); + + // Size warning + if token.is_large() { + eprintln!( + "\x1b[1;33mWarning:\x1b[0m CI token is ~{} KB, approaching GitHub's 48 KB secret limit.", + token.estimated_size() / 1024 + ); + eprintln!(" Consider reducing the identity repo size or splitting secrets."); + } + + // Step 11: Detect forge + set secret + let forge = match repo_override { + Some(url) => Forge::from_url(&url), + None => { + let url = git_stdout(&["remote", "get-url", "origin"]) + .context("No git remote origin found. Use --repo to specify.")?; + Forge::from_url(&url) + } + }; + + let backend = backend_for_forge(&forge); + println!(); + println!( + "Detected forge: {} ({})", + backend.name(), + forge.repo_identifier() + ); + + match backend.set_secret("AUTHS_CI_TOKEN", &token_json) { + Ok(()) => println!( + "\x1b[0;32m\u{2713}\x1b[0m AUTHS_CI_TOKEN set on {}", + forge.repo_identifier() + ), + Err(e) => { + eprintln!("\x1b[1;33mCould not set secret automatically: {e}\x1b[0m"); + println!(); + println!("Set this manually as a repository secret named AUTHS_CI_TOKEN:"); + println!(); + println!("{token_json}"); + } + } + + // Step 12: Print template + revocation instructions + println!(); + backend.print_ci_template(); + println!(); + + #[allow(clippy::disallowed_methods)] + let expiry = chrono::Utc::now() + chrono::Duration::seconds(max_age_secs as i64); + println!( + "Token expires: {} ({} from now)", + expiry.format("%Y-%m-%d"), + humanize_duration(max_age_secs) + ); + println!("To rotate: auths ci rotate"); + println!( + "To revoke: auths device revoke --device-did {} --key {}", + device_did, identity_key_alias + ); + + Ok(()) +} + +/// Create a portable file-backend keychain from the platform keychain. +pub(super) fn create_file_keychain(keychain: &dyn KeyStorage, passphrase: &str) -> Result { + let key_alias = KeyAlias::new_unchecked(CI_DEVICE_ALIAS); + let (identity_did, _role, encrypted_key_data) = keychain + .load_key(&key_alias) + .context("CI device key not found in keychain")?; + + let tmp = tempfile::TempDir::new().context("Failed to create temp directory")?; + let keychain_path = tmp.path().join("ci-keychain.enc"); + let dst = EncryptedFileStorage::with_path(keychain_path.clone()) + .context("Failed to create file storage")?; + dst.set_password(Zeroizing::new(passphrase.to_string())); + dst.store_key( + &key_alias, + &identity_did, + KeyRole::Primary, + &encrypted_key_data, + ) + .context("Failed to store key in file keychain")?; + + let keychain_bytes = std::fs::read(&keychain_path).context("Failed to read file keychain")?; + Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &keychain_bytes, + )) +} + +/// Link the CI device to the identity. +fn link_ci_device( + identity_key_alias: &str, + device_did: &str, + repo_path: &Path, + env_config: &EnvironmentConfig, + passphrase_provider: Arc, +) -> Result<()> { + println!("\x1b[2mLinking CI device to identity...\x1b[0m"); + + let link_config = auths_sdk::types::DeviceLinkConfig { + identity_key_alias: KeyAlias::new_unchecked(identity_key_alias), + device_key_alias: Some(KeyAlias::new_unchecked(CI_DEVICE_ALIAS)), + device_did: Some(device_did.to_string()), + capabilities: vec![auths_verifier::Capability::sign_release()], + expires_in: None, + note: Some("CI release signer (auths ci setup)".to_string()), + payload: None, + }; + + let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))?; + auths_sdk::domains::device::service::link_device( + link_config, + &ctx, + &auths_core::ports::clock::SystemClock, + ) + .map_err(|e| anyhow!("Failed to link CI device: {e}"))?; + + println!("\x1b[0;32m\u{2713}\x1b[0m CI device linked"); + Ok(()) +} + +/// Build the verify bundle JSON for inclusion in the CiToken. +pub(super) fn build_verify_bundle( + identity_did_str: &str, + public_key_bytes: &[u8; 32], + repo_path: &Path, + max_age_secs: u64, +) -> Result { + let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf()); + let attestations = attestation_storage + .load_all_attestations() + .unwrap_or_default(); + + #[allow(clippy::disallowed_methods)] + let now = chrono::Utc::now(); + + #[allow(clippy::disallowed_methods)] + let identity_did = auths_core::storage::keychain::IdentityDID::new_unchecked(identity_did_str); + #[allow(clippy::disallowed_methods)] + let public_key_hex = auths_verifier::PublicKeyHex::new_unchecked(hex::encode(public_key_bytes)); + + let bundle = IdentityBundle { + identity_did, + public_key_hex, + attestation_chain: attestations, + bundle_timestamp: now, + max_valid_for_secs: max_age_secs, + }; + + serde_json::to_value(&bundle).context("Failed to serialize verify bundle") +} + +/// Print a warning for very short TTL values. +pub fn warn_short_ttl(max_age_secs: u64) { + if max_age_secs < 3600 { + eprintln!( + "\x1b[1;33mWarning:\x1b[0m Token TTL is {}s (< 1 hour). CI will fail after expiry.", + max_age_secs + ); + eprintln!(" Recommended:"); + eprintln!(" 30 days: --max-age-secs 2592000"); + eprintln!(" 90 days: --max-age-secs 7776000"); + eprintln!(" 1 year: --max-age-secs 31536000"); + } +} + +/// Format a duration in seconds to a human-readable string. +pub(super) fn humanize_duration(secs: u64) -> String { + if secs >= 86400 * 365 { + let years = secs / (86400 * 365); + if years == 1 { + "1 year".to_string() + } else { + format!("{years} years") + } + } else if secs >= 86400 { + let days = secs / 86400; + if days == 1 { + "1 day".to_string() + } else { + format!("{days} days") + } + } else if secs >= 3600 { + let hours = secs / 3600; + if hours == 1 { + "1 hour".to_string() + } else { + format!("{hours} hours") + } + } else { + format!("{secs}s") + } +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index ca777878..6ca50ace 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod artifact; pub mod audit; pub mod auth; pub mod cache; +pub mod ci; pub mod commit; pub mod completions; pub mod config; diff --git a/crates/auths-cli/src/factories/mod.rs b/crates/auths-cli/src/factories/mod.rs index 8ea2b365..0cba425f 100644 --- a/crates/auths-cli/src/factories/mod.rs +++ b/crates/auths-cli/src/factories/mod.rs @@ -36,11 +36,10 @@ use crate::core::provider::{CliPassphraseProvider, PrefilledPassphraseProvider}; /// let config = build_config(&cli)?; /// ``` pub fn build_config(cli: &AuthsCli) -> Result { - let is_json = cli.json || matches!(cli.format, OutputFormat::Json); - let output_format = if is_json { + let output_format = if cli.json { OutputFormat::Json } else { - cli.format + OutputFormat::Text }; let env_config = EnvironmentConfig::from_env(); diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index d585e51a..4fd6fc8b 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -5,7 +5,7 @@ use clap::{CommandFactory, Parser}; use auths_cli::cli::{AuthsCli, RootCommand}; use auths_cli::commands::executable::ExecutableCommand; -use auths_cli::config::OutputFormat; + use auths_cli::factories::{build_config, init_audit_sinks}; use auths_cli::ux::format::set_json_mode; @@ -55,8 +55,7 @@ fn run() -> Result<()> { let cli = AuthsCli::parse(); - let is_json = cli.json || matches!(cli.format, OutputFormat::Json); - if is_json { + if cli.json { set_json_mode(true); } @@ -88,6 +87,8 @@ fn run() -> Result<()> { // Utilities RootCommand::Config(cmd) => cmd.execute(&ctx), RootCommand::Completions(cmd) => cmd.execute(&ctx), + // CI/CD + RootCommand::Ci(cmd) => cmd.execute(&ctx), // Advanced RootCommand::Reset(cmd) => cmd.execute(&ctx), RootCommand::SignCommit(cmd) => cmd.execute(&ctx), @@ -141,6 +142,10 @@ const HELP_GROUPS: &[CommandGroup] = &[ heading: "Setup & Troubleshooting", commands: &["pair", "trust", "doctor", "tutorial"], }, + CommandGroup { + heading: "CI/CD", + commands: &["ci"], + }, CommandGroup { heading: "Utilities", commands: &["config", "completions"], diff --git a/crates/auths-cli/tests/cases/clap_collision.rs b/crates/auths-cli/tests/cases/clap_collision.rs new file mode 100644 index 00000000..8c328a74 --- /dev/null +++ b/crates/auths-cli/tests/cases/clap_collision.rs @@ -0,0 +1,75 @@ +//! Regression test: ensure no clap argument name collisions between +//! global flags and subcommand flags (e.g., the --format collision +//! fixed in Epic 1.1). + +use assert_cmd::Command; + +/// Subcommands that have their own `--format` or `--output` flags. +/// Running `--help` on each is enough to trigger clap's duplicate-argument +/// panic if a collision exists. +const SUBCOMMANDS_WITH_FORMAT: &[&[&str]] = &[ + &["key", "export", "--help"], + &["key", "list", "--help"], + &["key", "import", "--help"], + &["key", "delete", "--help"], + &["key", "copy-backend", "--help"], +]; + +/// All top-level subcommands — `--help` must never panic. +const ALL_SUBCOMMANDS: &[&[&str]] = &[ + &["init", "--help"], + &["sign", "--help"], + &["verify", "--help"], + &["artifact", "--help"], + &["status", "--help"], + &["whoami", "--help"], + &["pair", "--help"], + &["trust", "--help"], + &["doctor", "--help"], + &["config", "--help"], + &["completions", "--help"], + &["ci", "--help"], + &["ci", "setup", "--help"], + &["ci", "rotate", "--help"], + &["id", "--help"], + &["device", "--help"], + &["key", "--help"], + &["policy", "--help"], + &["debug", "--help"], +]; + +#[test] +fn key_export_format_flag_does_not_panic() { + for args in SUBCOMMANDS_WITH_FORMAT { + let output = Command::cargo_bin("auths") + .unwrap() + .args(*args) + .output() + .unwrap(); + + assert!( + output.status.success(), + "`auths {}` exited with non-zero status.\nstderr: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr), + ); + } +} + +#[test] +fn all_subcommand_help_pages_do_not_panic() { + for args in ALL_SUBCOMMANDS { + let output = Command::cargo_bin("auths") + .unwrap() + .args(*args) + .output() + .unwrap(); + + assert!( + output.status.success(), + "`auths {}` panicked or failed.\nstderr: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr), + ); + } +} diff --git a/crates/auths-cli/tests/cases/mod.rs b/crates/auths-cli/tests/cases/mod.rs index 13188c53..58586bb7 100644 --- a/crates/auths-cli/tests/cases/mod.rs +++ b/crates/auths-cli/tests/cases/mod.rs @@ -1,3 +1,4 @@ +mod clap_collision; mod doctor; mod golden_path; mod helpers; diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index 0de90ef0..8019c8d3 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -35,6 +35,10 @@ html-escape = "0.2" ssh-key = "0.6" tempfile = "3" url = { version = "2", features = ["serde"] } +flate2 = "1" +rand = "0.9" +tar = "0.4" +walkdir = "2" zeroize = "1.8" uuid = { workspace = true, features = ["serde", "v4"] } dashmap = "6" @@ -52,7 +56,10 @@ auths-core = { workspace = true, features = ["test-utils"] } auths-id = { workspace = true, features = ["test-utils"] } auths-verifier = { workspace = true, features = ["test-utils"] } auths-storage = { workspace = true, features = ["backend-git"] } +base64 = "0.22" +flate2 = "1" git2.workspace = true +tar = "0.4" tempfile = "3" [lints] diff --git a/crates/auths-sdk/src/domains/ci/bundle.rs b/crates/auths-sdk/src/domains/ci/bundle.rs new file mode 100644 index 00000000..95293ba2 --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/bundle.rs @@ -0,0 +1,123 @@ +//! Identity repo bundler — packages `~/.auths` into a portable base64 tar.gz. + +use super::error::CiError; +use base64::Engine as _; +use flate2::Compression; +use flate2::write::GzEncoder; +use std::io::Write; +use std::path::Path; +use tar::Builder; +use walkdir::WalkDir; + +/// Build a base64-encoded tar.gz of the identity repo directory. +/// +/// Creates a flat archive (contents at root, no directory prefix) excluding +/// `*.sock` and `*.lock` files. Sets `mtime(0)` for reproducible archives. +/// +/// Args: +/// * `auths_dir`: Path to the `~/.auths` directory to bundle. +/// +/// Usage: +/// ```ignore +/// let b64 = build_identity_bundle(Path::new("/home/user/.auths"))?; +/// ``` +pub fn build_identity_bundle(auths_dir: &Path) -> Result { + let mut buf = Vec::new(); + { + let gz = GzEncoder::new(&mut buf, Compression::default()); + let mut archive = Builder::new(gz); + add_dir_to_tar(&mut archive, auths_dir, Path::new("."))?; + let gz = archive.into_inner().map_err(|e| CiError::BundleFailed { + reason: format!("tar finalize: {e}"), + })?; + gz.finish().map_err(|e| CiError::BundleFailed { + reason: format!("gzip finalize: {e}"), + })?; + } + Ok(base64::engine::general_purpose::STANDARD.encode(&buf)) +} + +/// Recursively add a directory to a tar archive, excluding `*.sock` and `*.lock` files. +fn add_dir_to_tar( + archive: &mut Builder, + src_dir: &Path, + prefix: &Path, +) -> Result<(), CiError> { + for entry in WalkDir::new(src_dir).follow_links(false) { + let entry = entry.map_err(|e| CiError::BundleFailed { + reason: format!("walk: {e}"), + })?; + let path = entry.path(); + + // Exclude socket and lock files + if let Some(ext) = path.extension() + && (ext == "sock" || ext == "lock") + { + continue; + } + + let rel = path + .strip_prefix(src_dir) + .map_err(|e| CiError::BundleFailed { + reason: format!("strip prefix: {e}"), + })?; + if rel.as_os_str().is_empty() { + continue; + } + let archive_path = prefix.join(rel); + + let metadata = entry.metadata().map_err(|e| CiError::BundleFailed { + reason: format!("metadata for {}: {e}", path.display()), + })?; + + if metadata.is_dir() { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_mtime(0); + header.set_cksum(); + archive + .append_data(&mut header, &archive_path, &[] as &[u8]) + .map_err(|e| CiError::BundleFailed { + reason: format!("append dir {}: {e}", archive_path.display()), + })?; + } else if metadata.is_file() { + #[allow(clippy::disallowed_methods)] + // INVARIANT: bundle must read identity repo files from disk + let data = std::fs::read(path).map_err(|e| CiError::BundleFailed { + reason: format!("read {}: {e}", path.display()), + })?; + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_cksum(); + archive + .append_data(&mut header, &archive_path, data.as_slice()) + .map_err(|e| CiError::BundleFailed { + reason: format!("append file {}: {e}", archive_path.display()), + })?; + } + // Skip symlinks, sockets, etc. + } + Ok(()) +} + +/// Generate a cryptographically secure passphrase for CI device keys. +/// +/// Returns a 64-character hex string (32 random bytes), which is shell-safe +/// across all platforms (no special characters that need escaping). +/// +/// Usage: +/// ```ignore +/// let passphrase = generate_ci_passphrase(); +/// assert_eq!(passphrase.len(), 64); +/// ``` +pub fn generate_ci_passphrase() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} diff --git a/crates/auths-sdk/src/domains/ci/error.rs b/crates/auths-sdk/src/domains/ci/error.rs index 902e4099..5453fd1d 100644 --- a/crates/auths-sdk/src/domains/ci/error.rs +++ b/crates/auths-sdk/src/domains/ci/error.rs @@ -42,6 +42,34 @@ pub enum CiError { /// Underlying error. reason: String, }, + + /// The CI token version is not supported by this build. + #[error("unsupported CI token version {version} (expected 1)")] + TokenVersionUnsupported { + /// The version found in the token. + version: u32, + }, + + /// Failed to deserialize a CI token from JSON. + #[error("CI token deserialization failed: {reason}")] + TokenDeserializationFailed { + /// The underlying parse error message. + reason: String, + }, + + /// Failed to serialize a CI token to JSON. + #[error("CI token serialization failed: {reason}")] + TokenSerializationFailed { + /// The underlying serialization error message. + reason: String, + }, + + /// Failed to build the identity bundle tar.gz. + #[error("identity bundle failed: {reason}")] + BundleFailed { + /// What went wrong during bundling. + reason: String, + }, } impl auths_core::error::AuthsErrorInfo for CiError { @@ -52,6 +80,10 @@ impl auths_core::error::AuthsErrorInfo for CiError { Self::NoArtifacts => "AUTHS-E7003", Self::CollectionDirFailed { .. } => "AUTHS-E7004", Self::CollectionCopyFailed { .. } => "AUTHS-E7005", + Self::TokenVersionUnsupported { .. } => "AUTHS-E7006", + Self::TokenDeserializationFailed { .. } => "AUTHS-E7007", + Self::TokenSerializationFailed { .. } => "AUTHS-E7008", + Self::BundleFailed { .. } => "AUTHS-E7009", } } @@ -61,7 +93,7 @@ impl auths_core::error::AuthsErrorInfo for CiError { Some("Set CI-specific environment variables or pass --ci-environment explicitly") } Self::IdentityBundleInvalid { .. } => { - Some("Re-run `just ci-setup` to regenerate the identity bundle secret") + Some("Re-run `auths ci setup` to regenerate the identity bundle secret") } Self::NoArtifacts => Some("Check your glob pattern matches at least one file"), Self::CollectionDirFailed { .. } => { @@ -70,6 +102,16 @@ impl auths_core::error::AuthsErrorInfo for CiError { Self::CollectionCopyFailed { .. } => { Some("Check file permissions and available disk space") } + Self::TokenVersionUnsupported { .. } => { + Some("Update auths to the latest version to support this token format") + } + Self::TokenDeserializationFailed { .. } => { + Some("Check that AUTHS_CI_TOKEN contains valid JSON from `auths ci setup`") + } + Self::TokenSerializationFailed { .. } => { + Some("This is an internal error — please report it") + } + Self::BundleFailed { .. } => Some("Check that ~/.auths exists and is readable"), } } } diff --git a/crates/auths-sdk/src/domains/ci/forge.rs b/crates/auths-sdk/src/domains/ci/forge.rs new file mode 100644 index 00000000..ae696387 --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/forge.rs @@ -0,0 +1,156 @@ +//! Forge detection from git remote URLs. +//! +//! Parses git remote URLs (HTTPS, SSH, bare shorthand) into a [`Forge`] variant +//! identifying the hosting platform and repository path. + +/// A detected forge (hosting platform) and its repository identifier. +/// +/// Usage: +/// ```ignore +/// let forge = Forge::from_url("git@github.com:owner/repo.git"); +/// assert_eq!(forge.display_name(), "GitHub"); +/// assert_eq!(forge.repo_identifier(), "owner/repo"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Forge { + /// GitHub (github.com or enterprise instances containing "github"). + GitHub { + /// Repository in `owner/repo` format. + owner_repo: String, + }, + /// GitLab (gitlab.com or instances containing "gitlab"). + GitLab { + /// Repository in `group/project` format (may include subgroups). + group_project: String, + }, + /// Bitbucket (bitbucket.org or instances containing "bitbucket"). + Bitbucket { + /// Repository in `workspace/repo` format. + workspace_repo: String, + }, + /// Radicle (hosts containing "radicle"). + Radicle { + /// Radicle repository identifier. + rid: String, + }, + /// Unrecognized hosting platform. + Unknown { + /// The original URL or identifier. + url: String, + }, +} + +impl Forge { + /// Parse any git remote URL or shorthand into a `Forge` variant. + /// + /// Handles HTTPS (`https://github.com/owner/repo.git`), SSH + /// (`git@github.com:owner/repo.git`), and bare shorthand (`owner/repo`). + /// Strips `.git` suffix automatically. + /// + /// Args: + /// * `url`: The git remote URL string. + /// + /// Usage: + /// ```ignore + /// let forge = Forge::from_url("https://github.com/auths-dev/auths.git"); + /// assert!(matches!(forge, Forge::GitHub { .. })); + /// ``` + pub fn from_url(url: &str) -> Self { + let url = url.trim().trim_end_matches(".git"); + + // SSH: git@host:path + if let Some(rest) = url.strip_prefix("git@") + && let Some((host, path)) = rest.split_once(':') + { + return Self::from_host_and_path(host, path); + } + + // SSH with explicit protocol: ssh://git@host/path or ssh://git@host:port/path + if let Some(rest) = url.strip_prefix("ssh://git@") + && let Some((host_port, path)) = rest.split_once('/') + { + let host = host_port.split(':').next().unwrap_or(host_port); + return Self::from_host_and_path(host, path); + } + + // HTTPS/HTTP: https://host/path + if let Some(rest) = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + && let Some((host, path)) = rest.split_once('/') + { + return Self::from_host_and_path(host, path); + } + + // Bare owner/repo — cannot determine forge without hostname + if url.contains('/') && !url.contains(':') && !url.contains('.') { + return Forge::Unknown { + url: url.to_string(), + }; + } + + Forge::Unknown { + url: url.to_string(), + } + } + + /// Match a hostname and path to a forge variant. + fn from_host_and_path(host: &str, path: &str) -> Self { + let path = path + .trim_start_matches('/') + .trim_end_matches('/') + .to_string(); + let host_lower = host.to_lowercase(); + + if host_lower.contains("github") { + Forge::GitHub { owner_repo: path } + } else if host_lower.contains("gitlab") { + Forge::GitLab { + group_project: path, + } + } else if host_lower.contains("bitbucket") { + Forge::Bitbucket { + workspace_repo: path, + } + } else if host_lower.contains("radicle") { + Forge::Radicle { rid: path } + } else { + Forge::Unknown { + url: format!("{host}/{path}"), + } + } + } + + /// Human-readable name for this forge. + /// + /// Usage: + /// ```ignore + /// assert_eq!(Forge::GitHub { owner_repo: "a/b".into() }.display_name(), "GitHub"); + /// ``` + pub fn display_name(&self) -> &str { + match self { + Forge::GitHub { .. } => "GitHub", + Forge::GitLab { .. } => "GitLab", + Forge::Bitbucket { .. } => "Bitbucket", + Forge::Radicle { .. } => "Radicle", + Forge::Unknown { .. } => "Unknown", + } + } + + /// The repository identifier string (e.g., `owner/repo`). + /// + /// Usage: + /// ```ignore + /// let forge = Forge::from_url("git@github.com:auths-dev/auths.git"); + /// assert_eq!(forge.repo_identifier(), "auths-dev/auths"); + /// ``` + pub fn repo_identifier(&self) -> &str { + match self { + Forge::GitHub { owner_repo } => owner_repo, + Forge::GitLab { group_project } => group_project, + Forge::Bitbucket { workspace_repo } => workspace_repo, + Forge::Radicle { rid } => rid, + Forge::Unknown { url } => url, + } + } +} diff --git a/crates/auths-sdk/src/domains/ci/mod.rs b/crates/auths-sdk/src/domains/ci/mod.rs index 2163f3bf..be43c78a 100644 --- a/crates/auths-sdk/src/domains/ci/mod.rs +++ b/crates/auths-sdk/src/domains/ci/mod.rs @@ -1,9 +1,15 @@ //! CI domain — shared types, errors, and environment detection for CI workflows. +pub mod bundle; pub mod environment; pub mod error; +pub mod forge; +pub mod token; pub mod types; +pub use bundle::{build_identity_bundle, generate_ci_passphrase}; pub use environment::map_ci_environment; pub use error::CiError; +pub use forge::Forge; +pub use token::CiToken; pub use types::{CiEnvironment, CiIdentityConfig}; diff --git a/crates/auths-sdk/src/domains/ci/token.rs b/crates/auths-sdk/src/domains/ci/token.rs new file mode 100644 index 00000000..3bac19f2 --- /dev/null +++ b/crates/auths-sdk/src/domains/ci/token.rs @@ -0,0 +1,155 @@ +//! CI token format for bundling all signing/verification secrets into one portable JSON blob. + +use super::error::CiError; +use serde::{Deserialize, Serialize}; + +/// Current token format version. +const CURRENT_VERSION: u32 = 1; + +/// Size threshold (bytes) above which a warning is emitted about GitHub secrets limits. +const SIZE_WARNING_THRESHOLD: usize = 40_960; // 40 KB + +/// Single portable token containing everything CI needs for signing and verification. +/// +/// Set as one GitHub/GitLab/etc secret (`AUTHS_CI_TOKEN`). The CLI produces it; +/// the sign/verify actions consume it. Users never see the internals. +/// +/// Args: +/// * `version`: Format version for forward compatibility (currently 1). +/// * `passphrase`: Passphrase for the CI device key. +/// * `keychain`: Base64-encoded encrypted keychain file (file-backend). +/// * `identity_repo`: Base64-encoded tar.gz of `~/.auths` (flat format). +/// * `verify_bundle`: Identity bundle JSON for verification. +/// * `created_at`: ISO 8601 timestamp of when this token was created. +/// * `max_valid_for_secs`: Max age of the verify bundle in seconds. +/// +/// Usage: +/// ```ignore +/// let token = CiToken::new(passphrase, keychain_b64, repo_b64, bundle_json, 31536000); +/// let json = token.to_json()?; +/// let parsed = CiToken::from_json(&json)?; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CiToken { + /// Format version for forward compatibility. + pub version: u32, + + /// Passphrase for the CI device key. + pub passphrase: String, + + /// Base64-encoded encrypted keychain file (file-backend). + pub keychain: String, + + /// Base64-encoded tar.gz of `~/.auths` (flat format: contents at root, no `.auths/` prefix). + pub identity_repo: String, + + /// Identity bundle JSON for verification (output of `auths id export-bundle`). + pub verify_bundle: serde_json::Value, + + /// When this token was created (ISO 8601). + pub created_at: String, + + /// Max age of the verify bundle in seconds. + pub max_valid_for_secs: u64, +} + +impl CiToken { + /// Create a new `CiToken` with the current version and timestamp. + /// + /// Args: + /// * `passphrase`: Passphrase for the CI device key. + /// * `keychain`: Base64-encoded encrypted keychain. + /// * `identity_repo`: Base64-encoded tar.gz of the identity repo. + /// * `verify_bundle`: Verify bundle JSON value. + /// * `created_at`: ISO 8601 timestamp string. + /// * `max_valid_for_secs`: TTL for the verify bundle. + /// + /// Usage: + /// ```ignore + /// let token = CiToken::new(pass, kc, repo, bundle, now_str, 31536000); + /// ``` + pub fn new( + passphrase: String, + keychain: String, + identity_repo: String, + verify_bundle: serde_json::Value, + created_at: String, + max_valid_for_secs: u64, + ) -> Self { + Self { + version: CURRENT_VERSION, + passphrase, + keychain, + identity_repo, + verify_bundle, + created_at, + max_valid_for_secs, + } + } + + /// Serialize this token to a JSON string. + /// + /// Usage: + /// ```ignore + /// let json_str = token.to_json()?; + /// ``` + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(|e| CiError::TokenSerializationFailed { + reason: e.to_string(), + }) + } + + /// Deserialize a token from a JSON string, validating the version. + /// + /// Args: + /// * `json`: JSON string representing a `CiToken`. + /// + /// Usage: + /// ```ignore + /// let token = CiToken::from_json(&json_str)?; + /// ``` + pub fn from_json(json: &str) -> Result { + let token: Self = + serde_json::from_str(json).map_err(|e| CiError::TokenDeserializationFailed { + reason: e.to_string(), + })?; + + if token.version != CURRENT_VERSION { + return Err(CiError::TokenVersionUnsupported { + version: token.version, + }); + } + + Ok(token) + } + + /// Estimate the byte size of this token when serialized to JSON. + /// + /// Usage: + /// ```ignore + /// let size = token.estimated_size(); + /// ``` + pub fn estimated_size(&self) -> usize { + // Approximate: sum of field lengths plus JSON overhead + self.passphrase.len() + + self.keychain.len() + + self.identity_repo.len() + + self.verify_bundle.to_string().len() + + self.created_at.len() + + 200 // JSON keys, braces, commas, version, max_valid_for_secs + } + + /// Returns `true` if the token exceeds the size warning threshold (40 KB). + /// + /// The caller is responsible for displaying any warning to the user. + /// + /// Usage: + /// ```ignore + /// if token.is_large() { + /// eprintln!("Warning: token is ~{} KB", token.estimated_size() / 1024); + /// } + /// ``` + pub fn is_large(&self) -> bool { + self.estimated_size() > SIZE_WARNING_THRESHOLD + } +} diff --git a/crates/auths-sdk/tests/cases/ci_token.rs b/crates/auths-sdk/tests/cases/ci_token.rs new file mode 100644 index 00000000..1e7f92d1 --- /dev/null +++ b/crates/auths-sdk/tests/cases/ci_token.rs @@ -0,0 +1,282 @@ +//! Tests for CiToken, Forge URL parsing, identity bundler, and passphrase generation. + +use auths_sdk::domains::ci::bundle::{build_identity_bundle, generate_ci_passphrase}; +use auths_sdk::domains::ci::error::CiError; +use auths_sdk::domains::ci::forge::Forge; +use auths_sdk::domains::ci::token::CiToken; + +// ── CiToken serialization ── + +#[test] +fn ci_token_serialize_roundtrip() { + let token = CiToken::new( + "abcdef1234567890".to_string(), + "base64keychain==".to_string(), + "base64repo==".to_string(), + serde_json::json!({"identity_did": "did:keri:test"}), + "2026-01-01T00:00:00Z".to_string(), + 31536000, + ); + + let json = token.to_json().unwrap(); + let parsed = CiToken::from_json(&json).unwrap(); + + assert_eq!(parsed.version, 1); + assert_eq!(parsed.passphrase, "abcdef1234567890"); + assert_eq!(parsed.keychain, "base64keychain=="); + assert_eq!(parsed.identity_repo, "base64repo=="); + assert_eq!(parsed.created_at, "2026-01-01T00:00:00Z"); + assert_eq!(parsed.max_valid_for_secs, 31536000); +} + +#[test] +fn ci_token_rejects_unsupported_version() { + let json = r#"{ + "version": 99, + "passphrase": "test", + "keychain": "test", + "identity_repo": "test", + "verify_bundle": {}, + "created_at": "2026-01-01T00:00:00Z", + "max_valid_for_secs": 31536000 + }"#; + + let result = CiToken::from_json(json); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, CiError::TokenVersionUnsupported { version: 99 }), + "Expected TokenVersionUnsupported, got: {err:?}" + ); +} + +#[test] +fn ci_token_rejects_invalid_json() { + let result = CiToken::from_json("not valid json"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CiError::TokenDeserializationFailed { .. } + )); +} + +#[test] +fn ci_token_estimated_size() { + let token = CiToken::new( + "a".repeat(64), + "b".repeat(1000), + "c".repeat(30000), + serde_json::json!({"key": "value"}), + "2026-01-01T00:00:00Z".to_string(), + 31536000, + ); + + let estimated = token.estimated_size(); + let actual_json = token.to_json().unwrap(); + let actual_size = actual_json.len(); + + // Estimate should be within 20% of actual + let tolerance = actual_size / 5; + assert!( + estimated.abs_diff(actual_size) < tolerance, + "Estimated {estimated} vs actual {actual_size} (tolerance {tolerance})" + ); +} + +// ── Forge URL parsing ── + +#[test] +fn forge_from_github_https() { + let forge = Forge::from_url("https://github.com/owner/repo.git"); + assert_eq!( + forge, + Forge::GitHub { + owner_repo: "owner/repo".to_string() + } + ); + assert_eq!(forge.display_name(), "GitHub"); + assert_eq!(forge.repo_identifier(), "owner/repo"); +} + +#[test] +fn forge_from_github_https_no_suffix() { + let forge = Forge::from_url("https://github.com/owner/repo"); + assert_eq!( + forge, + Forge::GitHub { + owner_repo: "owner/repo".to_string() + } + ); +} + +#[test] +fn forge_from_github_ssh() { + let forge = Forge::from_url("git@github.com:auths-dev/auths.git"); + assert_eq!( + forge, + Forge::GitHub { + owner_repo: "auths-dev/auths".to_string() + } + ); +} + +#[test] +fn forge_from_gitlab_https() { + let forge = Forge::from_url("https://gitlab.com/group/project.git"); + assert_eq!( + forge, + Forge::GitLab { + group_project: "group/project".to_string() + } + ); +} + +#[test] +fn forge_from_gitlab_ssh() { + let forge = Forge::from_url("git@gitlab.com:group/subgroup/project.git"); + assert_eq!( + forge, + Forge::GitLab { + group_project: "group/subgroup/project".to_string() + } + ); +} + +#[test] +fn forge_from_bitbucket() { + let forge = Forge::from_url("git@bitbucket.org:workspace/repo.git"); + assert_eq!( + forge, + Forge::Bitbucket { + workspace_repo: "workspace/repo".to_string() + } + ); +} + +#[test] +fn forge_from_unknown_host() { + let forge = Forge::from_url("https://selfhosted.example.com/org/repo.git"); + assert_eq!( + forge, + Forge::Unknown { + url: "selfhosted.example.com/org/repo".to_string() + } + ); +} + +#[test] +fn forge_from_enterprise_github() { + let forge = Forge::from_url("https://github.acme.com/internal/tools.git"); + assert_eq!( + forge, + Forge::GitHub { + owner_repo: "internal/tools".to_string() + } + ); +} + +#[test] +fn forge_from_ssh_with_explicit_protocol() { + let forge = Forge::from_url("ssh://git@github.com/owner/repo.git"); + assert_eq!( + forge, + Forge::GitHub { + owner_repo: "owner/repo".to_string() + } + ); +} + +#[test] +fn forge_strips_trailing_slash() { + let forge = Forge::from_url("https://github.com/owner/repo/"); + assert_eq!(forge.repo_identifier(), "owner/repo"); +} + +// ── Passphrase generation ── + +#[test] +fn ci_passphrase_is_hex_64_chars() { + let pass = generate_ci_passphrase(); + assert_eq!(pass.len(), 64); + assert!( + pass.chars().all(|c| c.is_ascii_hexdigit()), + "Passphrase contains non-hex chars: {pass}" + ); +} + +#[test] +fn ci_passphrase_is_unique() { + let a = generate_ci_passphrase(); + let b = generate_ci_passphrase(); + assert_ne!(a, b, "Two generated passphrases should differ"); +} + +// ── Identity bundle ── + +#[test] +fn build_identity_bundle_produces_valid_base64() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + // Create some test files + std::fs::write(dir.join("config"), b"test config").unwrap(); + std::fs::create_dir_all(dir.join("objects")).unwrap(); + std::fs::write(dir.join("objects/abc"), b"object data").unwrap(); + + let b64 = build_identity_bundle(dir).unwrap(); + + // Should be valid base64 + use base64::Engine as _; + let decoded = base64::engine::general_purpose::STANDARD + .decode(&b64) + .expect("Should be valid base64"); + assert!(!decoded.is_empty()); + + // Should be valid gzip + use std::io::Read; + let mut gz = flate2::read::GzDecoder::new(&decoded[..]); + let mut decompressed = Vec::new(); + gz.read_to_end(&mut decompressed) + .expect("Should be valid gzip"); + assert!(!decompressed.is_empty()); +} + +#[test] +fn build_identity_bundle_excludes_socks_and_locks() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + std::fs::write(dir.join("config"), b"keep").unwrap(); + std::fs::write(dir.join("agent.sock"), b"exclude").unwrap(); + std::fs::write(dir.join("registry.lock"), b"exclude").unwrap(); + + let b64 = build_identity_bundle(dir).unwrap(); + + // Decode and read tar entries + use base64::Engine as _; + let decoded = base64::engine::general_purpose::STANDARD + .decode(&b64) + .unwrap(); + let gz = flate2::read::GzDecoder::new(&decoded[..]); + let mut archive = tar::Archive::new(gz); + + let entry_names: Vec = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path().unwrap().display().to_string()) + .collect(); + + assert!( + entry_names.iter().any(|n| n.contains("config")), + "Should include config, got: {entry_names:?}" + ); + assert!( + !entry_names.iter().any(|n| n.contains("sock")), + "Should exclude .sock files, got: {entry_names:?}" + ); + assert!( + !entry_names.iter().any(|n| n.contains("lock")), + "Should exclude .lock files, got: {entry_names:?}" + ); +} diff --git a/crates/auths-sdk/tests/cases/mod.rs b/crates/auths-sdk/tests/cases/mod.rs index 82585c0e..b345dfa5 100644 --- a/crates/auths-sdk/tests/cases/mod.rs +++ b/crates/auths-sdk/tests/cases/mod.rs @@ -2,6 +2,7 @@ mod allowed_signers; mod artifact; mod audit; mod ci_setup; +mod ci_token; mod device; mod diagnostics; pub mod helpers; diff --git a/packages/auths-node/src/artifact.rs b/packages/auths-node/src/artifact.rs index 3e758470..59d8bcf6 100644 --- a/packages/auths-node/src/artifact.rs +++ b/packages/auths-node/src/artifact.rs @@ -268,6 +268,7 @@ pub fn sign_artifact_bytes_raw( }) .transpose()?; + #[allow(clippy::disallowed_methods)] // FFI boundary: clock injected from here into domain code let now = Utc::now(); let data_len = data.len(); diff --git a/packages/auths-node/src/identity.rs b/packages/auths-node/src/identity.rs index 9d4f5ed9..58c59757 100644 --- a/packages/auths-node/src/identity.rs +++ b/packages/auths-node/src/identity.rs @@ -490,9 +490,9 @@ pub fn generate_inmemory_keypair() -> napi::Result { .map_err(|e| format_error("AUTHS_CRYPTO_ERROR", format!("Seed extraction failed: {e}")))?; let pub_bytes = keypair.public_key().as_ref(); - let pub_array: &[u8; 32] = pub_bytes.try_into().map_err(|_| { - format_error("AUTHS_CRYPTO_ERROR", "Invalid public key length") - })?; + let pub_array: &[u8; 32] = pub_bytes + .try_into() + .map_err(|_| format_error("AUTHS_CRYPTO_ERROR", "Invalid public key length"))?; let did = ed25519_pubkey_to_did_key(pub_array); Ok(NapiInMemoryKeypair { diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index c9d27b70..5242b727 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -364,19 +370,23 @@ dependencies = [ "base64", "chrono", "dashmap", + "flate2", "hex", "html-escape", "json-canon", "parking_lot", + "rand 0.9.2", "ring", "serde", "serde_json", "sha2", "ssh-key", + "tar", "tempfile", "thiserror 2.0.18", "url", "uuid", + "walkdir", "zeroize", ] @@ -856,6 +866,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1172,12 +1191,33 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -1972,7 +2012,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -2121,6 +2164,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -2352,7 +2405,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2432,6 +2485,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "poly1305" version = "0.8.0" @@ -2790,6 +2849,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3347,6 +3415,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3545,6 +3619,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -4672,6 +4757,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/packages/auths-python/src/artifact_sign.rs b/packages/auths-python/src/artifact_sign.rs index 244f6669..773fba03 100644 --- a/packages/auths-python/src/artifact_sign.rs +++ b/packages/auths-python/src/artifact_sign.rs @@ -214,6 +214,7 @@ fn build_context_and_sign( /// ``` #[pyfunction] #[pyo3(signature = (file_path, identity_key_alias, repo_path, passphrase=None, expires_in=None, note=None, commit_sha=None))] +#[allow(clippy::too_many_arguments)] // PyO3 boundary: _py context param is mandatory pub fn sign_artifact( _py: Python<'_>, file_path: &str, @@ -258,6 +259,7 @@ pub fn sign_artifact( /// ``` #[pyfunction] #[pyo3(signature = (data, identity_key_alias, repo_path, passphrase=None, expires_in=None, note=None, commit_sha=None))] +#[allow(clippy::too_many_arguments)] // PyO3 boundary: _py context param is mandatory pub fn sign_artifact_bytes( _py: Python<'_>, data: &[u8], @@ -313,6 +315,7 @@ pub fn sign_artifact_bytes_raw( let did = IdentityDID::parse(identity_did) .map_err(|e| PyValueError::new_err(format!("[AUTHS_INVALID_INPUT] {e}")))?; + #[allow(clippy::disallowed_methods)] // FFI boundary: clock injected from here into domain code let now = Utc::now(); let result = diff --git a/packages/auths-python/src/identity.rs b/packages/auths-python/src/identity.rs index b70d1e4f..80743cb0 100644 --- a/packages/auths-python/src/identity.rs +++ b/packages/auths-python/src/identity.rs @@ -661,9 +661,9 @@ pub fn generate_inmemory_keypair() -> PyResult<(String, String, String)> { })?; let pub_bytes = keypair.public_key().as_ref(); - let pub_array: &[u8; 32] = pub_bytes.try_into().map_err(|_| { - PyRuntimeError::new_err("[AUTHS_CRYPTO_ERROR] Invalid public key length") - })?; + let pub_array: &[u8; 32] = pub_bytes + .try_into() + .map_err(|_| PyRuntimeError::new_err("[AUTHS_CRYPTO_ERROR] Invalid public key length"))?; let did = ed25519_pubkey_to_did_key(pub_array); Ok((hex::encode(seed), hex::encode(pub_bytes), did)) diff --git a/packages/auths-verifier-swift/Cargo.toml b/packages/auths-verifier-swift/Cargo.toml index 0e70c772..1950348e 100644 --- a/packages/auths-verifier-swift/Cargo.toml +++ b/packages/auths-verifier-swift/Cargo.toml @@ -30,6 +30,9 @@ chrono = { version = "0.4", features = ["serde"] } # Error handling thiserror = "2.0" +# Async runtime for blocking on async verify functions +tokio = { version = "1", features = ["rt"] } + [[bin]] name = "uniffi-bindgen" path = "uniffi-bindgen.rs" diff --git a/packages/auths-verifier-swift/src/lib.rs b/packages/auths-verifier-swift/src/lib.rs index 4108ea78..b5f60bf3 100644 --- a/packages/auths-verifier-swift/src/lib.rs +++ b/packages/auths-verifier-swift/src/lib.rs @@ -5,15 +5,12 @@ use ::auths_verifier::core::{Attestation, MAX_ATTESTATION_JSON_SIZE, MAX_JSON_BATCH_SIZE}; use ::auths_verifier::types::{ - ChainLink as RustChainLink, - DeviceDID, - VerificationReport as RustVerificationReport, + ChainLink as RustChainLink, DeviceDID, VerificationReport as RustVerificationReport, VerificationStatus as RustVerificationStatus, }; use ::auths_verifier::verify::{ verify_chain as rust_verify_chain, - verify_device_authorization as rust_verify_device_authorization, - verify_with_keys, + verify_device_authorization as rust_verify_device_authorization, verify_with_keys, }; // Use proc-macro based approach (no UDL) @@ -57,6 +54,7 @@ pub enum VerificationStatus { Revoked { at: Option }, InvalidSignature { step: u32 }, BrokenChain { missing_link: String }, + InsufficientWitnesses { required: u32, verified: u32 }, } impl From for VerificationStatus { @@ -75,6 +73,12 @@ impl From for VerificationStatus { RustVerificationStatus::BrokenChain { missing_link } => { VerificationStatus::BrokenChain { missing_link } } + RustVerificationStatus::InsufficientWitnesses { required, verified } => { + VerificationStatus::InsufficientWitnesses { + required: required as u32, + verified: verified as u32, + } + } } } } @@ -136,7 +140,8 @@ pub fn verify_attestation(attestation_json: String, issuer_pk_hex: String) -> Ve valid: false, error: Some(format!( "Attestation JSON too large: {} bytes, max {}", - attestation_json.len(), MAX_ATTESTATION_JSON_SIZE + attestation_json.len(), + MAX_ATTESTATION_JSON_SIZE )), }; } @@ -173,8 +178,17 @@ pub fn verify_attestation(attestation_json: String, issuer_pk_hex: String) -> Ve } }; - // Verify - match verify_with_keys(&att, &issuer_pk_bytes) { + // Verify (bridge sync UniFFI boundary → async verifier) + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + return VerificationResult { + valid: false, + error: Some(format!("Failed to create async runtime: {e}")), + }; + } + }; + match rt.block_on(verify_with_keys(&att, &issuer_pk_bytes)) { Ok(_verified) => VerificationResult { valid: true, error: None, @@ -228,8 +242,10 @@ pub fn verify_chain( }) .collect::, _>>()?; - // Verify chain - match rust_verify_chain(&attestations, &root_pk_bytes) { + // Verify chain (bridge sync UniFFI boundary → async verifier) + let rt = tokio::runtime::Runtime::new() + .map_err(|e| VerifierError::VerificationFailed(format!("Async runtime: {e}")))?; + match rt.block_on(rust_verify_chain(&attestations, &root_pk_bytes)) { Ok(report) => Ok(report.into()), Err(e) => Err(VerifierError::VerificationFailed(format!( "Chain verification failed: {}", @@ -290,8 +306,15 @@ pub fn verify_device_authorization( let device = DeviceDID::parse(&device_did) .map_err(|e| VerifierError::InvalidInput(format!("Invalid device DID: {e}")))?; - // Verify - match rust_verify_device_authorization(&identity_did, &device, &attestations, &identity_pk_bytes) { + // Verify (bridge sync UniFFI boundary → async verifier) + let rt = tokio::runtime::Runtime::new() + .map_err(|e| VerifierError::VerificationFailed(format!("Async runtime: {e}")))?; + match rt.block_on(rust_verify_device_authorization( + &identity_did, + &device, + &attestations, + &identity_pk_bytes, + )) { Ok(report) => Ok(report.into()), Err(e) => Err(VerifierError::VerificationFailed(format!( "Device authorization verification failed: {}",