use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use serde::Serialize;
use std::ffi::CString;
use std::fs;
use std::path::PathBuf;
use auths_core::api::ffi;
use auths_core::error::AgentError;
use auths_core::storage::encrypted_file::EncryptedFileStorage;
use auths_core::storage::keychain::{
IdentityDID, KeyAlias, KeyRole, KeyStorage, get_platform_keychain,
};
use zeroize::{Zeroize, Zeroizing};
use crate::core::types::ExportFormat;
use crate::ux::format::{JsonResponse, is_json_mode};
#[derive(Parser, Debug, Clone)]
#[command(
name = "key",
about = "Manage local cryptographic keys in secure storage (list, import, export, delete)."
)]
pub struct KeyCommand {
#[command(subcommand)]
pub command: KeySubcommand,
}
#[derive(Subcommand, Debug, Clone)]
pub enum KeySubcommand {
List,
Export {
#[arg(
long = "key-alias",
visible_alias = "alias",
help = "Local alias of the key to export."
)]
key_alias: String,
#[arg(
long,
help = "Passphrase to decrypt the key (needed for 'pem'/'pub' formats)."
)]
passphrase: String,
#[arg(
long,
help = "Export format: pem (OpenSSH private), pub (OpenSSH public), enc (raw encrypted bytes)."
)]
format: ExportFormat,
},
Delete {
#[arg(
long = "key-alias",
visible_alias = "alias",
help = "Local alias of the key to remove."
)]
key_alias: String,
},
Import {
#[arg(
long = "key-alias",
visible_alias = "alias",
help = "Local alias to assign to the imported key."
)]
key_alias: String,
#[arg(
long,
value_parser, // Add value_parser for PathBuf
help = "Path to the file containing the raw 32-byte Ed25519 seed."
)]
seed_file: PathBuf,
#[arg(
long,
help = "Controller DID (e.g., did:key:...) to associate with the imported key."
)]
controller_did: String,
},
CopyBackend {
#[arg(long = "key-alias", visible_alias = "alias")]
key_alias: String,
#[arg(long)]
dst_backend: String,
#[arg(long)]
dst_file: Option<PathBuf>,
#[arg(long)]
dst_passphrase: Option<String>,
},
}
pub fn handle_key(cmd: KeyCommand) -> Result<()> {
match cmd.command {
KeySubcommand::List => key_list(),
KeySubcommand::Export {
key_alias,
passphrase,
format,
} => key_export(&key_alias, &passphrase, format),
KeySubcommand::Delete { key_alias } => key_delete(&key_alias),
KeySubcommand::Import {
key_alias,
seed_file,
controller_did,
} => {
let identity_did = IdentityDID::new(controller_did);
key_import(&key_alias, &seed_file, &identity_did)
}
KeySubcommand::CopyBackend {
key_alias,
dst_backend,
dst_file,
dst_passphrase,
} => key_copy_backend(
&key_alias,
&dst_backend,
dst_file.as_ref(),
dst_passphrase.as_deref(),
),
}
}
#[derive(Debug, Serialize)]
struct KeyListResponse {
backend: String,
aliases: Vec<String>,
count: usize,
}
fn key_list() -> Result<()> {
let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
let backend_name = keychain.backend_name().to_string();
let aliases = match keychain.list_aliases() {
Ok(a) => a,
Err(AgentError::SecurityError(msg))
if cfg!(target_os = "macos") && msg.contains("-25300") =>
{
Vec::new()
}
Err(e) => return Err(e.into()),
};
if is_json_mode() {
let alias_strings: Vec<String> = aliases.iter().map(|a| a.to_string()).collect();
let count = alias_strings.len();
let response = JsonResponse::success(
"key list",
KeyListResponse {
backend: backend_name,
aliases: alias_strings,
count,
},
);
response.print()?;
} else {
eprintln!("Using keychain backend: {}", backend_name);
if aliases.is_empty() {
println!("No keys found in keychain for this application.");
} else {
println!("Stored keys:");
for alias in aliases {
println!("- {}", alias);
}
}
}
Ok(())
}
#[inline]
fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> {
let c_alias = CString::new(alias).context("Alias contains null byte")?;
let c_passphrase = CString::new(passphrase).context("Passphrase contains null byte")?;
match format {
ExportFormat::Pem => {
let ptr = unsafe {
ffi::ffi_export_private_key_openssh(c_alias.as_ptr(), c_passphrase.as_ptr())
};
if ptr.is_null() {
anyhow::bail!(
"❌ Failed to export PEM private key (check alias/passphrase or logs)"
);
}
let pem_string = unsafe {
let c_str = std::ffi::CStr::from_ptr(ptr);
let rust_str = c_str
.to_str()
.context("Failed to convert PEM FFI string to UTF-8")?
.to_owned(); ffi::ffi_free_str(ptr); rust_str
};
println!("{}", pem_string); }
ExportFormat::Pub => {
let ptr = unsafe {
ffi::ffi_export_public_key_openssh(c_alias.as_ptr(), c_passphrase.as_ptr())
};
if ptr.is_null() {
anyhow::bail!("❌ Failed to export public key (check alias/passphrase or logs)");
}
let pub_string = unsafe {
let c_str = std::ffi::CStr::from_ptr(ptr);
let rust_str = c_str
.to_str()
.context("Failed to convert Pubkey FFI string to UTF-8")?
.to_owned();
ffi::ffi_free_str(ptr);
rust_str
};
println!("{}", pub_string);
}
ExportFormat::Enc => {
let mut out_len: usize = 0;
let buf_ptr = unsafe { ffi::ffi_export_encrypted_key(c_alias.as_ptr(), &mut out_len) };
if buf_ptr.is_null() {
anyhow::bail!(
"❌ Failed to export encrypted private key (key not found or FFI error)"
);
}
let slice_data = unsafe {
let slice = std::slice::from_raw_parts(buf_ptr, out_len);
let data = slice.to_vec(); ffi::ffi_free_bytes(buf_ptr, out_len); data
};
println!("{}", hex::encode(slice_data)); }
}
Ok(())
}
fn key_delete(alias: &str) -> Result<()> {
let keychain: Box<dyn KeyStorage> = get_platform_keychain()?;
eprintln!("🔍 Using keychain backend: {}", keychain.backend_name());
match keychain.delete_key(&KeyAlias::new_unchecked(alias)) {
Ok(_) => {
println!("🗑️ Removed key alias '{}'", alias); Ok(())
}
Err(AgentError::KeyNotFound) => {
eprintln!("ℹ️ Key alias '{}' not found, nothing to remove.", alias);
Ok(()) }
Err(err) => {
Err(anyhow!(err)).context(format!("Failed to remove key '{}'", alias))
}
}
}
fn key_import(alias: &str, seed_file_path: &PathBuf, controller_did: &IdentityDID) -> Result<()> {
let seed_bytes = fs::read(seed_file_path)
.with_context(|| format!("Failed to read seed file: {:?}", seed_file_path))?;
if seed_bytes.len() != 32 {
return Err(anyhow!(
"Seed file must contain exactly 32 bytes, found {}.",
seed_bytes.len()
));
}
let seed: [u8; 32] = seed_bytes.try_into().expect("validated 32 bytes above");
let seed = Zeroizing::new(seed);
let passphrase = if let Ok(env_pass) = std::env::var("AUTHS_PASSPHRASE") {
Zeroizing::new(env_pass)
} else {
Zeroizing::new(
rpassword::prompt_password(format!(
"Enter passphrase to encrypt the key '{}': ",
alias
))
.context("Failed to read passphrase")?,
)
};
if passphrase.is_empty() {
return Err(anyhow!("Passphrase cannot be empty."));
}
let keychain = get_platform_keychain()?;
let result =
auths_sdk::keys::import_seed(&seed, &passphrase, alias, controller_did, keychain.as_ref())
.with_context(|| format!("Failed to import key '{alias}'"))?;
println!(
"Imported key '{}' (public key: {})",
result.alias,
hex::encode(result.public_key)
);
Ok(())
}
fn key_copy_backend(
alias: &str,
dst_backend: &str,
dst_file: Option<&PathBuf>,
dst_passphrase: Option<&str>,
) -> Result<()> {
let src_keychain = get_platform_keychain()?;
eprintln!("Source backend: {}", src_keychain.backend_name());
let key_alias = KeyAlias::new_unchecked(alias);
let (identity_did, _role, mut encrypted_key_data) = src_keychain
.load_key(&key_alias)
.with_context(|| format!("Key '{}' not found in source keychain", alias))?;
let dst_storage: Box<dyn KeyStorage> = match dst_backend.to_lowercase().as_str() {
"file" => {
let path = dst_file
.ok_or_else(|| anyhow!("--dst-file is required when --dst-backend is 'file'"))?;
let storage = EncryptedFileStorage::with_path(path.clone())
.context("Failed to create destination file storage")?;
let password: Zeroizing<String> = dst_passphrase
.map(|s| Zeroizing::new(s.to_string()))
.or_else(|| std::env::var("AUTHS_PASSPHRASE").ok().map(Zeroizing::new))
.ok_or_else(|| {
anyhow!(
"Passphrase required for file backend. \
Use --dst-passphrase or set the AUTHS_PASSPHRASE env var."
)
})?;
storage.set_password(password);
Box::new(storage)
}
other => {
encrypted_key_data.zeroize();
return Err(anyhow!(
"Unknown destination backend '{}'. Supported values: file",
other
));
}
};
eprintln!("Destination backend: {}", dst_storage.backend_name());
let result = dst_storage
.store_key(
&key_alias,
&identity_did,
KeyRole::Primary,
&encrypted_key_data,
)
.with_context(|| format!("Failed to store key '{}' in destination backend", alias));
encrypted_key_data.zeroize();
result?;
eprintln!(
"✓ Copied key '{}' ({}) to {}",
alias, identity_did, dst_backend
);
Ok(())
}
use crate::commands::executable::ExecutableCommand;
use crate::config::CliConfig;
impl ExecutableCommand for KeyCommand {
fn execute(&self, _ctx: &CliConfig) -> Result<()> {
handle_key(self.clone())
}
}