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,
44
45 Export {
47 #[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 #[arg(
59 long,
60 help = "Passphrase to decrypt the key (omit to be prompted securely)."
61 )]
62 passphrase: Option<String>,
63
64 #[arg(
66 long,
67 help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
68 )]
69 format: ExportFormat,
70 },
71
72 Delete {
74 #[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 {
85 #[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 #[arg(
95 long,
96 value_parser, help = "Path to the file containing the raw 32-byte Ed25519 seed."
98 )]
99 seed_file: PathBuf,
100
101 #[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 CopyBackend {
125 #[arg(long = "key-alias", visible_alias = "alias")]
127 key_alias: String,
128
129 #[arg(long)]
131 dst_backend: String,
132
133 #[arg(long)]
135 dst_file: Option<PathBuf>,
136
137 #[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 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#[derive(Debug, Serialize)]
185struct KeyListResponse {
186 backend: String,
187 aliases: Vec<String>,
188 count: usize,
189}
190
191fn 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 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 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
236struct 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#[inline]
247fn prompt_export_passphrase() -> Result<String> {
251 #[allow(clippy::disallowed_methods)] 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 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(); ffi::ffi_free_str(ptr); rust_str
288 };
289 println!("{}", pem_string); }
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 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 let slice = std::slice::from_raw_parts(buf_ptr, out_len);
324 let data = slice.to_vec(); ffi::ffi_free_bytes(buf_ptr, out_len); data
327 };
328 println!("{}", hex::encode(slice_data)); }
330 }
331
332 Ok(())
333}
334
335fn 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); Ok(())
344 }
345 Err(AgentError::KeyNotFound) => {
346 eprintln!("ℹ️ Key alias '{}' not found, nothing to remove.", alias);
347 Ok(()) }
349 Err(err) => {
350 Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
352 }
353 }
354}
355
356fn 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)] 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)] 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
406fn key_copy_backend(
414 alias: &str,
415 dst_backend: &str,
416 dst_file: Option<&PathBuf>,
417 dst_passphrase: Option<&str>,
418) -> Result<()> {
419 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 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 let password: Zeroizing<String> = dst_passphrase
438 .map(|s| Zeroizing::new(s.to_string()))
439 .or_else(|| {
440 #[allow(clippy::disallowed_methods)] 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 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}