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