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