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(
57 long,
58 help = "Passphrase to decrypt the key (needed for 'pem'/'pub' formats)."
59 )]
60 passphrase: String,
61
62 #[arg(
64 long,
65 help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
66 )]
67 format: ExportFormat,
68 },
69
70 Delete {
72 #[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 {
83 #[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 #[arg(
93 long,
94 value_parser, help = "Path to the file containing the raw 32-byte Ed25519 seed."
96 )]
97 seed_file: PathBuf,
98
99 #[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 CopyBackend {
123 #[arg(long = "key-alias", visible_alias = "alias")]
125 key_alias: String,
126
127 #[arg(long)]
129 dst_backend: String,
130
131 #[arg(long)]
133 dst_file: Option<PathBuf>,
134
135 #[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 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#[derive(Debug, Serialize)]
177struct KeyListResponse {
178 backend: String,
179 aliases: Vec<String>,
180 count: usize,
181}
182
183fn 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 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 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#[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 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(); ffi::ffi_free_str(ptr); rust_str
253 };
254 println!("{}", pem_string); }
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 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 let slice = std::slice::from_raw_parts(buf_ptr, out_len);
288 let data = slice.to_vec(); ffi::ffi_free_bytes(buf_ptr, out_len); data
291 };
292 println!("{}", hex::encode(slice_data)); }
294 }
295
296 Ok(())
297}
298
299fn 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); Ok(())
308 }
309 Err(AgentError::KeyNotFound) => {
310 eprintln!("âšī¸ Key alias '{}' not found, nothing to remove.", alias);
311 Ok(()) }
313 Err(err) => {
314 Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
316 }
317 }
318}
319
320fn 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)] 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)] 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
370fn key_copy_backend(
378 alias: &str,
379 dst_backend: &str,
380 dst_file: Option<&PathBuf>,
381 dst_passphrase: Option<&str>,
382) -> Result<()> {
383 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 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 let password: Zeroizing<String> = dst_passphrase
402 .map(|s| Zeroizing::new(s.to_string()))
403 .or_else(|| {
404 #[allow(clippy::disallowed_methods)] 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 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}