1use anyhow::{Context, Result, anyhow};
2use clap::{Parser, Subcommand};
3use serde::Serialize;
4use std::ffi::CString;
5use std::fs;
6use std::io::IsTerminal;
7use std::path::PathBuf;
8
9use auths_sdk::domains::signing::export_policy::{ExportSensitivity, authorize_key_export};
10use auths_sdk::error::AgentError;
11use auths_sdk::ffi::ffi;
12use auths_sdk::keychain::EncryptedFileStorage;
13use auths_sdk::keychain::{IdentityDID, KeyAlias, KeyRole, KeyStorage, get_platform_keychain};
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 signing keys stored on this device.",
23 after_help = "Examples:
24 auths key list # List all stored key aliases
25 auths key import --key-alias mykey --seed-file seed.bin
26 # Import an Ed25519 key from a 32-byte seed
27 auths key export --key-alias mykey --format pub --passphrase <pass>
28 # Export a public key in OpenSSH format
29 auths key delete --key-alias mykey
30 # Remove a key from secure storage
31
32Related:
33 auths id — Manage identities
34 auths device — Manage linked devices
35 auths sign — Sign commits and artifacts using keys"
36)]
37pub struct KeyCommand {
38 #[command(subcommand)]
39 pub command: KeySubcommand,
40}
41
42#[derive(Subcommand, Debug, Clone)]
43pub enum KeySubcommand {
44 List,
46
47 Export {
49 #[arg(
51 long = "key-alias",
52 visible_alias = "alias",
53 help = "Local alias of the key to export."
54 )]
55 key_alias: String,
56
57 #[arg(
61 long,
62 help = "Passphrase to decrypt the key (omit to be prompted securely)."
63 )]
64 passphrase: Option<String>,
65
66 #[arg(
68 long,
69 help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
70 )]
71 format: ExportFormat,
72 },
73
74 Delete {
76 #[arg(
78 long = "key-alias",
79 visible_alias = "alias",
80 help = "Local alias of the key to remove."
81 )]
82 key_alias: String,
83 },
84
85 Import {
87 #[arg(
89 long = "key-alias",
90 visible_alias = "alias",
91 help = "Local alias to assign to the imported key."
92 )]
93 key_alias: String,
94
95 #[arg(
97 long,
98 value_parser, help = "Path to the file containing the raw 32-byte Ed25519 seed."
100 )]
101 seed_file: PathBuf,
102
103 #[arg(
105 long,
106 help = "Your identity to associate with this key (e.g., did:keri:E...)."
107 )]
108 controller_did: String,
109 },
110
111 CopyBackend {
127 #[arg(long = "key-alias", visible_alias = "alias")]
129 key_alias: String,
130
131 #[arg(long)]
133 dst_backend: String,
134
135 #[arg(long)]
137 dst_file: Option<PathBuf>,
138
139 #[arg(long)]
142 dst_passphrase: Option<String>,
143 },
144}
145
146pub fn handle_key(cmd: KeyCommand) -> Result<()> {
147 match cmd.command {
148 KeySubcommand::List => key_list(),
149 KeySubcommand::Export {
150 key_alias,
151 passphrase,
152 format,
153 } => {
154 let sensitivity = match format {
158 ExportFormat::Pem => ExportSensitivity::PlaintextPrivate,
159 ExportFormat::Pub => ExportSensitivity::Public,
160 ExportFormat::Enc => ExportSensitivity::Encrypted,
161 };
162 let interactive = std::io::stdin().is_terminal();
163 let confirmed = if sensitivity == ExportSensitivity::PlaintextPrivate && interactive {
164 dialoguer::Confirm::new()
165 .with_prompt(
166 "Export your UNENCRYPTED private key to stdout? Anyone who reads it controls \
167 this identity. Continue? [y/N]",
168 )
169 .default(false)
170 .interact()
171 .context("Failed to read export confirmation")?
172 } else {
173 false
174 };
175 authorize_key_export(sensitivity, interactive, confirmed)
176 .map_err(|e| anyhow!("{e}"))?;
177
178 let passphrase = match passphrase {
179 Some(p) => p,
180 None => prompt_export_passphrase()?,
181 };
182 key_export(&key_alias, &passphrase, format)
183 }
184 KeySubcommand::Delete { key_alias } => key_delete(&key_alias),
185 KeySubcommand::Import {
186 key_alias,
187 seed_file,
188 controller_did,
189 } => {
190 let identity_did = IdentityDID::try_from(controller_did)
191 .context("controller DID is not a valid did:keri identifier")?;
192 key_import(&key_alias, &seed_file, &identity_did)
193 }
194 KeySubcommand::CopyBackend {
195 key_alias,
196 dst_backend,
197 dst_file,
198 dst_passphrase,
199 } => key_copy_backend(
200 &key_alias,
201 &dst_backend,
202 dst_file.as_ref(),
203 dst_passphrase.as_deref(),
204 ),
205 }
206}
207
208#[derive(Debug, Serialize)]
210struct KeyListResponse {
211 backend: String,
212 aliases: Vec<String>,
213 count: usize,
214}
215
216fn key_list() -> Result<()> {
218 let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
219 let backend_name = keychain.backend_name().to_string();
220
221 let aliases = match keychain.list_aliases() {
222 Ok(a) => a,
223 Err(AgentError::SecurityError(msg))
224 if cfg!(target_os = "macos") && msg.contains("-25300") =>
225 {
226 Vec::new()
228 }
229 Err(e) => return Err(e.into()),
230 };
231
232 if is_json_mode() {
233 let alias_strings: Vec<String> = aliases.iter().map(|a| a.to_string()).collect();
234 let count = alias_strings.len();
235 let response = JsonResponse::success(
236 "key list",
237 KeyListResponse {
238 backend: backend_name,
239 aliases: alias_strings,
240 count,
241 },
242 );
243 response.print()?;
244 } else {
245 eprintln!("Using key storage: {}", backend_name);
247
248 if aliases.is_empty() {
249 println!("No keys found in keychain for this application.");
250 } else {
251 println!("Stored keys:");
252 for alias in aliases {
253 println!("- {}", alias);
254 }
255 }
256 }
257
258 Ok(())
259}
260
261struct FfiContextGuard(*mut ffi::AuthsFfiContext);
263
264impl Drop for FfiContextGuard {
265 fn drop(&mut self) {
266 unsafe { ffi::ffi_context_free(self.0) };
267 }
268}
269
270#[inline]
272fn prompt_export_passphrase() -> Result<String> {
276 #[allow(clippy::disallowed_methods)] if let Ok(pass) = std::env::var("AUTHS_PASSPHRASE") {
278 return Ok(pass);
279 }
280 rpassword::prompt_password("Enter passphrase: ")
281 .map_err(|e| anyhow::anyhow!("Failed to read passphrase from terminal: {e}"))
282}
283
284fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> {
285 let c_alias = CString::new(alias).context("Alias contains null byte")?;
286 let c_passphrase = CString::new(passphrase).context("Passphrase contains null byte")?;
287 let ctx = unsafe { ffi::ffi_context_new(std::ptr::null()) };
288 if ctx.is_null() {
289 anyhow::bail!("Failed to initialize FFI configuration context");
290 }
291 let ctx_guard = FfiContextGuard(ctx);
292 let ctx = ctx_guard.0.cast_const();
293
294 match format {
295 ExportFormat::Pem => {
296 let ptr = unsafe {
297 ffi::ffi_export_private_key_openssh(ctx, c_alias.as_ptr(), c_passphrase.as_ptr())
298 };
299 if ptr.is_null() {
300 anyhow::bail!(
301 "Failed to export PEM private key. Check the key alias with `auths key list` or verify your passphrase."
302 );
303 }
304 let pem_string = unsafe {
305 let c_str = std::ffi::CStr::from_ptr(ptr);
307 let rust_str = c_str
308 .to_str()
309 .context("Failed to convert PEM FFI string to UTF-8")?
310 .to_owned(); ffi::ffi_free_str(ptr); rust_str
313 };
314 println!("{}", pem_string); }
316 ExportFormat::Pub => {
317 let ptr = unsafe {
318 ffi::ffi_export_public_key_openssh(ctx, c_alias.as_ptr(), c_passphrase.as_ptr())
319 };
320 if ptr.is_null() {
321 anyhow::bail!(
322 "Failed to export public key. Check the key alias with `auths key list` or verify your passphrase."
323 );
324 }
325 let pub_string = unsafe {
326 let c_str = std::ffi::CStr::from_ptr(ptr);
328 let rust_str = c_str
329 .to_str()
330 .context("Failed to convert Pubkey FFI string to UTF-8")?
331 .to_owned();
332 ffi::ffi_free_str(ptr);
333 rust_str
334 };
335 println!("{}", pub_string);
336 }
337 ExportFormat::Enc => {
338 let mut out_len: usize = 0;
339 let buf_ptr =
340 unsafe { ffi::ffi_export_encrypted_key(ctx, c_alias.as_ptr(), &mut out_len) };
341 if buf_ptr.is_null() {
342 anyhow::bail!(
343 "❌ Failed to export encrypted private key (key not found or FFI error)"
344 );
345 }
346 let slice_data = unsafe {
347 let slice = std::slice::from_raw_parts(buf_ptr, out_len);
349 let data = slice.to_vec(); ffi::ffi_free_bytes(buf_ptr, out_len); data
352 };
353 println!("{}", hex::encode(slice_data)); }
355 }
356
357 Ok(())
358}
359
360fn key_delete(alias: &str) -> Result<()> {
362 let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
363 eprintln!("Using key storage: {}", keychain.backend_name());
364
365 match keychain.delete_key(&KeyAlias::new_unchecked(alias)) {
366 Ok(_) => {
367 println!("🗑️ Removed key alias '{}'", alias); Ok(())
369 }
370 Err(AgentError::KeyNotFound) => {
371 eprintln!("ℹ️ Key alias '{}' not found, nothing to remove.", alias);
372 Ok(()) }
374 Err(err) => {
375 Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
377 }
378 }
379}
380
381fn key_import(alias: &str, seed_file_path: &PathBuf, controller_did: &IdentityDID) -> Result<()> {
383 let seed_bytes = fs::read(seed_file_path)
384 .with_context(|| format!("Failed to read seed file: {:?}", seed_file_path))?;
385 if seed_bytes.len() != 32 {
386 return Err(anyhow!(
387 "Seed file must contain exactly 32 bytes, found {}.",
388 seed_bytes.len()
389 ));
390 }
391 #[allow(clippy::expect_used)] let seed: [u8; 32] = seed_bytes.try_into().expect("validated 32 bytes above");
393 let seed = Zeroizing::new(seed);
394
395 #[allow(clippy::disallowed_methods)] let passphrase = if let Ok(env_pass) = std::env::var("AUTHS_PASSPHRASE") {
397 Zeroizing::new(env_pass)
398 } else {
399 Zeroizing::new(
400 rpassword::prompt_password(format!(
401 "Enter passphrase to encrypt the key '{}': ",
402 alias
403 ))
404 .context("Failed to read passphrase")?,
405 )
406 };
407 if passphrase.is_empty() {
408 return Err(anyhow!("Passphrase cannot be empty."));
409 }
410
411 let keychain = get_platform_keychain()?;
412 let result = auths_sdk::keys::import_seed(
413 &seed,
414 auths_crypto::CurveType::default(),
415 &passphrase,
416 alias,
417 controller_did,
418 keychain.as_ref(),
419 )
420 .with_context(|| format!("Failed to import key '{alias}'"))?;
421
422 println!(
423 "Imported key '{}' ({} public key: {})",
424 result.alias,
425 result.curve,
426 hex::encode(&result.public_key)
427 );
428 Ok(())
429}
430
431fn key_copy_backend(
439 alias: &str,
440 dst_backend: &str,
441 dst_file: Option<&PathBuf>,
442 dst_passphrase: Option<&str>,
443) -> Result<()> {
444 let src_keychain = get_platform_keychain()?;
446 eprintln!("Source backend: {}", src_keychain.backend_name());
447
448 let key_alias = KeyAlias::new_unchecked(alias);
449 let (identity_did, _role, mut encrypted_key_data) = src_keychain
450 .load_key(&key_alias)
451 .with_context(|| format!("Key '{}' not found in source keychain", alias))?;
452
453 let dst_storage: Box<dyn KeyStorage> = match dst_backend.to_lowercase().as_str() {
455 "file" => {
456 let path = dst_file
457 .ok_or_else(|| anyhow!("--dst-file is required when --dst-backend is 'file'"))?;
458 let storage = EncryptedFileStorage::with_path(path.clone())
459 .context("Failed to create destination file storage")?;
460 let password: Zeroizing<String> = dst_passphrase
463 .map(|s| Zeroizing::new(s.to_string()))
464 .or_else(|| {
465 #[allow(clippy::disallowed_methods)] std::env::var("AUTHS_PASSPHRASE").ok().map(Zeroizing::new)
467 })
468 .ok_or_else(|| {
469 anyhow!(
470 "Passphrase required for file backend. \
471 Use --dst-passphrase or set the AUTHS_PASSPHRASE env var."
472 )
473 })?;
474 storage.set_password(password);
475 Box::new(storage)
476 }
477 other => {
478 encrypted_key_data.zeroize();
479 return Err(anyhow!(
480 "Unknown destination backend '{}'. Supported values: file",
481 other
482 ));
483 }
484 };
485
486 eprintln!("Destination backend: {}", dst_storage.backend_name());
487
488 let result = dst_storage
489 .store_key(
490 &key_alias,
491 &identity_did,
492 KeyRole::Primary,
493 &encrypted_key_data,
494 )
495 .with_context(|| format!("Failed to store key '{}' in destination backend", alias));
496
497 encrypted_key_data.zeroize();
499
500 result?;
501 eprintln!(
502 "✓ Copied key '{}' ({}) to {}",
503 alias, identity_did, dst_backend
504 );
505 Ok(())
506}
507
508use crate::commands::executable::ExecutableCommand;
509use crate::config::CliConfig;
510
511impl ExecutableCommand for KeyCommand {
512 fn execute(&self, ctx: &CliConfig) -> Result<()> {
513 if ctx.repo_path.is_some() {
518 anyhow::bail!(
519 "`--repo` is not supported for `auths key`; the keychain is selected by \
520 AUTHS_KEYCHAIN_BACKEND / AUTHS_KEYCHAIN_FILE, not by repository."
521 );
522 }
523 handle_key(self.clone())
524 }
525}