Skip to main content

auths_cli/commands/
key.rs

1use anyhow::{Context, Result, anyhow};
2use clap::{Parser, Subcommand};
3use serde::Serialize;
4use std::ffi::CString;
5use std::fs;
6use std::io::IsTerminal;
7use std::path::PathBuf;
8
9use auths_sdk::domains::signing::export_policy::{ExportSensitivity, authorize_key_export};
10use auths_sdk::error::AgentError;
11use auths_sdk::ffi::ffi;
12use auths_sdk::keychain::EncryptedFileStorage;
13use auths_sdk::keychain::{IdentityDID, KeyAlias, KeyRole, KeyStorage, get_platform_keychain};
14use zeroize::{Zeroize, Zeroizing};
15
16use crate::core::types::ExportFormat;
17use crate::ux::format::{JsonResponse, is_json_mode};
18
19#[derive(Parser, Debug, Clone)]
20#[command(
21    name = "key",
22    about = "Manage signing keys stored on this device.",
23    after_help = "Examples:
24  auths key list            # List all stored key aliases
25  auths key import --key-alias mykey --seed-file seed.bin
26                            # Import an Ed25519 key from a 32-byte seed
27  auths key export --key-alias mykey --format pub --passphrase <pass>
28                            # Export a public key in OpenSSH format
29  auths key delete --key-alias mykey
30                            # Remove a key from secure storage
31
32Related:
33  auths id     — Manage identities
34  auths device — Manage linked devices
35  auths sign   — Sign commits and artifacts using keys"
36)]
37pub struct KeyCommand {
38    #[command(subcommand)]
39    pub command: KeySubcommand,
40}
41
42#[derive(Subcommand, Debug, Clone)]
43pub enum KeySubcommand {
44    /// List aliases of all keys stored in the platform's secure storage.
45    List,
46
47    /// Export a stored key in various formats (requires passphrase for some formats).
48    Export {
49        /// Local alias of the key to export.
50        #[arg(
51            long = "key-alias",
52            visible_alias = "alias",
53            help = "Local alias of the key to export."
54        )]
55        key_alias: String,
56
57        /// Passphrase to decrypt the key (needed for 'pem'/'pub' formats).
58        /// Omit it to be prompted — passing it as an argument leaves it in
59        /// shell history.
60        #[arg(
61            long,
62            help = "Passphrase to decrypt the key (omit to be prompted securely)."
63        )]
64        passphrase: Option<String>,
65
66        /// Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes).
67        #[arg(
68            long,
69            help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
70        )]
71        format: ExportFormat,
72    },
73
74    /// Remove a key from the platform's secure storage by alias.
75    Delete {
76        /// Local alias of the key to remove.
77        #[arg(
78            long = "key-alias",
79            visible_alias = "alias",
80            help = "Local alias of the key to remove."
81        )]
82        key_alias: String,
83    },
84
85    /// Import an Ed25519 key from a 32-byte seed file and store it encrypted.
86    Import {
87        /// Local alias to assign to the imported key.
88        #[arg(
89            long = "key-alias",
90            visible_alias = "alias",
91            help = "Local alias to assign to the imported key."
92        )]
93        key_alias: String,
94
95        /// Path to the file containing the raw 32-byte Ed25519 seed.
96        #[arg(
97            long,
98            value_parser, // Add value_parser for PathBuf
99            help = "Path to the file containing the raw 32-byte Ed25519 seed."
100        )]
101        seed_file: PathBuf,
102
103        /// Your identity to associate with this key (e.g., did:keri:E...).
104        #[arg(
105            long,
106            help = "Your identity to associate with this key (e.g., did:keri:E...)."
107        )]
108        controller_did: String,
109    },
110
111    /// Copy a key from the current keychain backend to a different backend.
112    ///
113    /// Useful for creating a file-based keychain for headless CI environments without
114    /// exposing the raw key material. The encrypted key bytes are copied as-is; the
115    /// same passphrase used to store the key in the source backend must be used when
116    /// loading it from the destination.
117    ///
118    /// Examples:
119    ///   # Copy to file keychain (passphrase from env var)
120    ///   AUTHS_PASSPHRASE="$PASS" auths key copy-backend \
121    ///     --alias main --dst-backend file --dst-file /tmp/keychain.enc
122    ///
123    ///   # Copy to file keychain (passphrase from flag)
124    ///   auths key copy-backend --alias main \
125    ///     --dst-backend file --dst-file /tmp/keychain.enc --dst-passphrase "$PASS"
126    CopyBackend {
127        /// Alias of the key to copy from the current (source) keychain.
128        #[arg(long = "key-alias", visible_alias = "alias")]
129        key_alias: String,
130
131        /// Destination backend type. Currently supported: "file".
132        #[arg(long)]
133        dst_backend: String,
134
135        /// Path for the destination file keychain (required when --dst-backend is "file").
136        #[arg(long)]
137        dst_file: Option<PathBuf>,
138
139        /// Passphrase for the destination file keychain.
140        /// If omitted, the AUTHS_PASSPHRASE environment variable is used.
141        #[arg(long)]
142        dst_passphrase: Option<String>,
143    },
144}
145
146pub fn handle_key(cmd: KeyCommand) -> Result<()> {
147    match cmd.command {
148        KeySubcommand::List => key_list(),
149        KeySubcommand::Export {
150            key_alias,
151            passphrase,
152            format,
153        } => {
154            // A plaintext private-key export hands out a usable secret in the clear, so it requires an
155            // interactive confirmation; refused non-interactively so a script holding AUTHS_PASSPHRASE
156            // cannot silently exfiltrate the signing key. Public/encrypted exports are not gated.
157            let sensitivity = match format {
158                ExportFormat::Pem => ExportSensitivity::PlaintextPrivate,
159                ExportFormat::Pub => ExportSensitivity::Public,
160                ExportFormat::Enc => ExportSensitivity::Encrypted,
161            };
162            let interactive = std::io::stdin().is_terminal();
163            let confirmed = if sensitivity == ExportSensitivity::PlaintextPrivate && interactive {
164                dialoguer::Confirm::new()
165                    .with_prompt(
166                        "Export your UNENCRYPTED private key to stdout? Anyone who reads it controls \
167                         this identity. Continue? [y/N]",
168                    )
169                    .default(false)
170                    .interact()
171                    .context("Failed to read export confirmation")?
172            } else {
173                false
174            };
175            authorize_key_export(sensitivity, interactive, confirmed)
176                .map_err(|e| anyhow!("{e}"))?;
177
178            let passphrase = match passphrase {
179                Some(p) => p,
180                None => prompt_export_passphrase()?,
181            };
182            key_export(&key_alias, &passphrase, format)
183        }
184        KeySubcommand::Delete { key_alias } => key_delete(&key_alias),
185        KeySubcommand::Import {
186            key_alias,
187            seed_file,
188            controller_did,
189        } => {
190            let identity_did = IdentityDID::try_from(controller_did)
191                .context("controller DID is not a valid did:keri identifier")?;
192            key_import(&key_alias, &seed_file, &identity_did)
193        }
194        KeySubcommand::CopyBackend {
195            key_alias,
196            dst_backend,
197            dst_file,
198            dst_passphrase,
199        } => key_copy_backend(
200            &key_alias,
201            &dst_backend,
202            dst_file.as_ref(),
203            dst_passphrase.as_deref(),
204        ),
205    }
206}
207
208/// JSON response for key list command.
209#[derive(Debug, Serialize)]
210struct KeyListResponse {
211    backend: String,
212    aliases: Vec<String>,
213    count: usize,
214}
215
216/// Lists all key aliases stored in the platform's secure storage.
217fn key_list() -> Result<()> {
218    let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
219    let backend_name = keychain.backend_name().to_string();
220
221    let aliases = match keychain.list_aliases() {
222        Ok(a) => a,
223        Err(AgentError::SecurityError(msg))
224            if cfg!(target_os = "macos") && msg.contains("-25300") =>
225        {
226            // Handle macOS 'item not found' gracefully
227            Vec::new()
228        }
229        Err(e) => return Err(e.into()),
230    };
231
232    if is_json_mode() {
233        let alias_strings: Vec<String> = aliases.iter().map(|a| a.to_string()).collect();
234        let count = alias_strings.len();
235        let response = JsonResponse::success(
236            "key list",
237            KeyListResponse {
238                backend: backend_name,
239                aliases: alias_strings,
240                count,
241            },
242        );
243        response.print()?;
244    } else {
245        // Use eprintln for status messages to not interfere with potential stdout parsing
246        eprintln!("Using key storage: {}", backend_name);
247
248        if aliases.is_empty() {
249            println!("No keys found in keychain for this application.");
250        } else {
251            println!("Stored keys:");
252            for alias in aliases {
253                println!("- {}", alias);
254            }
255        }
256    }
257
258    Ok(())
259}
260
261/// Frees the wrapped FFI context on drop, covering early returns in `key_export`.
262struct FfiContextGuard(*mut ffi::AuthsFfiContext);
263
264impl Drop for FfiContextGuard {
265    fn drop(&mut self) {
266        unsafe { ffi::ffi_context_free(self.0) };
267    }
268}
269
270/// Exports a stored key in one of several formats using FFI calls.
271#[inline]
272/// Prompt for the export passphrase on the terminal — never via a CLI argument
273/// (which would land in shell history). Falls back to AUTHS_PASSPHRASE for
274/// headless use.
275fn prompt_export_passphrase() -> Result<String> {
276    #[allow(clippy::disallowed_methods)] // CLI boundary: env fallback for headless export
277    if let Ok(pass) = std::env::var("AUTHS_PASSPHRASE") {
278        return Ok(pass);
279    }
280    rpassword::prompt_password("Enter passphrase: ")
281        .map_err(|e| anyhow::anyhow!("Failed to read passphrase from terminal: {e}"))
282}
283
284fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> {
285    let c_alias = CString::new(alias).context("Alias contains null byte")?;
286    let c_passphrase = CString::new(passphrase).context("Passphrase contains null byte")?;
287    let ctx = unsafe { ffi::ffi_context_new(std::ptr::null()) };
288    if ctx.is_null() {
289        anyhow::bail!("Failed to initialize FFI configuration context");
290    }
291    let ctx_guard = FfiContextGuard(ctx);
292    let ctx = ctx_guard.0.cast_const();
293
294    match format {
295        ExportFormat::Pem => {
296            let ptr = unsafe {
297                ffi::ffi_export_private_key_openssh(ctx, c_alias.as_ptr(), c_passphrase.as_ptr())
298            };
299            if ptr.is_null() {
300                anyhow::bail!(
301                    "Failed to export PEM private key. Check the key alias with `auths key list` or verify your passphrase."
302                );
303            }
304            let pem_string = unsafe {
305                // Safety: ptr is not null and points to a C string allocated by FFI
306                let c_str = std::ffi::CStr::from_ptr(ptr);
307                let rust_str = c_str
308                    .to_str()
309                    .context("Failed to convert PEM FFI string to UTF-8")?
310                    .to_owned(); // Own the string before freeing ptr
311                ffi::ffi_free_str(ptr); // Free immediately after copying
312                rust_str
313            };
314            println!("{}", pem_string); // Print the owned Rust string
315        }
316        ExportFormat::Pub => {
317            let ptr = unsafe {
318                ffi::ffi_export_public_key_openssh(ctx, c_alias.as_ptr(), c_passphrase.as_ptr())
319            };
320            if ptr.is_null() {
321                anyhow::bail!(
322                    "Failed to export public key. Check the key alias with `auths key list` or verify your passphrase."
323                );
324            }
325            let pub_string = unsafe {
326                // Safety: ptr is not null and points to a C string allocated by FFI
327                let c_str = std::ffi::CStr::from_ptr(ptr);
328                let rust_str = c_str
329                    .to_str()
330                    .context("Failed to convert Pubkey FFI string to UTF-8")?
331                    .to_owned();
332                ffi::ffi_free_str(ptr);
333                rust_str
334            };
335            println!("{}", pub_string);
336        }
337        ExportFormat::Enc => {
338            let mut out_len: usize = 0;
339            let buf_ptr =
340                unsafe { ffi::ffi_export_encrypted_key(ctx, c_alias.as_ptr(), &mut out_len) };
341            if buf_ptr.is_null() {
342                anyhow::bail!(
343                    "❌ Failed to export encrypted private key (key not found or FFI error)"
344                );
345            }
346            let slice_data = unsafe {
347                // Safety: buf_ptr is not null and out_len is set by FFI
348                let slice = std::slice::from_raw_parts(buf_ptr, out_len);
349                let data = slice.to_vec(); // Copy data before freeing
350                ffi::ffi_free_bytes(buf_ptr, out_len); // Free immediately
351                data
352            };
353            println!("{}", hex::encode(slice_data)); // Print hex of copied data
354        }
355    }
356
357    Ok(())
358}
359
360/// Deletes a key from the platform's secure storage by its alias.
361fn key_delete(alias: &str) -> Result<()> {
362    let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
363    eprintln!("Using key storage: {}", keychain.backend_name());
364
365    match keychain.delete_key(&KeyAlias::new_unchecked(alias)) {
366        Ok(_) => {
367            println!("🗑️ Removed key alias '{}'", alias); // Print confirmation to stdout
368            Ok(())
369        }
370        Err(AgentError::KeyNotFound) => {
371            eprintln!("ℹ️ Key alias '{}' not found, nothing to remove.", alias);
372            Ok(()) // Treat 'not found' as success for delete idempotency
373        }
374        Err(err) => {
375            // Propagate other errors
376            Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
377        }
378    }
379}
380
381/// Imports an Ed25519 key from a 32-byte seed file, encrypts, and stores it.
382fn key_import(alias: &str, seed_file_path: &PathBuf, controller_did: &IdentityDID) -> Result<()> {
383    let seed_bytes = fs::read(seed_file_path)
384        .with_context(|| format!("Failed to read seed file: {:?}", seed_file_path))?;
385    if seed_bytes.len() != 32 {
386        return Err(anyhow!(
387            "Seed file must contain exactly 32 bytes, found {}.",
388            seed_bytes.len()
389        ));
390    }
391    #[allow(clippy::expect_used)] // INVARIANT: length validated to be 32 bytes on line 309
392    let seed: [u8; 32] = seed_bytes.try_into().expect("validated 32 bytes above");
393    let seed = Zeroizing::new(seed);
394
395    #[allow(clippy::disallowed_methods)] // CLI boundary: passphrase from env
396    let passphrase = if let Ok(env_pass) = std::env::var("AUTHS_PASSPHRASE") {
397        Zeroizing::new(env_pass)
398    } else {
399        Zeroizing::new(
400            rpassword::prompt_password(format!(
401                "Enter passphrase to encrypt the key '{}': ",
402                alias
403            ))
404            .context("Failed to read passphrase")?,
405        )
406    };
407    if passphrase.is_empty() {
408        return Err(anyhow!("Passphrase cannot be empty."));
409    }
410
411    let keychain = get_platform_keychain()?;
412    let result = auths_sdk::keys::import_seed(
413        &seed,
414        auths_crypto::CurveType::default(),
415        &passphrase,
416        alias,
417        controller_did,
418        keychain.as_ref(),
419    )
420    .with_context(|| format!("Failed to import key '{alias}'"))?;
421
422    println!(
423        "Imported key '{}' ({} public key: {})",
424        result.alias,
425        result.curve,
426        hex::encode(&result.public_key)
427    );
428    Ok(())
429}
430
431/// Copies a key from the current (source) keychain to a different destination backend.
432///
433/// The encrypted key bytes are transferred as-is — no re-encryption occurs. The same
434/// passphrase used when the key was originally stored must be used to load it later.
435///
436/// For the file backend, the destination file keychain is protected by an additional
437/// file-level passphrase supplied via `--dst-passphrase` or `AUTHS_PASSPHRASE`.
438fn key_copy_backend(
439    alias: &str,
440    dst_backend: &str,
441    dst_file: Option<&PathBuf>,
442    dst_passphrase: Option<&str>,
443) -> Result<()> {
444    // Load the encrypted key bytes from the source keychain (no decryption).
445    let src_keychain = get_platform_keychain()?;
446    eprintln!("Source backend: {}", src_keychain.backend_name());
447
448    let key_alias = KeyAlias::new_unchecked(alias);
449    let (identity_did, _role, mut encrypted_key_data) = src_keychain
450        .load_key(&key_alias)
451        .with_context(|| format!("Key '{}' not found in source keychain", alias))?;
452
453    // Build destination storage.
454    let dst_storage: Box<dyn KeyStorage> = match dst_backend.to_lowercase().as_str() {
455        "file" => {
456            let path = dst_file
457                .ok_or_else(|| anyhow!("--dst-file is required when --dst-backend is 'file'"))?;
458            let storage = EncryptedFileStorage::with_path(path.clone())
459                .context("Failed to create destination file storage")?;
460            // Passphrase priority: explicit flag > AUTHS_PASSPHRASE env var > error.
461            // Wrap immediately in Zeroizing so the heap allocation is cleared on drop.
462            let password: Zeroizing<String> = dst_passphrase
463                .map(|s| Zeroizing::new(s.to_string()))
464                .or_else(|| {
465                    #[allow(clippy::disallowed_methods)] // CLI boundary: passphrase from env
466                    std::env::var("AUTHS_PASSPHRASE").ok().map(Zeroizing::new)
467                })
468                .ok_or_else(|| {
469                    anyhow!(
470                        "Passphrase required for file backend. \
471                        Use --dst-passphrase or set the AUTHS_PASSPHRASE env var."
472                    )
473                })?;
474            storage.set_password(password);
475            Box::new(storage)
476        }
477        other => {
478            encrypted_key_data.zeroize();
479            return Err(anyhow!(
480                "Unknown destination backend '{}'. Supported values: file",
481                other
482            ));
483        }
484    };
485
486    eprintln!("Destination backend: {}", dst_storage.backend_name());
487
488    let result = dst_storage
489        .store_key(
490            &key_alias,
491            &identity_did,
492            KeyRole::Primary,
493            &encrypted_key_data,
494        )
495        .with_context(|| format!("Failed to store key '{}' in destination backend", alias));
496
497    // Zeroize the encrypted key bytes regardless of whether store succeeded.
498    encrypted_key_data.zeroize();
499
500    result?;
501    eprintln!(
502        "✓ Copied key '{}' ({}) to {}",
503        alias, identity_did, dst_backend
504    );
505    Ok(())
506}
507
508use crate::commands::executable::ExecutableCommand;
509use crate::config::CliConfig;
510
511impl ExecutableCommand for KeyCommand {
512    fn execute(&self, ctx: &CliConfig) -> Result<()> {
513        // Every `key` subcommand operates on the per-machine keychain selected by
514        // AUTHS_KEYCHAIN_* (backend, file, passphrase) — never on a repo-scoped
515        // registry. `--repo` cannot point at the keychain, so accepting it would
516        // silently act on the global store. Reject it instead of ignoring it.
517        if ctx.repo_path.is_some() {
518            anyhow::bail!(
519                "`--repo` is not supported for `auths key`; the keychain is selected by \
520                 AUTHS_KEYCHAIN_BACKEND / AUTHS_KEYCHAIN_FILE, not by repository."
521            );
522        }
523        handle_key(self.clone())
524    }
525}