use anyhow::{bail, Result};
use base64::Engine;
use clap::{Parser, Subcommand};
use crate::keys::alias;
use crate::keys::group;
use crate::keys::identity::{format_pubkey_file, EnsealIdentity, TrustedKey};
use crate::keys::store::KeyStore;
use crate::ui::display;
#[derive(Parser)]
pub struct KeysArgs {
#[command(subcommand)]
pub command: KeysCommand,
}
#[derive(Subcommand)]
pub enum KeysCommand {
Init,
Export,
Import {
file: String,
#[arg(long)]
yes: bool,
},
List,
Remove {
identity: String,
},
Fingerprint,
Alias {
name: String,
identity: String,
},
Group {
#[command(subcommand)]
command: GroupCommand,
},
}
#[derive(Subcommand)]
pub enum GroupCommand {
Create {
name: String,
},
Add {
group: String,
identity: String,
},
Remove {
group: String,
identity: String,
},
List {
name: Option<String>,
},
Delete {
name: String,
},
}
pub fn run(args: KeysArgs) -> Result<()> {
match args.command {
KeysCommand::Init => cmd_init(),
KeysCommand::Export => cmd_export(),
KeysCommand::Import { file, yes } => cmd_import(&file, yes),
KeysCommand::List => cmd_list(),
KeysCommand::Remove { identity } => cmd_remove(&identity),
KeysCommand::Fingerprint => cmd_fingerprint(),
KeysCommand::Alias { name, identity } => cmd_alias(&name, &identity),
KeysCommand::Group { command } => cmd_group(command),
}
}
fn cmd_init() -> Result<()> {
let store = KeyStore::open()?;
if store.is_initialized() {
display::warning(
"keys already initialized. Use 'enseal keys export' to view your public key.",
);
return Ok(());
}
let identity = EnsealIdentity::generate();
identity.save(&store)?;
display::ok("keypair generated");
println!();
println!(" fingerprint: {}", identity.fingerprint());
println!(" keys stored in: {}", store.keys_dir().display());
println!();
println!("Share your public key with: enseal keys export");
Ok(())
}
fn cmd_export() -> Result<()> {
let store = KeyStore::open()?;
let identity = EnsealIdentity::load(&store)?;
let age_pub = identity.age_recipient.to_string();
let sign_pub = base64::engine::general_purpose::STANDARD
.encode(identity.signing_key.verifying_key().to_bytes());
let hostname = username_or_unknown();
let content = format_pubkey_file(&hostname, &age_pub, &sign_pub);
print!("{}", content);
Ok(())
}
fn cmd_import(file: &str, skip_confirm: bool) -> Result<()> {
let store = KeyStore::open()?;
let content = std::fs::read_to_string(file)
.map_err(|e| anyhow::anyhow!("failed to read '{}': {}", file, e))?;
let path = std::path::Path::new(file);
let identity_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
crate::keys::store::validate_identity_name(identity_name)?;
let trusted = TrustedKey::parse(identity_name, &content)?;
println!("Importing public key:");
println!(" identity: {}", identity_name);
println!(" fingerprint: {}", trusted.fingerprint());
println!();
if !skip_confirm && !confirm("Trust this key?")? {
println!("import cancelled");
return Ok(());
}
store.ensure_dirs()?;
let dest = store.trusted_key_path(identity_name)?;
std::fs::write(&dest, &content)?;
display::ok(&format!("imported key for '{}'", identity_name));
Ok(())
}
fn cmd_list() -> Result<()> {
let store = KeyStore::open()?;
if store.is_initialized() {
let identity = EnsealIdentity::load(&store)?;
println!("Own key:");
println!(" fingerprint: {}", identity.fingerprint());
println!();
}
let trusted = store.list_trusted()?;
if trusted.is_empty() {
println!("No trusted keys. Import with: enseal keys import <file>");
} else {
println!("Trusted keys:");
for name in &trusted {
match TrustedKey::load(&store, name) {
Ok(key) => println!(" {} ({})", name, key.fingerprint()),
Err(_) => println!(" {} (error reading key)", name),
}
}
}
let aliases = alias::list(&store)?;
if !aliases.is_empty() {
println!();
println!("Aliases:");
for (name, identity) in &aliases {
println!(" {} -> {}", name, identity);
}
}
let groups = group::list_groups(&store)?;
if !groups.is_empty() {
println!();
println!("Groups:");
for (name, entry) in &groups {
if entry.members.is_empty() {
println!(" {} (empty)", name);
} else {
println!(" {} ({})", name, entry.members.join(", "));
}
}
}
Ok(())
}
fn cmd_remove(identity: &str) -> Result<()> {
crate::keys::store::validate_identity_name(identity)?;
let store = KeyStore::open()?;
let path = store.trusted_key_path(identity)?;
if !path.exists() {
bail!("no trusted key found for '{}'", identity);
}
std::fs::remove_file(&path)?;
let aliases = alias::list(&store)?;
for (name, target) in &aliases {
if target == identity {
let _ = alias::remove(&store, name);
display::warning(&format!(
"removed alias '{}' (pointed to removed key)",
name
));
}
}
let groups = group::list_groups(&store)?;
for (name, entry) in &groups {
if entry.members.contains(&identity.to_string()) {
let _ = group::remove_member(&store, name, identity);
display::warning(&format!("removed '{}' from group '{}'", identity, name));
}
}
display::ok(&format!("removed trusted key for '{}'", identity));
Ok(())
}
fn cmd_fingerprint() -> Result<()> {
let store = KeyStore::open()?;
let identity = EnsealIdentity::load(&store)?;
println!("{}", identity.fingerprint());
Ok(())
}
fn cmd_alias(name: &str, identity: &str) -> Result<()> {
let store = KeyStore::open()?;
alias::set(&store, name, identity)?;
display::ok(&format!("alias '{}' -> '{}'", name, identity));
Ok(())
}
fn cmd_group(command: GroupCommand) -> Result<()> {
let store = KeyStore::open()?;
match command {
GroupCommand::Create { name } => {
group::create(&store, &name)?;
display::ok(&format!("created group '{}'", name));
}
GroupCommand::Add {
group: grp,
identity,
} => {
if group::add_member(&store, &grp, &identity)? {
display::ok(&format!("added '{}' to group '{}'", identity, grp));
} else {
display::warning(&format!("'{}' is already a member of '{}'", identity, grp));
}
}
GroupCommand::Remove {
group: grp,
identity,
} => {
if group::remove_member(&store, &grp, &identity)? {
display::ok(&format!("removed '{}' from group '{}'", identity, grp));
} else {
display::warning(&format!("'{}' is not a member of '{}'", identity, grp));
}
}
GroupCommand::List { name } => {
if let Some(name) = name {
match group::get_members(&store, &name)? {
Some(members) => {
println!("Group '{}':", name);
if members.is_empty() {
println!(" (no members)");
} else {
for m in &members {
println!(" {}", m);
}
}
}
None => {
bail!("group '{}' does not exist", name);
}
}
} else {
let groups = group::list_groups(&store)?;
if groups.is_empty() {
println!("No groups. Create one with: enseal keys group create <name>");
} else {
println!("Groups:");
for (name, entry) in &groups {
println!(" {} ({} members)", name, entry.members.len());
}
}
}
}
GroupCommand::Delete { name } => {
if group::delete_group(&store, &name)? {
display::ok(&format!("deleted group '{}'", name));
} else {
bail!("group '{}' does not exist", name);
}
}
}
Ok(())
}
fn username_or_unknown() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
fn confirm(prompt: &str) -> Result<bool> {
if !is_terminal::is_terminal(std::io::stdin()) {
bail!("cannot prompt for confirmation in non-interactive mode. Use --yes to skip");
}
use dialoguer::Confirm;
let result = Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()?;
Ok(result)
}