diff --git a/Cargo.lock b/Cargo.lock index e2734afd..3e89ea1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "ssh-key", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/README.md b/README.md index f6a09c42..1f4680e1 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ auths device revoke --device-did did:key:z6Mk... auths verify attestation.json ``` -**Export allowed-signers for Git verification** +**Sync allowed-signers for Git verification** ```bash -auths git allowed-signers >> ~/.ssh/allowed_signers +auths signers sync ``` --- @@ -161,7 +161,9 @@ No central server. No blockchain. Just Git and cryptography. | `auths verify` | Verify an attestation | | `auths verify-commit` | Verify a signed commit | | `auths git setup` | Configure Git for signing | -| `auths git allowed-signers` | Generate allowed-signers file | +| `auths signers sync` | Sync allowed-signers from registry | +| `auths signers list` | List allowed signers | +| `auths signers add` | Add a manual signer | Run `auths --help` for full documentation. diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 734b59f3..229730df 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -64,7 +64,8 @@ sha2 = "0.10" tempfile = "3" thiserror.workspace = true zeroize = "1.8" -reqwest = { version = "0.13.2", features = ["json", "form"] } +reqwest = { version = "0.13.2", features = ["json", "form", "blocking"] } +ssh-key = "0.6" url = "2.5" which = "8.0.0" open = "5" diff --git a/crates/auths-cli/src/bin/verify.rs b/crates/auths-cli/src/bin/verify.rs index 0723eef8..a722b459 100644 --- a/crates/auths-cli/src/bin/verify.rs +++ b/crates/auths-cli/src/bin/verify.rs @@ -154,7 +154,7 @@ fn verify_file( bail!( "Allowed signers file not found: {:?}\n\n\ Create it with:\n \ - auths git allowed-signers > {:?}", + auths signers sync --output {:?}", allowed_signers, allowed_signers ); diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 0b20cdd5..7d689365 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -24,6 +24,7 @@ use crate::commands::org::OrgCommand; use crate::commands::policy::PolicyCommand; use crate::commands::scim::ScimCommand; use crate::commands::sign::SignCommand; +use crate::commands::signers::SignersCommand; use crate::commands::status::StatusCommand; use crate::commands::trust::TrustCommand; use crate::commands::unified_verify::UnifiedVerifyCommand; @@ -92,6 +93,7 @@ pub enum RootCommand { Whoami(WhoamiCommand), Tutorial(LearnCommand), Doctor(DoctorCommand), + Signers(SignersCommand), Pair(PairCommand), #[command(hide = true)] Completions(CompletionsCommand), diff --git a/crates/auths-cli/src/commands/doctor.rs b/crates/auths-cli/src/commands/doctor.rs index fdce4f43..a47bd489 100644 --- a/crates/auths-cli/src/commands/doctor.rs +++ b/crates/auths-cli/src/commands/doctor.rs @@ -180,22 +180,56 @@ fn check_identity_exists() -> Check { } fn check_allowed_signers_file() -> Check { + use auths_sdk::workflows::allowed_signers::{AllowedSigners, SignerSource}; + let path = crate::factories::storage::read_git_config("gpg.ssh.allowedSignersFile") .ok() .flatten(); let (passed, detail, suggestion) = match path { Some(path_str) => { - if std::path::Path::new(&path_str).exists() { - (true, format!("Set to: {path_str}"), None) + let file_path = std::path::Path::new(&path_str); + if file_path.exists() { + match AllowedSigners::load(file_path) { + Ok(signers) => { + let entries = signers.list(); + let attestation_count = entries + .iter() + .filter(|e| e.source == SignerSource::Attestation) + .count(); + let manual_count = entries + .iter() + .filter(|e| e.source == SignerSource::Manual) + .count(); + + let has_markers = std::fs::read_to_string(file_path) + .map(|c| c.contains("# auths:attestation")) + .unwrap_or(false); + + let mut detail = format!( + "{path_str} ({} attestation, {} manual)", + attestation_count, manual_count + ); + + if !has_markers && !entries.is_empty() { + detail.push_str( + " [no auths markers — run `auths signers sync` to add them]", + ); + } + + (true, detail, None) + } + Err(_) => ( + true, + format!("{path_str} (exists, could not parse entries)"), + None, + ), + } } else { ( false, format!("Configured but file not found: {path_str}"), - Some( - "Run: auths init --profile developer (regenerates allowed_signers)" - .to_string(), - ), + Some("Run: auths signers sync (regenerates allowed_signers)".to_string()), ) } } diff --git a/crates/auths-cli/src/commands/git.rs b/crates/auths-cli/src/commands/git.rs index ad8d554a..21ac3ac5 100644 --- a/crates/auths-cli/src/commands/git.rs +++ b/crates/auths-cli/src/commands/git.rs @@ -1,12 +1,7 @@ //! Git integration commands for Auths. -//! -//! Provides commands for managing Git allowed_signers files based on -//! Auths device authorizations. use anyhow::{Context, Result, bail}; -use auths_sdk::workflows::git_integration::{ - format_allowed_signers_file, generate_allowed_signers, -}; +use auths_sdk::workflows::allowed_signers::AllowedSigners; use auths_storage::git::RegistryAttestationStorage; use clap::{Parser, Subcommand}; #[cfg(unix)] @@ -26,32 +21,11 @@ pub struct GitCommand { #[derive(Subcommand, Debug, Clone)] pub enum GitSubcommand { - /// Generate allowed_signers file from Auths device authorizations. - /// - /// Scans the identity repository for authorized devices and outputs - /// an allowed_signers file compatible with Git's ssh.allowedSignersFile. - #[command(name = "allowed-signers")] - AllowedSigners(AllowedSignersCommand), - /// Install Git hooks for automatic allowed_signers regeneration. - /// - /// Installs a post-merge hook that regenerates the allowed_signers file - /// when identity refs change after a git pull/merge. #[command(name = "install-hooks")] InstallHooks(InstallHooksCommand), } -#[derive(Parser, Debug, Clone)] -pub struct AllowedSignersCommand { - /// Path to the Auths identity repository. - #[arg(long, default_value = "~/.auths")] - pub repo: PathBuf, - - /// Output file path. If not specified, outputs to stdout. - #[arg(long = "output", short = 'o')] - pub output_file: Option, -} - #[derive(Parser, Debug, Clone)] pub struct InstallHooksCommand { /// Path to the Git repository where hooks should be installed. @@ -73,19 +47,8 @@ pub struct InstallHooksCommand { } /// Handle git subcommand. -pub fn handle_git( - cmd: GitCommand, - repo_override: Option, - attestation_prefix_override: Option, - attestation_blob_name_override: Option, -) -> Result<()> { +pub fn handle_git(cmd: GitCommand, repo_override: Option) -> Result<()> { match cmd.command { - GitSubcommand::AllowedSigners(subcmd) => handle_allowed_signers( - subcmd, - repo_override, - attestation_prefix_override, - attestation_blob_name_override, - ), GitSubcommand::InstallHooks(subcmd) => handle_install_hooks(subcmd, repo_override), } } @@ -108,7 +71,8 @@ fn handle_install_hooks( let existing = fs::read_to_string(&post_merge_path) .with_context(|| format!("Failed to read existing hook: {:?}", post_merge_path))?; - if existing.contains("auths git allowed-signers") { + if existing.contains("auths git allowed-signers") || existing.contains("auths signers sync") + { println!( "Auths post-merge hook already installed at {:?}", post_merge_path @@ -120,8 +84,9 @@ fn handle_install_hooks( "A post-merge hook already exists at {:?}\n\ It was not created by Auths. Use --force to overwrite, or manually \n\ add the following to your existing hook:\n\n\ - auths git allowed-signers --output {}", + auths signers sync --repo {} --output {}", post_merge_path, + cmd.auths_repo.display(), cmd.allowed_signers_path.display() ); } @@ -164,20 +129,21 @@ fn handle_install_hooks( println!("\nGenerating initial allowed_signers file..."); let storage = RegistryAttestationStorage::new(&auths_repo); - match generate_allowed_signers(&storage) { - Ok(entries) => { - let output = format_allowed_signers_file(&entries); - fs::write(&cmd.allowed_signers_path, &output) - .with_context(|| format!("Failed to write {:?}", cmd.allowed_signers_path))?; - println!( - "Wrote {} entries to {:?}", - entries.len(), - cmd.allowed_signers_path - ); + let mut signers = AllowedSigners::new(&cmd.allowed_signers_path); + match signers.sync(&storage) { + Ok(report) => { + if let Err(e) = signers.save() { + eprintln!("Warning: Could not write allowed_signers: {}", e); + } else { + println!( + "Wrote {} entries to {:?}", + report.added, cmd.allowed_signers_path + ); + } } Err(e) => { eprintln!("Warning: Could not generate initial allowed_signers: {}", e); - eprintln!("You may need to run 'auths git allowed-signers' manually."); + eprintln!("You may need to run 'auths signers sync' manually."); } } @@ -200,7 +166,6 @@ fn find_git_dir(repo_path: &Path) -> Result { let content = fs::read_to_string(&git_dir) .with_context(|| format!("Failed to read {:?}", git_dir))?; - // Format: "gitdir: " if let Some(path) = content.strip_prefix("gitdir: ") { let linked_path = PathBuf::from(path.trim()); if linked_path.is_absolute() { @@ -229,53 +194,14 @@ fn generate_post_merge_hook(auths_repo: &Path, allowed_signers_path: &Path) -> S # Regenerates allowed_signers file after merge/pull # Run auths to regenerate allowed_signers -auths git allowed-signers --repo "{}" --output "{}" +auths signers sync --repo "{}" --output "{}" "#, auths_repo.display(), allowed_signers_path.display() ) } -fn handle_allowed_signers( - cmd: AllowedSignersCommand, - repo_override: Option, - _attestation_prefix_override: Option, - _attestation_blob_name_override: Option, -) -> Result<()> { - let repo_path = if let Some(override_path) = repo_override { - expand_tilde(&override_path)? - } else { - expand_tilde(&cmd.repo)? - }; - - // Note: Layout config overrides are deprecated with registry backend. - // The registry uses a fixed path structure under refs/auths/registry. - - let storage = RegistryAttestationStorage::new(&repo_path); - let entries = generate_allowed_signers(&storage) - .context("Failed to load attestations from repository")?; - - let output = format_allowed_signers_file(&entries); - - if let Some(output_path) = cmd.output_file { - if let Some(parent) = output_path.parent() - && !parent.as_os_str().is_empty() - && !parent.exists() - { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory {:?}", parent))?; - } - fs::write(&output_path, &output) - .with_context(|| format!("Failed to write to {:?}", output_path))?; - eprintln!("Wrote {} entries to {:?}", entries.len(), output_path); - } else { - print!("{}", output); - } - - Ok(()) -} - -fn expand_tilde(path: &Path) -> Result { +pub(crate) fn expand_tilde(path: &Path) -> Result { let path_str = path.to_string_lossy(); if path_str.starts_with("~/") || path_str == "~" { let home = dirs::home_dir().context("Failed to determine home directory")?; @@ -294,61 +220,15 @@ use crate::config::CliConfig; impl ExecutableCommand for GitCommand { fn execute(&self, ctx: &CliConfig) -> Result<()> { - handle_git( - self.clone(), - ctx.repo_path.clone(), - self.overrides.attestation_prefix.clone(), - self.overrides.attestation_blob.clone(), - ) + handle_git(self.clone(), ctx.repo_path.clone()) } } #[cfg(test)] mod tests { use super::*; - use auths_sdk::workflows::git_integration::public_key_to_ssh; use tempfile::TempDir; - #[test] - fn test_allowed_signers_output_flag_parses() { - let cmd = AllowedSignersCommand::try_parse_from([ - "allowed-signers", - "--output", - "/tmp/allowed_signers", - ]) - .expect("--output flag must parse without panic"); - assert_eq!(cmd.output_file, Some(PathBuf::from("/tmp/allowed_signers"))); - } - - #[test] - fn test_allowed_signers_no_output_defaults_to_none() { - let cmd = AllowedSignersCommand::try_parse_from(["allowed-signers"]) - .expect("allowed-signers with no args must parse"); - assert!(cmd.output_file.is_none()); - } - - #[test] - fn test_public_key_to_ssh() { - let pk_bytes: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let result = public_key_to_ssh(&pk_bytes); - assert!(result.is_ok(), "Failed: {:?}", result.err()); - - let ssh_key = result.unwrap(); - assert!(ssh_key.starts_with("ssh-ed25519 "), "Got: {}", ssh_key); - } - - #[test] - fn test_public_key_to_ssh_invalid_length() { - let pk_bytes = vec![0u8; 16]; - let result = public_key_to_ssh(&pk_bytes); - assert!(result.is_err()); - } - #[test] fn test_expand_tilde() { let path = PathBuf::from("~/.auths"); @@ -380,15 +260,6 @@ mod tests { assert_eq!(result, PathBuf::from("relative/path")); } - #[test] - fn test_allowed_signers_default_repo_contains_tilde() { - // Verify the clap default is ~/ and that expand_tilde handles it - let cmd = AllowedSignersCommand::try_parse_from(["allowed-signers"]).unwrap(); - assert_eq!(cmd.repo, PathBuf::from("~/.auths")); - let expanded = expand_tilde(&cmd.repo).unwrap(); - assert!(!expanded.to_string_lossy().contains("~")); - } - #[test] fn test_find_git_dir() { let temp = TempDir::new().unwrap(); @@ -415,7 +286,7 @@ mod tests { let hook = generate_post_merge_hook(&auths_repo, &allowed_signers); assert!(hook.starts_with("#!/bin/bash")); - assert!(hook.contains("auths git allowed-signers")); + assert!(hook.contains("auths signers sync")); assert!(hook.contains("/home/user/.auths")); assert!(hook.contains(".auths/allowed_signers")); } diff --git a/crates/auths-cli/src/commands/id/migrate.rs b/crates/auths-cli/src/commands/id/migrate.rs index 86255b15..17a27e96 100644 --- a/crates/auths-cli/src/commands/id/migrate.rs +++ b/crates/auths-cli/src/commands/id/migrate.rs @@ -881,7 +881,7 @@ fn perform_ssh_migration(key: &SshKeyInfo, cmd: &FromSshCommand, out: &Output) - out.println(" 1. Start using Auths for new commits:"); out.println(" auths agent start"); out.println(" 2. Existing SSH-signed commits remain verifiable"); - out.println(" 3. Run 'auths git allowed-signers' to update Git config"); + out.println(" 3. Run 'auths signers sync' to update allowed signers"); Ok(()) } diff --git a/crates/auths-cli/src/commands/init_helpers.rs b/crates/auths-cli/src/commands/init_helpers.rs index cb6ceb07..decdc6e9 100644 --- a/crates/auths-cli/src/commands/init_helpers.rs +++ b/crates/auths-cli/src/commands/init_helpers.rs @@ -4,9 +4,7 @@ use dialoguer::MultiSelect; use std::path::{Path, PathBuf}; use std::process::Command; -use auths_sdk::workflows::git_integration::{ - format_allowed_signers_file, generate_allowed_signers, -}; +use auths_sdk::workflows::allowed_signers::AllowedSigners; use auths_storage::git::RegistryAttestationStorage; use crate::ux::format::Output; @@ -101,14 +99,20 @@ pub(crate) fn write_allowed_signers(key_alias: &str, out: &Output) -> Result<()> let repo_path = get_auths_repo_path()?; let storage = RegistryAttestationStorage::new(&repo_path); - let entries = generate_allowed_signers(&storage).unwrap_or_default(); - let content = format_allowed_signers_file(&entries); let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; let ssh_dir = home.join(".ssh"); std::fs::create_dir_all(&ssh_dir)?; let signers_path = ssh_dir.join("allowed_signers"); - std::fs::write(&signers_path, content)?; + + let mut signers = + AllowedSigners::load(&signers_path).unwrap_or_else(|_| AllowedSigners::new(&signers_path)); + let report = signers + .sync(&storage) + .map_err(|e| anyhow!("Failed to sync allowed signers: {}", e))?; + signers + .save() + .map_err(|e| anyhow!("Failed to write allowed signers: {}", e))?; let signers_str = signers_path .to_str() @@ -117,7 +121,7 @@ pub(crate) fn write_allowed_signers(key_alias: &str, out: &Output) -> Result<()> out.println(&format!( " Wrote {} allowed signer(s) to {}", - entries.len(), + report.added, signers_path.display() )); out.println(&format!( diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index d5a4fb4b..b7ed4b28 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -25,6 +25,7 @@ pub mod policy; pub mod provision; pub mod scim; pub mod sign; +pub mod signers; pub mod status; pub mod trust; pub mod unified_verify; diff --git a/crates/auths-cli/src/commands/signers.rs b/crates/auths-cli/src/commands/signers.rs new file mode 100644 index 00000000..c1d91f02 --- /dev/null +++ b/crates/auths-cli/src/commands/signers.rs @@ -0,0 +1,329 @@ +//! Signer management commands for Auths. + +use anyhow::{Context, Result}; +use auths_sdk::workflows::allowed_signers::{ + AllowedSigners, AllowedSignersError, EmailAddress, SignerPrincipal, SignerSource, +}; +use auths_storage::git::RegistryAttestationStorage; +use auths_verifier::core::Ed25519PublicKey; +use clap::{Parser, Subcommand}; +use ssh_key::PublicKey as SshPublicKey; +use std::path::PathBuf; + +use super::git::expand_tilde; + +#[derive(Parser, Debug, Clone)] +#[command(about = "Manage allowed signers for Git commit verification.")] +pub struct SignersCommand { + #[command(subcommand)] + pub command: SignersSubcommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum SignersSubcommand { + /// List all entries in the allowed_signers file. + List(SignersListArgs), + + /// Add a manual signer entry. + Add(SignersAddArgs), + + /// Remove a manual signer entry. + Remove(SignersRemoveArgs), + + /// Sync attestation entries from the auths registry. + Sync(SignersSyncArgs), + + /// Add a signer from a GitHub user's SSH keys. + #[command(name = "add-from-github")] + AddFromGithub(SignersAddFromGithubArgs), +} + +#[derive(Parser, Debug, Clone)] +pub struct SignersListArgs { + /// Output as JSON. + #[arg(long)] + pub json: bool, +} + +#[derive(Parser, Debug, Clone)] +pub struct SignersAddArgs { + /// Email address of the signer. + pub email: String, + + /// SSH public key (ssh-ed25519 AAAA...). + pub pubkey: String, +} + +#[derive(Parser, Debug, Clone)] +pub struct SignersRemoveArgs { + /// Email address of the signer to remove. + pub email: String, +} + +#[derive(Parser, Debug, Clone)] +pub struct SignersSyncArgs { + /// Path to the Auths identity repository. + #[arg(long, default_value = "~/.auths")] + pub repo: PathBuf, + + /// Output file path. Overrides the default location. + #[arg(long = "output", short = 'o')] + pub output_file: Option, +} + +#[derive(Parser, Debug, Clone)] +pub struct SignersAddFromGithubArgs { + /// GitHub username whose SSH keys to add. + pub username: String, +} + +fn resolve_signers_path() -> Result { + let output = std::process::Command::new("git") + .args(["config", "--get", "gpg.ssh.allowedSignersFile"]) + .output(); + + if let Ok(out) = output + && out.status.success() + { + let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !path_str.is_empty() { + let path = PathBuf::from(&path_str); + return expand_tilde(&path); + } + } + + let home = dirs::home_dir().context("Could not determine home directory")?; + Ok(home.join(".ssh").join("allowed_signers")) +} + +fn handle_list(args: &SignersListArgs) -> Result<()> { + let path = resolve_signers_path()?; + let signers = AllowedSigners::load(&path) + .with_context(|| format!("Failed to load {}", path.display()))?; + + if args.json { + let json = + serde_json::to_string_pretty(signers.list()).context("Failed to serialize entries")?; + println!("{}", json); + return Ok(()); + } + + let entries = signers.list(); + if entries.is_empty() { + println!("No entries in {}", path.display()); + return Ok(()); + } + + println!("{:<40} {:<12} KEY FINGERPRINT", "PRINCIPAL", "SOURCE"); + for entry in entries { + let source = match entry.source { + SignerSource::Attestation => "attestation", + SignerSource::Manual => "manual", + }; + let fingerprint = hex::encode(&entry.public_key.as_bytes()[..8]); + println!( + "{:<40} {:<12} {}...", + entry.principal.to_string(), + source, + fingerprint + ); + } + + Ok(()) +} + +fn handle_add(args: &SignersAddArgs) -> Result<()> { + let path = resolve_signers_path()?; + let mut signers = AllowedSigners::load(&path) + .with_context(|| format!("Failed to load {}", path.display()))?; + + let principal = SignerPrincipal::Email( + EmailAddress::new(&args.email).map_err(|e| anyhow::anyhow!("{}", e))?, + ); + + let pubkey = parse_ssh_pubkey(&args.pubkey)?; + + signers + .add(principal, pubkey, SignerSource::Manual) + .map_err(|e| anyhow::anyhow!("{}", e))?; + signers + .save() + .with_context(|| format!("Failed to write {}", path.display()))?; + + println!("Added {} to {}", args.email, path.display()); + Ok(()) +} + +fn handle_remove(args: &SignersRemoveArgs) -> Result<()> { + let path = resolve_signers_path()?; + let mut signers = AllowedSigners::load(&path) + .with_context(|| format!("Failed to load {}", path.display()))?; + + let principal = SignerPrincipal::Email( + EmailAddress::new(&args.email).map_err(|e| anyhow::anyhow!("{}", e))?, + ); + + match signers.remove(&principal) { + Ok(true) => { + signers + .save() + .with_context(|| format!("Failed to write {}", path.display()))?; + println!("Removed {} from {}", args.email, path.display()); + } + Ok(false) => { + println!("Entry not found: {}", args.email); + } + Err(AllowedSignersError::AttestationEntryProtected(p)) => { + eprintln!( + "Cannot remove '{}': attestation entries are managed by `auths signers sync`.", + p + ); + std::process::exit(1); + } + Err(e) => return Err(anyhow::anyhow!("{}", e)), + } + + Ok(()) +} + +fn handle_sync(args: &SignersSyncArgs) -> Result<()> { + let repo_path = expand_tilde(&args.repo)?; + let storage = RegistryAttestationStorage::new(&repo_path); + + let path = if let Some(ref output) = args.output_file { + expand_tilde(output)? + } else { + resolve_signers_path()? + }; + + let mut signers = AllowedSigners::load(&path) + .with_context(|| format!("Failed to load {}", path.display()))?; + + let report = signers + .sync(&storage) + .context("Failed to sync attestations")?; + + signers + .save() + .with_context(|| format!("Failed to write {}", path.display()))?; + + println!( + "Synced: {} added, {} removed, {} manual preserved", + report.added, report.removed, report.preserved + ); + println!("Wrote to {}", path.display()); + + Ok(()) +} + +fn handle_add_from_github(args: &SignersAddFromGithubArgs) -> Result<()> { + let url = format!("https://github.com/{}.keys", args.username); + let response = reqwest::blocking::get(&url) + .with_context(|| format!("Failed to fetch keys from {}", url))?; + + if !response.status().is_success() { + anyhow::bail!( + "GitHub returned {} for user '{}'. Check the username.", + response.status(), + args.username + ); + } + + let body = response.text().context("Failed to read response body")?; + let ed25519_keys: Vec<&str> = body + .lines() + .filter(|line| line.starts_with("ssh-ed25519 ")) + .collect(); + + if ed25519_keys.is_empty() { + println!( + "No ssh-ed25519 keys found for GitHub user '{}'. Only Ed25519 keys are supported.", + args.username + ); + return Ok(()); + } + + let path = resolve_signers_path()?; + let mut signers = AllowedSigners::load(&path) + .with_context(|| format!("Failed to load {}", path.display()))?; + + let email = format!("{}@github.com", args.username); + let principal = + SignerPrincipal::Email(EmailAddress::new(&email).map_err(|e| anyhow::anyhow!("{}", e))?); + + let mut added = 0; + for key_str in &ed25519_keys { + let pubkey = match parse_ssh_pubkey(key_str) { + Ok(k) => k, + Err(e) => { + eprintln!("Skipping invalid key: {}", e); + continue; + } + }; + + // For multiple keys, append index to email to avoid duplicates + let p = if ed25519_keys.len() > 1 && added > 0 { + let indexed_email = format!("{}+{}@github.com", args.username, added); + SignerPrincipal::Email( + EmailAddress::new(&indexed_email).map_err(|e| anyhow::anyhow!("{}", e))?, + ) + } else { + principal.clone() + }; + + match signers.add(p, pubkey, SignerSource::Manual) { + Ok(()) => added += 1, + Err(AllowedSignersError::DuplicatePrincipal(p)) => { + eprintln!("Skipping duplicate: {}", p); + } + Err(e) => return Err(anyhow::anyhow!("{}", e)), + } + } + + if added > 0 { + signers + .save() + .with_context(|| format!("Failed to write {}", path.display()))?; + println!( + "Added {} key(s) for {} to {}", + added, + args.username, + path.display() + ); + } else { + println!("No new keys added."); + } + + Ok(()) +} + +fn parse_ssh_pubkey(key_str: &str) -> Result { + let openssh_str = if key_str.starts_with("ssh-ed25519 ") { + key_str.to_string() + } else { + format!("ssh-ed25519 {}", key_str) + }; + + let ssh_pk = SshPublicKey::from_openssh(&openssh_str) + .map_err(|e| anyhow::anyhow!("Invalid SSH key: {}", e))?; + + match ssh_pk.key_data() { + ssh_key::public::KeyData::Ed25519(ed) => Ok(Ed25519PublicKey::from_bytes(ed.0)), + _ => anyhow::bail!("Only ssh-ed25519 keys are supported"), + } +} + +use crate::commands::executable::ExecutableCommand; +use crate::config::CliConfig; + +impl ExecutableCommand for SignersCommand { + fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.command { + SignersSubcommand::List(args) => handle_list(args), + SignersSubcommand::Add(args) => handle_add(args), + SignersSubcommand::Remove(args) => handle_remove(args), + SignersSubcommand::Sync(args) => handle_sync(args), + SignersSubcommand::AddFromGithub(args) => handle_add_from_github(args), + } + } +} diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index f8915e89..21f449cc 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -66,6 +66,7 @@ fn run() -> Result<()> { RootCommand::Whoami(cmd) => cmd.execute(&ctx), RootCommand::Tutorial(cmd) => cmd.execute(&ctx), RootCommand::Doctor(cmd) => cmd.execute(&ctx), + RootCommand::Signers(cmd) => cmd.execute(&ctx), RootCommand::Pair(cmd) => cmd.execute(&ctx), RootCommand::Completions(cmd) => cmd.execute(&ctx), RootCommand::Emergency(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index c4e2493d..9ed9102c 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -27,6 +27,7 @@ chrono = "0.4" hex = "0.4" html-escape = "0.2" ssh-key = "0.6" +tempfile = "3" zeroize = "1.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"], optional = true } auths-pairing-daemon = { workspace = true, optional = true } diff --git a/crates/auths-sdk/src/workflows/allowed_signers.rs b/crates/auths-sdk/src/workflows/allowed_signers.rs new file mode 100644 index 00000000..5e150079 --- /dev/null +++ b/crates/auths-sdk/src/workflows/allowed_signers.rs @@ -0,0 +1,642 @@ +//! AllowedSigners management — structured SSH allowed_signers file operations. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use auths_core::error::AuthsErrorInfo; +use auths_id::error::StorageError; +use auths_id::storage::attestation::AttestationSource; +use auths_verifier::core::Ed25519PublicKey; +use auths_verifier::types::DeviceDID; +use serde::{Deserialize, Serialize}; +use ssh_key::PublicKey as SshPublicKey; +use thiserror::Error; + +use super::git_integration::public_key_to_ssh; + +// ── Section markers ──────────────────────────────────────────────── + +const MANAGED_HEADER: &str = "# auths:managed — do not edit manually"; +const ATTESTATION_MARKER: &str = "# auths:attestation"; +const MANUAL_MARKER: &str = "# auths:manual"; + +// ── Types ────────────────────────────────────────────────────────── + +/// A single entry in an AllowedSigners file. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SignerEntry { + /// The principal (email or DID) that identifies this signer. + pub principal: SignerPrincipal, + /// The Ed25519 public key for this signer. + pub public_key: Ed25519PublicKey, + /// Whether this entry is attestation-managed or user-added. + pub source: SignerSource, +} + +/// The principal (identity) associated with a signer entry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SignerPrincipal { + /// A device DID-derived principal (from attestation without email payload). + DeviceDid(DeviceDID), + /// An email address principal (from manual entry or attestation with email). + Email(EmailAddress), +} + +impl fmt::Display for SignerPrincipal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DeviceDid(did) => { + let did_str = did.as_str(); + let local_part = did_str.strip_prefix("did:key:").unwrap_or(did_str); + write!(f, "{}@auths.local", local_part) + } + Self::Email(addr) => write!(f, "{}", addr), + } + } +} + +/// Whether a signer entry is auto-managed (attestation) or user-added (manual). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SignerSource { + /// Managed by `sync()`, regenerated from attestation storage. + Attestation, + /// User-added, preserved across `sync()` operations. + Manual, +} + +/// Validated email address with basic sanity checking. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct EmailAddress(String); + +impl EmailAddress { + /// Creates a validated email address. + /// + /// Args: + /// * `email`: The email string to validate. + /// + /// Usage: + /// ```ignore + /// let addr = EmailAddress::new("user@example.com")?; + /// ``` + pub fn new(email: &str) -> Result { + if email.len() > 254 { + return Err(AllowedSignersError::InvalidEmail( + "exceeds 254 characters".to_string(), + )); + } + if email.contains('\0') || email.contains('\n') || email.contains('\r') { + return Err(AllowedSignersError::InvalidEmail( + "contains null byte or newline".to_string(), + )); + } + if email.chars().any(|c| c.is_whitespace()) { + return Err(AllowedSignersError::InvalidEmail( + "contains whitespace".to_string(), + )); + } + let parts: Vec<&str> = email.splitn(2, '@').collect(); + if parts.len() != 2 { + return Err(AllowedSignersError::InvalidEmail( + "missing @ symbol".to_string(), + )); + } + let (local, domain) = (parts[0], parts[1]); + if local.is_empty() { + return Err(AllowedSignersError::InvalidEmail( + "empty local part".to_string(), + )); + } + if domain.is_empty() { + return Err(AllowedSignersError::InvalidEmail( + "empty domain part".to_string(), + )); + } + if !domain.contains('.') { + return Err(AllowedSignersError::InvalidEmail( + "domain must contain a dot".to_string(), + )); + } + Ok(Self(email.to_string())) + } + + /// Returns the email as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for EmailAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for EmailAddress { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl TryFrom for EmailAddress { + type Error = AllowedSignersError; + fn try_from(s: String) -> Result { + Self::new(&s) + } +} + +/// Report returned by `AllowedSigners::sync()`. +#[derive(Debug, Clone, Serialize)] +pub struct SyncReport { + /// Number of attestation entries added in this sync. + pub added: usize, + /// Number of stale attestation entries removed. + pub removed: usize, + /// Number of manual entries preserved untouched. + pub preserved: usize, +} + +// ── Errors ───────────────────────────────────────────────────────── + +/// Errors from allowed_signers file operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AllowedSignersError { + /// Email address validation failed. + #[error("invalid email address: {0}")] + InvalidEmail(String), + + /// SSH key parsing or encoding failed. + #[error("invalid SSH key: {0}")] + InvalidKey(String), + + /// Could not read the allowed_signers file. + #[error("failed to read {path}: {source}")] + FileRead { + /// Path to the file that could not be read. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// Could not write the allowed_signers file. + #[error("failed to write {path}: {source}")] + FileWrite { + /// Path to the file that could not be written. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// A line in the file could not be parsed. + #[error("line {line}: {detail}")] + ParseError { + /// 1-based line number of the malformed entry. + line: usize, + /// Description of the parse error. + detail: String, + }, + + /// An entry with this principal already exists. + #[error("principal already exists: {0}")] + DuplicatePrincipal(String), + + /// Attempted to remove an attestation-managed entry. + #[error("cannot remove attestation-managed entry: {0}")] + AttestationEntryProtected(String), + + /// Attestation storage operation failed. + #[error("attestation storage error: {0}")] + Storage(#[from] StorageError), +} + +impl AuthsErrorInfo for AllowedSignersError { + fn error_code(&self) -> &'static str { + match self { + Self::InvalidEmail(_) => "AUTHS_INVALID_EMAIL", + Self::InvalidKey(_) => "AUTHS_INVALID_SSH_KEY", + Self::FileRead { .. } => "AUTHS_SIGNERS_FILE_READ", + Self::FileWrite { .. } => "AUTHS_SIGNERS_FILE_WRITE", + Self::ParseError { .. } => "AUTHS_SIGNERS_PARSE_ERROR", + Self::DuplicatePrincipal(_) => "AUTHS_DUPLICATE_PRINCIPAL", + Self::AttestationEntryProtected(_) => "AUTHS_ATTESTATION_ENTRY_PROTECTED", + Self::Storage(_) => "AUTHS_SIGNERS_STORAGE_ERROR", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::InvalidEmail(_) => Some("Email must be in user@domain.tld format"), + Self::InvalidKey(_) => { + Some("Key must be a valid ssh-ed25519 public key (ssh-ed25519 AAAA...)") + } + Self::FileRead { .. } => Some("Check file exists and has correct permissions"), + Self::FileWrite { .. } => Some("Check directory exists and has write permissions"), + Self::ParseError { .. } => Some( + "Check the allowed_signers file format: namespaces=\"git\" ssh-ed25519 ", + ), + Self::DuplicatePrincipal(_) => { + Some("Remove the existing entry first with `auths signers remove`") + } + Self::AttestationEntryProtected(_) => Some( + "Attestation entries are managed by `auths signers sync` — revoke the attestation instead", + ), + Self::Storage(_) => Some("Check the auths repository at ~/.auths"), + } + } +} + +// ── AllowedSigners struct ────────────────────────────────────────── + +/// Manages an SSH allowed_signers file with attestation and manual sections. +pub struct AllowedSigners { + entries: Vec, + file_path: PathBuf, +} + +impl AllowedSigners { + /// Creates an empty AllowedSigners bound to a file path. + pub fn new(file_path: impl Into) -> Self { + Self { + entries: Vec::new(), + file_path: file_path.into(), + } + } + + /// Loads and parses an allowed_signers file. + /// + /// If the file doesn't exist, returns an empty instance. + /// Files without section markers are treated as all-manual entries. + /// + /// Args: + /// * `path`: Path to the allowed_signers file. + /// + /// Usage: + /// ```ignore + /// let signers = AllowedSigners::load("~/.ssh/allowed_signers")?; + /// ``` + pub fn load(path: impl Into) -> Result { + let path = path.into(); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::new(path)); + } + Err(e) => { + return Err(AllowedSignersError::FileRead { path, source: e }); + } + }; + let mut signers = Self::new(path); + signers.parse_content(&content)?; + Ok(signers) + } + + /// Atomically writes the allowed_signers file with section markers. + /// + /// Usage: + /// ```ignore + /// signers.save()?; + /// ``` + pub fn save(&self) -> Result<(), AllowedSignersError> { + let content = self.format_content(); + if let Some(parent) = self.file_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AllowedSignersError::FileWrite { + path: self.file_path.clone(), + source: e, + })?; + } + + use std::io::Write; + let dir = self.file_path.parent().unwrap_or_else(|| Path::new(".")); + let tmp = + tempfile::NamedTempFile::new_in(dir).map_err(|e| AllowedSignersError::FileWrite { + path: self.file_path.clone(), + source: e, + })?; + (&tmp) + .write_all(content.as_bytes()) + .map_err(|e| AllowedSignersError::FileWrite { + path: self.file_path.clone(), + source: e, + })?; + tmp.persist(&self.file_path) + .map_err(|e| AllowedSignersError::FileWrite { + path: self.file_path.clone(), + source: e.error, + })?; + Ok(()) + } + + /// Returns all signer entries. + pub fn list(&self) -> &[SignerEntry] { + &self.entries + } + + /// Returns the file path this instance is bound to. + pub fn file_path(&self) -> &Path { + &self.file_path + } + + /// Adds a new signer entry. Rejects duplicates by principal. + pub fn add( + &mut self, + principal: SignerPrincipal, + pubkey: Ed25519PublicKey, + source: SignerSource, + ) -> Result<(), AllowedSignersError> { + let principal_str = principal.to_string(); + if self.entries.iter().any(|e| e.principal == principal) { + return Err(AllowedSignersError::DuplicatePrincipal(principal_str)); + } + self.entries.push(SignerEntry { + principal, + public_key: pubkey, + source, + }); + Ok(()) + } + + /// Removes a manual entry by principal. Returns true if an entry was removed. + pub fn remove(&mut self, principal: &SignerPrincipal) -> Result { + if let Some(entry) = self.entries.iter().find(|e| &e.principal == principal) + && entry.source == SignerSource::Attestation + { + return Err(AllowedSignersError::AttestationEntryProtected( + principal.to_string(), + )); + } + let before = self.entries.len(); + self.entries.retain(|e| &e.principal != principal); + Ok(self.entries.len() < before) + } + + /// Regenerates attestation entries from storage, preserving manual entries. + pub fn sync( + &mut self, + storage: &dyn AttestationSource, + ) -> Result { + let manual_count = self + .entries + .iter() + .filter(|e| e.source == SignerSource::Manual) + .count(); + + let old_attestation_count = self + .entries + .iter() + .filter(|e| e.source == SignerSource::Attestation) + .count(); + + self.entries.retain(|e| e.source == SignerSource::Manual); + + let attestations = storage.load_all_attestations()?; + let mut new_entries: Vec = attestations + .iter() + .filter(|att| !att.is_revoked()) + .map(|att| { + let principal = principal_from_attestation(att); + SignerEntry { + principal, + public_key: att.device_public_key, + source: SignerSource::Attestation, + } + }) + .collect(); + + new_entries.sort_by(|a, b| a.principal.to_string().cmp(&b.principal.to_string())); + new_entries.dedup_by(|a, b| a.principal == b.principal); + + let added = new_entries.len(); + for (i, entry) in new_entries.into_iter().enumerate() { + self.entries.insert(i, entry); + } + + Ok(SyncReport { + added, + removed: old_attestation_count, + preserved: manual_count, + }) + } + + // ── Private helpers ──────────────────────────────────────────── + + fn parse_content(&mut self, content: &str) -> Result<(), AllowedSignersError> { + let has_markers = content.contains(ATTESTATION_MARKER) || content.contains(MANUAL_MARKER); + let mut current_source = if has_markers { + None + } else { + Some(SignerSource::Manual) + }; + + for (line_num, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed == ATTESTATION_MARKER || trimmed.starts_with(ATTESTATION_MARKER) { + current_source = Some(SignerSource::Attestation); + continue; + } + if trimmed == MANUAL_MARKER || trimmed.starts_with(MANUAL_MARKER) { + current_source = Some(SignerSource::Manual); + continue; + } + + if trimmed.starts_with('#') { + continue; + } + + let source = match current_source { + Some(s) => s, + None => continue, + }; + + let entry = parse_entry_line(trimmed, line_num + 1, source)?; + self.entries.push(entry); + } + Ok(()) + } + + fn format_content(&self) -> String { + let mut out = String::new(); + out.push_str(MANAGED_HEADER); + out.push('\n'); + + out.push_str(ATTESTATION_MARKER); + out.push('\n'); + for entry in &self.entries { + if entry.source == SignerSource::Attestation { + out.push_str(&format_entry(entry)); + out.push('\n'); + } + } + + out.push_str(MANUAL_MARKER); + out.push('\n'); + for entry in &self.entries { + if entry.source == SignerSource::Manual { + out.push_str(&format_entry(entry)); + out.push('\n'); + } + } + + out + } +} + +// ── Free functions ───────────────────────────────────────────────── + +fn principal_from_attestation(att: &auths_verifier::core::Attestation) -> SignerPrincipal { + if let Some(ref payload) = att.payload + && let Some(email) = payload.get("email").and_then(|v| v.as_str()) + && !email.is_empty() + && let Ok(addr) = EmailAddress::new(email) + { + return SignerPrincipal::Email(addr); + } + SignerPrincipal::DeviceDid(att.subject.clone()) +} + +fn parse_entry_line( + line: &str, + line_num: usize, + source: SignerSource, +) -> Result { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + return Err(AllowedSignersError::ParseError { + line: line_num, + detail: "expected at least: ".to_string(), + }); + } + + let principal_str = parts[0]; + + let key_type_idx = parts + .iter() + .position(|&p| p == "ssh-ed25519") + .ok_or_else(|| AllowedSignersError::ParseError { + line: line_num, + detail: "only ssh-ed25519 keys are supported".to_string(), + })?; + + if key_type_idx + 1 >= parts.len() { + return Err(AllowedSignersError::ParseError { + line: line_num, + detail: "missing base64 key data after ssh-ed25519".to_string(), + }); + } + + let key_data = parts[key_type_idx + 1]; + let openssh_str = format!("ssh-ed25519 {}", key_data); + + let ssh_pk = + SshPublicKey::from_openssh(&openssh_str).map_err(|e| AllowedSignersError::ParseError { + line: line_num, + detail: format!("invalid SSH key: {}", e), + })?; + + let raw_bytes = match ssh_pk.key_data() { + ssh_key::public::KeyData::Ed25519(ed) => ed.0, + _ => { + return Err(AllowedSignersError::ParseError { + line: line_num, + detail: "expected Ed25519 key".to_string(), + }); + } + }; + + let public_key = Ed25519PublicKey::from_bytes(raw_bytes); + let principal = parse_principal(principal_str); + + Ok(SignerEntry { + principal, + public_key, + source, + }) +} + +fn parse_principal(s: &str) -> SignerPrincipal { + if let Some(local) = s.strip_suffix("@auths.local") { + let did_str = format!("did:key:{}", local); + return SignerPrincipal::DeviceDid(DeviceDID::new(did_str)); + } + if s.starts_with("did:key:") { + return SignerPrincipal::DeviceDid(DeviceDID::new(s)); + } + match EmailAddress::new(s) { + Ok(addr) => SignerPrincipal::Email(addr), + Err(_) => SignerPrincipal::DeviceDid(DeviceDID::new(s)), + } +} + +fn format_entry(entry: &SignerEntry) -> String { + #[allow(clippy::expect_used)] // INVARIANT: Ed25519PublicKey is always 32 valid bytes + let ssh_key = public_key_to_ssh(entry.public_key.as_bytes()) + .expect("Ed25519PublicKey always encodes to valid SSH key"); + format!("{} namespaces=\"git\" {}", entry.principal, ssh_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_valid() { + assert!(EmailAddress::new("user@example.com").is_ok()); + assert!(EmailAddress::new("a@b.co").is_ok()); + assert!(EmailAddress::new("test+tag@domain.org").is_ok()); + } + + #[test] + fn email_invalid() { + assert!(EmailAddress::new("").is_err()); + assert!(EmailAddress::new("@").is_err()); + assert!(EmailAddress::new("user@").is_err()); + assert!(EmailAddress::new("@domain.com").is_err()); + assert!(EmailAddress::new("user@domain").is_err()); + assert!(EmailAddress::new("invalid").is_err()); + } + + #[test] + fn email_injection_defense() { + assert!(EmailAddress::new("a\0b@evil.com").is_err()); + assert!(EmailAddress::new("a\n@evil.com").is_err()); + assert!(EmailAddress::new("a b@evil.com").is_err()); + } + + #[test] + fn principal_display_email() { + let p = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + assert_eq!(p.to_string(), "user@example.com"); + } + + #[test] + fn principal_display_did() { + let did = DeviceDID::new("did:key:z6MkTest123"); + let p = SignerPrincipal::DeviceDid(did); + assert_eq!(p.to_string(), "z6MkTest123@auths.local"); + } + + #[test] + fn principal_roundtrip() { + let email_p = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + let parsed = parse_principal(&email_p.to_string()); + assert_eq!(parsed, email_p); + + let did = DeviceDID::new("did:key:z6MkTest123"); + let did_p = SignerPrincipal::DeviceDid(did); + let parsed = parse_principal(&did_p.to_string()); + assert_eq!(parsed, did_p); + } + + #[test] + fn error_codes_and_suggestions() { + let err = AllowedSignersError::InvalidEmail("test".to_string()); + assert_eq!(err.error_code(), "AUTHS_INVALID_EMAIL"); + assert!(err.suggestion().is_some()); + } +} diff --git a/crates/auths-sdk/src/workflows/git_integration.rs b/crates/auths-sdk/src/workflows/git_integration.rs index 7efbcf0b..5e366006 100644 --- a/crates/auths-sdk/src/workflows/git_integration.rs +++ b/crates/auths-sdk/src/workflows/git_integration.rs @@ -1,27 +1,12 @@ -//! Git allowed-signers file computation from device attestations. +//! Git SSH key encoding utilities. -use auths_id::error::StorageError; -use auths_id::storage::attestation::AttestationSource; -use auths_verifier::core::Attestation; use ssh_key::PublicKey as SshPublicKey; use ssh_key::public::Ed25519PublicKey; use thiserror::Error; -/// A single entry in a Git allowed_signers file. -#[derive(Debug, Clone)] -pub struct AllowedSignerEntry { - /// Email or principal used by Git to identify the signer. - pub principal: String, - /// OpenSSH public key string (e.g. `"ssh-ed25519 AAAA..."`). - pub ssh_public_key: String, -} - -/// Errors that can occur during Git integration operations. +/// Errors from SSH key encoding operations. #[derive(Debug, Error)] pub enum GitIntegrationError { - /// Attestation storage could not be read. - #[error("failed to load attestations: {0}")] - Storage(#[from] StorageError), /// Raw public key bytes have an unexpected length. #[error("invalid Ed25519 public key length: expected 32, got {0}")] InvalidKeyLength(usize), @@ -30,64 +15,6 @@ pub enum GitIntegrationError { SshKeyEncoding(String), } -/// Compute the list of allowed-signer entries from an attestation source. -/// -/// Skips revoked attestations and devices whose public key cannot be -/// parsed; those are silently dropped. -/// -/// Args: -/// * `source`: Attestation storage backend. -/// -/// Usage: -/// ```ignore -/// let entries = generate_allowed_signers(&storage)?; -/// let file_content = format_allowed_signers_file(&entries); -/// ``` -pub fn generate_allowed_signers( - source: &dyn AttestationSource, -) -> Result, GitIntegrationError> { - let attestations = source.load_all_attestations()?; - let mut entries: Vec = attestations - .iter() - .filter(|att| !att.is_revoked()) - .filter_map(|att| { - let ssh_key = public_key_to_ssh(att.device_public_key.as_bytes()).ok()?; - Some(AllowedSignerEntry { - principal: principal_for(att), - ssh_public_key: ssh_key, - }) - }) - .collect(); - entries.sort_by(|a, b| a.principal.cmp(&b.principal)); - entries.dedup_by(|a, b| a.principal == b.principal && a.ssh_public_key == b.ssh_public_key); - Ok(entries) -} - -/// Format a list of `AllowedSignerEntry` values as an `allowed_signers` file. -/// -/// Each line has the form ` namespaces="git" `. -/// Returns an empty string when `entries` is empty, otherwise the file -/// ends with a trailing newline. -/// -/// Args: -/// * `entries`: Computed allowed-signer entries. -/// -/// Usage: -/// ```ignore -/// let content = format_allowed_signers_file(&entries); -/// std::fs::write(path, content)?; -/// ``` -pub fn format_allowed_signers_file(entries: &[AllowedSignerEntry]) -> String { - if entries.is_empty() { - return String::new(); - } - let lines: Vec = entries - .iter() - .map(|e| format!("{} namespaces=\"git\" {}", e.principal, e.ssh_public_key)) - .collect(); - format!("{}\n", lines.join("\n")) -} - /// Convert raw Ed25519 public key bytes to an OpenSSH public key string. /// /// Args: @@ -110,15 +37,3 @@ pub fn public_key_to_ssh(public_key_bytes: &[u8]) -> Result String { - if let Some(ref payload) = att.payload - && let Some(email) = payload.get("email").and_then(|v| v.as_str()) - && !email.is_empty() - { - return email.to_string(); - } - let did_str = att.subject.to_string(); - let local_part = did_str.strip_prefix("did:key:").unwrap_or(&did_str); - format!("{}@auths.local", local_part) -} diff --git a/crates/auths-sdk/src/workflows/mod.rs b/crates/auths-sdk/src/workflows/mod.rs index 8a1f79e2..5c2f5eb5 100644 --- a/crates/auths-sdk/src/workflows/mod.rs +++ b/crates/auths-sdk/src/workflows/mod.rs @@ -1,3 +1,4 @@ +pub mod allowed_signers; pub mod approval; pub mod artifact; pub mod audit; diff --git a/crates/auths-sdk/tests/cases/allowed_signers.rs b/crates/auths-sdk/tests/cases/allowed_signers.rs new file mode 100644 index 00000000..2c8782f9 --- /dev/null +++ b/crates/auths-sdk/tests/cases/allowed_signers.rs @@ -0,0 +1,173 @@ +use auths_sdk::workflows::allowed_signers::*; +use auths_verifier::core::Ed25519PublicKey; +use auths_verifier::types::DeviceDID; + +#[test] +fn email_validation_accepts_valid() { + assert!(EmailAddress::new("user@example.com").is_ok()); + assert!(EmailAddress::new("a@b.co").is_ok()); + assert!(EmailAddress::new("user+tag@domain.org").is_ok()); +} + +#[test] +fn email_validation_rejects_invalid() { + assert!(EmailAddress::new("").is_err()); + assert!(EmailAddress::new("@").is_err()); + assert!(EmailAddress::new("user@").is_err()); + assert!(EmailAddress::new("@domain.com").is_err()); + assert!(EmailAddress::new("user@domain").is_err()); + assert!(EmailAddress::new("nope").is_err()); +} + +#[test] +fn email_injection_defense() { + assert!(EmailAddress::new("a\0b@evil.com").is_err()); + assert!(EmailAddress::new("a\n@evil.com").is_err()); + assert!(EmailAddress::new("a\r@evil.com").is_err()); + assert!(EmailAddress::new("a b@evil.com").is_err()); +} + +#[test] +fn signer_principal_display_email() { + let p = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + assert_eq!(p.to_string(), "user@example.com"); +} + +#[test] +fn signer_principal_display_did() { + let did = DeviceDID::new("did:key:z6MkTest123"); + let p = SignerPrincipal::DeviceDid(did); + assert_eq!(p.to_string(), "z6MkTest123@auths.local"); +} + +#[test] +fn load_nonexistent_file_returns_empty() { + let signers = AllowedSigners::load("/tmp/auths-test-nonexistent-12345").unwrap(); + assert!(signers.list().is_empty()); +} + +#[test] +fn add_and_list() { + let mut signers = AllowedSigners::new("/tmp/test"); + let key = Ed25519PublicKey::from_bytes([1u8; 32]); + let principal = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + signers + .add(principal.clone(), key, SignerSource::Manual) + .unwrap(); + assert_eq!(signers.list().len(), 1); + assert_eq!(signers.list()[0].principal, principal); +} + +#[test] +fn add_duplicate_rejected() { + let mut signers = AllowedSigners::new("/tmp/test"); + let key = Ed25519PublicKey::from_bytes([1u8; 32]); + let principal = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + signers + .add(principal.clone(), key, SignerSource::Manual) + .unwrap(); + let result = signers.add(principal, key, SignerSource::Manual); + assert!(result.is_err()); +} + +#[test] +fn remove_manual_entry() { + let mut signers = AllowedSigners::new("/tmp/test"); + let key = Ed25519PublicKey::from_bytes([1u8; 32]); + let principal = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + signers + .add(principal.clone(), key, SignerSource::Manual) + .unwrap(); + assert!(signers.remove(&principal).unwrap()); + assert!(signers.list().is_empty()); +} + +#[test] +fn remove_nonexistent_returns_false() { + let mut signers = AllowedSigners::new("/tmp/test"); + let principal = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + assert!(!signers.remove(&principal).unwrap()); +} + +#[test] +fn remove_attestation_entry_rejected() { + let mut signers = AllowedSigners::new("/tmp/test"); + let key = Ed25519PublicKey::from_bytes([1u8; 32]); + let principal = SignerPrincipal::Email(EmailAddress::new("user@example.com").unwrap()); + signers + .add(principal.clone(), key, SignerSource::Attestation) + .unwrap(); + let result = signers.remove(&principal); + assert!(result.is_err()); +} + +#[test] +fn save_and_load_roundtrip() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("allowed_signers"); + + let mut signers = AllowedSigners::new(&path); + let key1 = Ed25519PublicKey::from_bytes([1u8; 32]); + let key2 = Ed25519PublicKey::from_bytes([2u8; 32]); + signers + .add( + SignerPrincipal::Email(EmailAddress::new("manual@example.com").unwrap()), + key1, + SignerSource::Manual, + ) + .unwrap(); + signers + .add( + SignerPrincipal::Email(EmailAddress::new("auto@example.com").unwrap()), + key2, + SignerSource::Attestation, + ) + .unwrap(); + signers.save().unwrap(); + + let loaded = AllowedSigners::load(&path).unwrap(); + assert_eq!(loaded.list().len(), 2); + + let manual = loaded + .list() + .iter() + .find(|e| e.source == SignerSource::Manual) + .unwrap(); + assert_eq!(manual.principal.to_string(), "manual@example.com"); + + let attestation = loaded + .list() + .iter() + .find(|e| e.source == SignerSource::Attestation) + .unwrap(); + assert_eq!(attestation.principal.to_string(), "auto@example.com"); +} + +#[test] +fn load_unmarked_file_treats_as_manual() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("allowed_signers"); + + // Write a file without section markers + let key = Ed25519PublicKey::from_bytes([1u8; 32]); + let ssh_key = auths_sdk::workflows::git_integration::public_key_to_ssh(key.as_bytes()).unwrap(); + let content = format!("user@example.com namespaces=\"git\" {}\n", ssh_key); + std::fs::write(&path, content).unwrap(); + + let loaded = AllowedSigners::load(&path).unwrap(); + assert_eq!(loaded.list().len(), 1); + assert_eq!(loaded.list()[0].source, SignerSource::Manual); +} + +#[test] +fn error_info_implemented() { + use auths_core::error::AuthsErrorInfo; + + let err = AllowedSignersError::InvalidEmail("test".to_string()); + assert!(!err.error_code().is_empty()); + assert!(err.suggestion().is_some()); + + let err = AllowedSignersError::DuplicatePrincipal("test".to_string()); + assert!(!err.error_code().is_empty()); + assert!(err.suggestion().is_some()); +} diff --git a/crates/auths-sdk/tests/cases/mod.rs b/crates/auths-sdk/tests/cases/mod.rs index d33beaad..efff1737 100644 --- a/crates/auths-sdk/tests/cases/mod.rs +++ b/crates/auths-sdk/tests/cases/mod.rs @@ -1,3 +1,4 @@ +mod allowed_signers; mod artifact; mod audit; mod ci_setup; diff --git a/crates/xtask/src/gen_docs.rs b/crates/xtask/src/gen_docs.rs index d3821757..37a3647c 100644 --- a/crates/xtask/src/gen_docs.rs +++ b/crates/xtask/src/gen_docs.rs @@ -136,8 +136,8 @@ const COMMANDS: &[Cmd] = &[ }, // ── git ───────────────────────────────────────────────────────────── Cmd { - args: &["git", "allowed-signers"], - marker: "auths git allowed-signers", + args: &["signers", "sync"], + marker: "auths signers sync", doc_file: "docs/cli/commands/advanced.md", }, Cmd { diff --git a/docs/cli/commands/advanced.md b/docs/cli/commands/advanced.md index 34aa6466..bb32ca1b 100644 --- a/docs/cli/commands/advanced.md +++ b/docs/cli/commands/advanced.md @@ -329,22 +329,22 @@ Generate an incident report ## Git -### auths git allowed-signers +### auths signers sync ```bash -auths git allowed-signers +auths signers sync ``` - -Generate allowed_signers file from Auths device authorizations + +Sync attestation entries from the auths registry | Flag | Default | Description | |------|---------|-------------| | `--repo ` | `~/.auths` | Path to the Auths identity repository | -| `-o, --output ` | — | Output file path. If not specified, outputs to stdout | +| `-o, --output ` | — | Output file path. Overrides the default location | | `--json` | — | Emit machine-readable JSON | | `-q, --quiet` | — | Suppress non-essential output | - + --- diff --git a/docs/getting-started/signing-commits.md b/docs/getting-started/signing-commits.md index fb8bd22a..22e3b307 100644 --- a/docs/getting-started/signing-commits.md +++ b/docs/getting-started/signing-commits.md @@ -81,7 +81,7 @@ To configure for the current repository only, replace `--global` with `--local`. The `allowed_signers` file maps identities to their authorized public keys. Auths generates this during `init`, but you can regenerate it at any time: ```bash -auths git allowed-signers --output ~/.ssh/allowed_signers +auths signers sync --output ~/.ssh/allowed_signers git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers ``` diff --git a/docs/guides/git/signing-configuration.md b/docs/guides/git/signing-configuration.md index 48e2577d..f37610f4 100644 --- a/docs/guides/git/signing-configuration.md +++ b/docs/guides/git/signing-configuration.md @@ -142,7 +142,7 @@ Git and `auths verify` both require an `allowed_signers` file that maps principa ### Generate It ```bash -auths git allowed-signers --output .auths/allowed_signers +auths signers sync --output .auths/allowed_signers ``` This scans your Auths identity repository for authorized devices and produces a file in the format Git expects: @@ -173,7 +173,7 @@ Install a post-merge hook that regenerates the `allowed_signers` file after each auths git install-hooks ``` -This creates a `.git/hooks/post-merge` hook that runs `auths git allowed-signers --output .auths/allowed_signers` automatically. +This creates a `.git/hooks/post-merge` hook that runs `auths signers sync --output .auths/allowed_signers` automatically. Options: diff --git a/docs/guides/git/team-workflows.md b/docs/guides/git/team-workflows.md index 582dc0f2..a01a2cf3 100644 --- a/docs/guides/git/team-workflows.md +++ b/docs/guides/git/team-workflows.md @@ -12,7 +12,7 @@ The recommended approach is to commit the `allowed_signers` file to your reposit ```bash # Generate from your Auths identity -auths git allowed-signers --output .auths/allowed_signers +auths signers sync --output .auths/allowed_signers # Configure Git to use it git config --local gpg.ssh.allowedSignersFile .auths/allowed_signers @@ -35,13 +35,13 @@ Each developer generates their own entry and contributes it to the shared file: ```bash # Teammate runs on their machine: -auths git allowed-signers +auths signers list ``` This outputs their entry to stdout. They copy it and open a PR to append it to `.auths/allowed_signers`. Alternatively, if you have access to the teammate's Auths identity repository, you can generate the full file from all known attestations: ```bash -auths git allowed-signers --repo /path/to/shared/auths-repo --output .auths/allowed_signers +auths signers sync --repo /path/to/shared/auths-repo --output .auths/allowed_signers ``` ### Auto-Regeneration @@ -55,7 +55,7 @@ auths git install-hooks This creates a `.git/hooks/post-merge` hook that runs: ```bash -auths git allowed-signers --repo ~/.auths --output .auths/allowed_signers +auths signers sync --repo ~/.auths --output .auths/allowed_signers ``` The hook ensures the `allowed_signers` file stays in sync with the latest device authorizations from your identity repository. Use `--force` to overwrite an existing hook. @@ -77,7 +77,7 @@ This creates their cryptographic identity, generates a key pair, stores it in th The new member exports their public key entry: ```bash -auths git allowed-signers +auths signers list ``` They share the output line (e.g., via a PR or secure channel). @@ -192,7 +192,7 @@ Revoked members' signatures remain valid for commits made before the revocation After revoking a member, regenerate the `allowed_signers` file to remove their key: ```bash -auths git allowed-signers --output .auths/allowed_signers +auths signers sync --output .auths/allowed_signers ``` ## Trust Management @@ -321,7 +321,7 @@ Output shows added, removed, and changed rules with risk scores (`LOW`, `MEDIUM` Team members who work across multiple machines can pair devices to sign with the same identity from any machine. -Each device generates its own key pair and receives a device attestation from the identity owner. The `allowed_signers` file includes entries for all authorized devices. When `auths git allowed-signers` is run, it scans all non-revoked attestations and generates entries for every authorized device key. +Each device generates its own key pair and receives a device attestation from the identity owner. The `allowed_signers` file includes entries for all authorized devices. When `auths signers list` is run, it scans all non-revoked attestations and generates entries for every authorized device key. ## Audit and Compliance @@ -358,7 +358,7 @@ auths init # Collect allowed_signers entries from all members # Commit the shared file to the repository -auths git allowed-signers --output .auths/allowed_signers +auths signers sync --output .auths/allowed_signers git config --local gpg.ssh.allowedSignersFile .auths/allowed_signers git add .auths/allowed_signers git commit -S -m "Initialize team signing" @@ -374,7 +374,7 @@ auths git install-hooks auths init # New member shares their entry: -auths git allowed-signers +auths signers list # (copy output line) # Maintainer appends to .auths/allowed_signers and commits @@ -397,7 +397,7 @@ steps: auths org revoke-member --org did:keri:E... --member did:keri:E... # Remove their entry from allowed_signers -auths git allowed-signers --output .auths/allowed_signers +auths signers sync --output .auths/allowed_signers # Commit the change git add .auths/allowed_signers diff --git a/docs/guides/git/verifying-commits.md b/docs/guides/git/verifying-commits.md index 7efa4e46..f9832098 100644 --- a/docs/guides/git/verifying-commits.md +++ b/docs/guides/git/verifying-commits.md @@ -278,7 +278,7 @@ The default path `.auths/allowed_signers` does not exist. Generate it: ```bash mkdir -p .auths -auths git allowed-signers --output .auths/allowed_signers +auths signers sync --output .auths/allowed_signers ``` ### "Signature from non-allowed signer" diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index ebc112c4..54d1a326 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -323,6 +323,7 @@ dependencies = [ "serde", "serde_json", "ssh-key", + "tempfile", "thiserror 2.0.18", "zeroize", ] diff --git a/packages/auths-python/src/audit.rs b/packages/auths-python/src/audit.rs index ed97a1b9..a8c7daee 100644 --- a/packages/auths-python/src/audit.rs +++ b/packages/auths-python/src/audit.rs @@ -23,9 +23,6 @@ pub fn generate_audit_report( ) -> PyResult { let target = resolve_repo(target_repo_path); let _auths = resolve_repo(auths_repo_path); - let since = since; - let until = until; - let author = author; py.allow_threads(move || { let provider = Git2LogProvider::open(&target) @@ -51,30 +48,28 @@ pub fn generate_audit_report( .commits .iter() .filter(|c| { - if let Some(ref a) = author { - if c.author_email != *a { - return false; - } + if let Some(ref a) = author + && c.author_email != *a + { + return false; } - if let Some(since_dt) = since_filter { - if let Ok(ct) = chrono::NaiveDateTime::parse_from_str( + if let Some(since_dt) = since_filter + && let Ok(ct) = chrono::NaiveDateTime::parse_from_str( &c.timestamp[..19], "%Y-%m-%dT%H:%M:%S", - ) { - if ct < since_dt { - return false; - } - } + ) + && ct < since_dt + { + return false; } - if let Some(until_dt) = until_filter { - if let Ok(ct) = chrono::NaiveDateTime::parse_from_str( + if let Some(until_dt) = until_filter + && let Ok(ct) = chrono::NaiveDateTime::parse_from_str( &c.timestamp[..19], "%Y-%m-%dT%H:%M:%S", - ) { - if ct > until_dt { - return false; - } - } + ) + && ct > until_dt + { + return false; } true }) diff --git a/packages/auths-python/src/git_integration.rs b/packages/auths-python/src/git_integration.rs index 7b1eef53..f8d5d57d 100644 --- a/packages/auths-python/src/git_integration.rs +++ b/packages/auths-python/src/git_integration.rs @@ -1,8 +1,7 @@ use std::path::PathBuf; -use auths_sdk::workflows::git_integration::{ - format_allowed_signers_file, generate_allowed_signers, -}; +use auths_sdk::workflows::allowed_signers::AllowedSigners; +use auths_sdk::workflows::git_integration::public_key_to_ssh; use auths_storage::git::RegistryAttestationStorage; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -28,11 +27,25 @@ pub fn generate_allowed_signers_file(py: Python<'_>, repo_path: &str) -> PyResul py.allow_threads(move || { let repo = PathBuf::from(shellexpand::tilde(&rp).as_ref()); let storage = RegistryAttestationStorage::new(&repo); - let entries = generate_allowed_signers(&storage).map_err( - |e: auths_sdk::workflows::git_integration::GitIntegrationError| { - PyRuntimeError::new_err(format!("[AUTHS_REGISTRY_ERROR] {e}")) - }, - )?; - Ok(format_allowed_signers_file(&entries)) + let mut signers = AllowedSigners::new("/dev/null"); + signers + .sync(&storage) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_REGISTRY_ERROR] {e}")))?; + let lines: Vec = signers + .list() + .iter() + .filter_map(|entry| { + let ssh_key = public_key_to_ssh(entry.public_key.as_bytes()).ok()?; + Some(format!( + "{} namespaces=\"git\" {}", + entry.principal, ssh_key + )) + }) + .collect(); + if lines.is_empty() { + Ok(String::new()) + } else { + Ok(format!("{}\n", lines.join("\n"))) + } }) } diff --git a/packages/auths-python/src/identity.rs b/packages/auths-python/src/identity.rs index 2536f20e..3e12becd 100644 --- a/packages/auths-python/src/identity.rs +++ b/packages/auths-python/src/identity.rs @@ -236,6 +236,9 @@ pub fn create_agent_identity( let parsed_caps_for_att = _parsed_caps; + #[allow(clippy::disallowed_methods)] // Presentation boundary + let now = chrono::Utc::now(); + py.allow_threads(|| { let (identity_did, result_alias) = initialize_registry_identity( backend.clone(), @@ -275,7 +278,7 @@ pub fn create_agent_identity( "subject": device_did.to_string(), "device_public_key": hex::encode(&pub_bytes), "capabilities": parsed_caps_for_att.iter().map(|c| c.as_str()).collect::>(), - "timestamp": chrono::Utc::now().to_rfc3339(), + "timestamp": now.to_rfc3339(), "note": format!("Agent: {}", alias), }); serde_json::to_string(&att).map_err(|e| { diff --git a/packages/auths-python/src/org.rs b/packages/auths-python/src/org.rs index b308462d..2cf0b863 100644 --- a/packages/auths-python/src/org.rs +++ b/packages/auths-python/src/org.rs @@ -102,6 +102,7 @@ pub fn create_org( initialize_registry_identity(backend.clone(), &key_alias, &provider, &*keychain, None) .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_ORG_ERROR] {e}")))?; + #[allow(clippy::disallowed_methods)] // Presentation boundary: UUID generation let rid = uuid::Uuid::new_v4().to_string(); let resolver = RegistryDidResolver::new(backend.clone()); @@ -110,6 +111,7 @@ pub fn create_org( .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_ORG_ERROR] {e}")))?; let org_pk_bytes = *org_resolved.public_key(); + #[allow(clippy::disallowed_methods)] // Presentation boundary let now = Utc::now(); let admin_capabilities = vec![ Capability::sign_commit(), @@ -160,6 +162,7 @@ pub fn create_org( #[pyfunction] #[pyo3(signature = (org_did, member_did, role, repo_path, capabilities_json=None, passphrase=None, note=None, member_public_key_hex=None))] +#[allow(clippy::too_many_arguments, clippy::type_complexity)] pub fn add_org_member( py: Python<'_>, org_did: &str, @@ -177,7 +180,6 @@ pub fn add_org_member( let org_did = org_did.to_string(); let member_did = member_did.to_string(); let role_str = role.to_string(); - let note = note; py.allow_threads(move || { let role: Role = role_str @@ -272,6 +274,7 @@ pub fn add_org_member( #[pyfunction] #[pyo3(signature = (org_did, member_did, repo_path, passphrase=None, note=None, member_public_key_hex=None))] +#[allow(clippy::type_complexity)] pub fn revoke_org_member( py: Python<'_>, org_did: &str, diff --git a/packages/auths-python/src/pairing.rs b/packages/auths-python/src/pairing.rs index b4a3094e..ed59d1b0 100644 --- a/packages/auths-python/src/pairing.rs +++ b/packages/auths-python/src/pairing.rs @@ -133,6 +133,7 @@ pub fn create_pairing_session_ffi( .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_PAIRING_ERROR] {e}")))?; let controller_did = managed.controller_did.to_string(); + #[allow(clippy::disallowed_methods)] // Presentation boundary let now = Utc::now(); let session_req = build_pairing_session_request( now, @@ -222,7 +223,6 @@ pub fn join_pairing_session_ffi( let short_code = short_code.to_string(); let endpoint = endpoint.to_string(); let token = token.to_string(); - let device_name = device_name; py.allow_threads(move || { let identity_storage = RegistryIdentityStorage::new(repo.clone()); @@ -308,6 +308,7 @@ pub fn join_pairing_session_ffi( .unwrap_or_default(); let expires_at = token_data["expires_at"].as_i64().unwrap_or(0); + #[allow(clippy::disallowed_methods)] // Presentation boundary let now = Utc::now(); let pairing_token = auths_core::pairing::PairingToken { controller_did: controller_did_str, @@ -419,6 +420,7 @@ pub fn complete_pairing_ffi( &passphrase_str, )); + #[allow(clippy::disallowed_methods)] // Presentation boundary let now = Utc::now(); let params = PairingAttestationParams { identity_storage: identity_storage.clone(), diff --git a/packages/auths-python/src/trust.rs b/packages/auths-python/src/trust.rs index 0926ab46..b851a33f 100644 --- a/packages/auths-python/src/trust.rs +++ b/packages/auths-python/src/trust.rs @@ -36,6 +36,7 @@ fn trust_level_str(tl: &TrustLevel) -> &'static str { #[pyfunction] #[pyo3(signature = (did, repo_path, label=None, trust_level="manual"))] +#[allow(clippy::type_complexity)] pub fn pin_identity( py: Python<'_>, did: &str, @@ -46,7 +47,6 @@ pub fn pin_identity( let tl = parse_trust_level(trust_level)?; let did = did.to_string(); let repo = repo_path.to_string(); - let label = label; py.allow_threads(move || { let store = PinnedIdentityStore::new(store_path(&repo)); @@ -65,6 +65,7 @@ pub fn pin_identity( // Check if already pinned — if so, update label by remove + re-pin if let Ok(Some(existing)) = store.lookup(&did) { let _ = store.remove(&did); + #[allow(clippy::disallowed_methods)] // Presentation boundary let now = Utc::now(); let pin = PinnedIdentity { did: did.clone(), @@ -76,7 +77,7 @@ pub fn pin_identity( kel_tip_said: existing.kel_tip_said, kel_sequence: existing.kel_sequence, first_seen: existing.first_seen, - origin: label.clone().unwrap_or_else(|| existing.origin), + origin: label.clone().unwrap_or(existing.origin), trust_level: tl.clone(), }; store @@ -93,6 +94,7 @@ pub fn pin_identity( )); } + #[allow(clippy::disallowed_methods)] // Presentation boundary let now = Utc::now(); let pin = PinnedIdentity { did: did.clone(), @@ -167,6 +169,7 @@ pub fn list_pinned_identities(py: Python<'_>, repo_path: &str) -> PyResult, did: &str, diff --git a/packages/auths-python/src/witness.rs b/packages/auths-python/src/witness.rs index 8b40b92b..4a4273d0 100644 --- a/packages/auths-python/src/witness.rs +++ b/packages/auths-python/src/witness.rs @@ -1,6 +1,6 @@ use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use auths_id::storage::identity::IdentityStorage; use auths_id::witness_config::WitnessConfig; @@ -10,24 +10,24 @@ fn resolve_repo(repo_path: &str) -> PathBuf { PathBuf::from(shellexpand::tilde(repo_path).as_ref()) } -fn load_witness_config(repo_path: &PathBuf) -> PyResult { - let storage = RegistryIdentityStorage::new(repo_path.clone()); +fn load_witness_config(repo_path: &Path) -> PyResult { + let storage = RegistryIdentityStorage::new(repo_path.to_path_buf()); let identity = storage .load_identity() .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_WITNESS_ERROR] {e}")))?; - if let Some(ref metadata) = identity.metadata { - if let Some(wc) = metadata.get("witness_config") { - let config: WitnessConfig = serde_json::from_value(wc.clone()) - .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_WITNESS_ERROR] {e}")))?; - return Ok(config); - } + if let Some(ref metadata) = identity.metadata + && let Some(wc) = metadata.get("witness_config") + { + let config: WitnessConfig = serde_json::from_value(wc.clone()) + .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_WITNESS_ERROR] {e}")))?; + return Ok(config); } Ok(WitnessConfig::default()) } -fn save_witness_config(repo_path: &PathBuf, config: &WitnessConfig) -> PyResult<()> { - let storage = RegistryIdentityStorage::new(repo_path.clone()); +fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> PyResult<()> { + let storage = RegistryIdentityStorage::new(repo_path.to_path_buf()); let mut identity = storage .load_identity() .map_err(|e| PyRuntimeError::new_err(format!("[AUTHS_WITNESS_ERROR] {e}")))?; @@ -59,7 +59,6 @@ pub fn add_witness( ) -> PyResult<(String, Option, Option)> { let url_str = url.to_string(); let repo = resolve_repo(repo_path); - let label = label; py.allow_threads(move || { let parsed_url: url::Url = url_str.parse().map_err(|e| {