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