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