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,
33
34 Export {
36 #[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 #[arg(
46 long,
47 help = "Passphrase to decrypt the key (needed for 'pem'/'pub' formats)."
48 )]
49 passphrase: String,
50
51 #[arg(
53 long,
54 help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
55 )]
56 format: ExportFormat,
57 },
58
59 Delete {
61 #[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 {
72 #[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 #[arg(
82 long,
83 value_parser, help = "Path to the file containing the raw 32-byte Ed25519 seed."
85 )]
86 seed_file: PathBuf,
87
88 #[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 CopyBackend {
112 #[arg(long = "key-alias", visible_alias = "alias")]
114 key_alias: String,
115
116 #[arg(long)]
118 dst_backend: String,
119
120 #[arg(long)]
122 dst_file: Option<PathBuf>,
123
124 #[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#[derive(Debug, Serialize)]
164struct KeyListResponse {
165 backend: String,
166 aliases: Vec<String>,
167 count: usize,
168}
169
170fn 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 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 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#[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 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(); ffi::ffi_free_str(ptr); rust_str
240 };
241 println!("{}", pem_string); }
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 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 let slice = std::slice::from_raw_parts(buf_ptr, out_len);
273 let data = slice.to_vec(); ffi::ffi_free_bytes(buf_ptr, out_len); data
276 };
277 println!("{}", hex::encode(slice_data)); }
279 }
280
281 Ok(())
282}
283
284fn 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); Ok(())
293 }
294 Err(AgentError::KeyNotFound) => {
295 eprintln!("âšī¸ Key alias '{}' not found, nothing to remove.", alias);
296 Ok(()) }
298 Err(err) => {
299 Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
301 }
302 }
303}
304
305fn 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
346fn key_copy_backend(
354 alias: &str,
355 dst_backend: &str,
356 dst_file: Option<&PathBuf>,
357 dst_passphrase: Option<&str>,
358) -> Result<()> {
359 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 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 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 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}