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::{IdentityDID, KeyAlias, 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 local cryptographic keys in secure storage (list, import, export, delete)."
21)]
22pub struct KeyCommand {
23 #[command(subcommand)]
24 pub command: KeySubcommand,
25}
26
27#[derive(Subcommand, Debug, Clone)]
28pub enum KeySubcommand {
29 List,
31
32 Export {
34 #[arg(long, help = "Local alias of the key to export.")]
36 alias: String,
37
38 #[arg(
40 long,
41 help = "Passphrase to decrypt the key (needed for 'pem'/'pub' formats)."
42 )]
43 passphrase: String,
44
45 #[arg(
47 long,
48 help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
49 )]
50 format: ExportFormat,
51 },
52
53 Delete {
55 #[arg(long, help = "Local alias of the key to remove.")]
57 alias: String,
58 },
59
60 Import {
62 #[arg(long, help = "Local alias to assign to the imported key.")]
64 alias: String,
65
66 #[arg(
68 long,
69 value_parser, help = "Path to the file containing the raw 32-byte Ed25519 seed."
71 )]
72 seed_file: PathBuf,
73
74 #[arg(
76 long,
77 help = "Controller DID (e.g., did:key:...) to associate with the imported key."
78 )]
79 controller_did: String,
80 },
81
82 CopyBackend {
98 #[arg(long)]
100 alias: String,
101
102 #[arg(long)]
104 dst_backend: String,
105
106 #[arg(long)]
108 dst_file: Option<PathBuf>,
109
110 #[arg(long)]
113 dst_passphrase: Option<String>,
114 },
115}
116
117pub fn handle_key(cmd: KeyCommand) -> Result<()> {
118 match cmd.command {
119 KeySubcommand::List => key_list(),
120 KeySubcommand::Export {
121 alias,
122 passphrase,
123 format,
124 } => key_export(&alias, &passphrase, format),
125 KeySubcommand::Delete { alias } => key_delete(&alias),
126 KeySubcommand::Import {
127 alias,
128 seed_file,
129 controller_did,
130 } => {
131 let identity_did = IdentityDID::new(controller_did);
132 key_import(&alias, &seed_file, &identity_did)
133 }
134 KeySubcommand::CopyBackend {
135 alias,
136 dst_backend,
137 dst_file,
138 dst_passphrase,
139 } => key_copy_backend(
140 &alias,
141 &dst_backend,
142 dst_file.as_ref(),
143 dst_passphrase.as_deref(),
144 ),
145 }
146}
147
148#[derive(Debug, Serialize)]
150struct KeyListResponse {
151 backend: String,
152 aliases: Vec<String>,
153 count: usize,
154}
155
156fn key_list() -> Result<()> {
158 let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
159 let backend_name = keychain.backend_name().to_string();
160
161 let aliases = match keychain.list_aliases() {
162 Ok(a) => a,
163 Err(AgentError::SecurityError(msg))
164 if cfg!(target_os = "macos") && msg.contains("-25300") =>
165 {
166 Vec::new()
168 }
169 Err(e) => return Err(e.into()),
170 };
171
172 if is_json_mode() {
173 let alias_strings: Vec<String> = aliases.iter().map(|a| a.to_string()).collect();
174 let count = alias_strings.len();
175 let response = JsonResponse::success(
176 "key list",
177 KeyListResponse {
178 backend: backend_name,
179 aliases: alias_strings,
180 count,
181 },
182 );
183 response.print()?;
184 } else {
185 eprintln!("Using keychain backend: {}", backend_name);
187
188 if aliases.is_empty() {
189 println!("No keys found in keychain for this application.");
190 } else {
191 println!("Stored keys:");
192 for alias in aliases {
193 println!("- {}", alias);
194 }
195 }
196 }
197
198 Ok(())
199}
200
201#[inline]
203fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> {
204 let c_alias = CString::new(alias).context("Alias contains null byte")?;
205 let c_passphrase = CString::new(passphrase).context("Passphrase contains null byte")?;
206
207 match format {
208 ExportFormat::Pem => {
209 let ptr = unsafe {
210 ffi::ffi_export_private_key_openssh(c_alias.as_ptr(), c_passphrase.as_ptr())
211 };
212 if ptr.is_null() {
213 anyhow::bail!(
214 "â Failed to export PEM private key (check alias/passphrase or logs)"
215 );
216 }
217 let pem_string = unsafe {
218 let c_str = std::ffi::CStr::from_ptr(ptr);
220 let rust_str = c_str
221 .to_str()
222 .context("Failed to convert PEM FFI string to UTF-8")?
223 .to_owned(); ffi::ffi_free_str(ptr); rust_str
226 };
227 println!("{}", pem_string); }
229 ExportFormat::Pub => {
230 let ptr = unsafe {
231 ffi::ffi_export_public_key_openssh(c_alias.as_ptr(), c_passphrase.as_ptr())
232 };
233 if ptr.is_null() {
234 anyhow::bail!("â Failed to export public key (check alias/passphrase or logs)");
235 }
236 let pub_string = unsafe {
237 let c_str = std::ffi::CStr::from_ptr(ptr);
239 let rust_str = c_str
240 .to_str()
241 .context("Failed to convert Pubkey FFI string to UTF-8")?
242 .to_owned();
243 ffi::ffi_free_str(ptr);
244 rust_str
245 };
246 println!("{}", pub_string);
247 }
248 ExportFormat::Enc => {
249 let mut out_len: usize = 0;
250 let buf_ptr = unsafe { ffi::ffi_export_encrypted_key(c_alias.as_ptr(), &mut out_len) };
251 if buf_ptr.is_null() {
252 anyhow::bail!(
253 "â Failed to export encrypted private key (key not found or FFI error)"
254 );
255 }
256 let slice_data = unsafe {
257 let slice = std::slice::from_raw_parts(buf_ptr, out_len);
259 let data = slice.to_vec(); ffi::ffi_free_bytes(buf_ptr, out_len); data
262 };
263 println!("{}", hex::encode(slice_data)); }
265 }
266
267 Ok(())
268}
269
270fn key_delete(alias: &str) -> Result<()> {
272 let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
273 eprintln!("đ Using keychain backend: {}", keychain.backend_name());
274
275 match keychain.delete_key(&KeyAlias::new_unchecked(alias)) {
276 Ok(_) => {
277 println!("đī¸ Removed key alias '{}'", alias); Ok(())
279 }
280 Err(AgentError::KeyNotFound) => {
281 eprintln!("âšī¸ Key alias '{}' not found, nothing to remove.", alias);
282 Ok(()) }
284 Err(err) => {
285 Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
287 }
288 }
289}
290
291fn key_import(alias: &str, seed_file_path: &PathBuf, controller_did: &IdentityDID) -> Result<()> {
293 let seed_bytes = fs::read(seed_file_path)
294 .with_context(|| format!("Failed to read seed file: {:?}", seed_file_path))?;
295 if seed_bytes.len() != 32 {
296 return Err(anyhow!(
297 "Seed file must contain exactly 32 bytes, found {}.",
298 seed_bytes.len()
299 ));
300 }
301 let seed: [u8; 32] = seed_bytes.try_into().expect("validated 32 bytes above");
302 let seed = Zeroizing::new(seed);
303
304 let passphrase = if let Ok(env_pass) = std::env::var("AUTHS_PASSPHRASE") {
305 Zeroizing::new(env_pass)
306 } else {
307 Zeroizing::new(
308 rpassword::prompt_password(format!(
309 "Enter passphrase to encrypt the key '{}': ",
310 alias
311 ))
312 .context("Failed to read passphrase")?,
313 )
314 };
315 if passphrase.is_empty() {
316 return Err(anyhow!("Passphrase cannot be empty."));
317 }
318
319 let keychain = get_platform_keychain()?;
320 let result =
321 auths_sdk::keys::import_seed(&seed, &passphrase, alias, controller_did, keychain.as_ref())
322 .with_context(|| format!("Failed to import key '{alias}'"))?;
323
324 println!(
325 "Imported key '{}' (public key: {})",
326 result.alias,
327 hex::encode(result.public_key)
328 );
329 Ok(())
330}
331
332fn key_copy_backend(
340 alias: &str,
341 dst_backend: &str,
342 dst_file: Option<&PathBuf>,
343 dst_passphrase: Option<&str>,
344) -> Result<()> {
345 let src_keychain = get_platform_keychain()?;
347 eprintln!("Source backend: {}", src_keychain.backend_name());
348
349 let key_alias = KeyAlias::new_unchecked(alias);
350 let (identity_did, mut encrypted_key_data) = src_keychain
351 .load_key(&key_alias)
352 .with_context(|| format!("Key '{}' not found in source keychain", alias))?;
353
354 let dst_storage: Box<dyn KeyStorage> = match dst_backend.to_lowercase().as_str() {
356 "file" => {
357 let path = dst_file
358 .ok_or_else(|| anyhow!("--dst-file is required when --dst-backend is 'file'"))?;
359 let storage = EncryptedFileStorage::with_path(path.clone())
360 .context("Failed to create destination file storage")?;
361 let password: Zeroizing<String> = dst_passphrase
364 .map(|s| Zeroizing::new(s.to_string()))
365 .or_else(|| std::env::var("AUTHS_PASSPHRASE").ok().map(Zeroizing::new))
366 .ok_or_else(|| {
367 anyhow!(
368 "Passphrase required for file backend. \
369 Use --dst-passphrase or set the AUTHS_PASSPHRASE env var."
370 )
371 })?;
372 storage.set_password(password);
373 Box::new(storage)
374 }
375 other => {
376 encrypted_key_data.zeroize();
377 return Err(anyhow!(
378 "Unknown destination backend '{}'. Supported values: file",
379 other
380 ));
381 }
382 };
383
384 eprintln!("Destination backend: {}", dst_storage.backend_name());
385
386 let result = dst_storage
387 .store_key(&key_alias, &identity_did, &encrypted_key_data)
388 .with_context(|| format!("Failed to store key '{}' in destination backend", alias));
389
390 encrypted_key_data.zeroize();
392
393 result?;
394 eprintln!(
395 "â Copied key '{}' ({}) to {}",
396 alias, identity_did, dst_backend
397 );
398 Ok(())
399}
400
401use crate::commands::executable::ExecutableCommand;
402use crate::config::CliConfig;
403
404impl ExecutableCommand for KeyCommand {
405 fn execute(&self, _ctx: &CliConfig) -> Result<()> {
406 handle_key(self.clone())
407 }
408}