use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::SystemTime;
use anyhow::{Context, Result, bail};
use astrid_core::PrincipalId;
use astrid_core::dirs::AstridHome;
use clap::{Args, Subcommand};
use colored::Colorize;
use ed25519_dalek::SigningKey;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::theme::Theme;
const MAX_NAME_LEN: usize = 64;
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum KeypairCommand {
Generate(GenerateArgs),
List(ListArgs),
Show(ShowArgs),
Pubkey(PubkeyArgs),
Delete(DeleteArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct GenerateArgs {
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub note: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub raw: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ShowArgs {
pub name: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct PubkeyArgs {
pub name: String,
#[arg(long, value_enum, default_value_t = PubkeyFormat::Hex)]
pub format: PubkeyFormat,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DeleteArgs {
pub name: String,
#[arg(long)]
pub yes: bool,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
pub(crate) enum PubkeyFormat {
Hex,
Openssh,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct KeyMeta {
schema_version: u32,
fingerprint: String,
created_at_epoch: u64,
backend: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bound_principal: Option<String>,
}
const META_SCHEMA_VERSION: u32 = 1;
fn local_keys_dir() -> Result<PathBuf> {
let home = AstridHome::resolve().context("resolve $ASTRID_HOME for keypair store")?;
let dir = home.keys_dir().join("local");
fs::create_dir_all(&dir).with_context(|| format!("create keypair dir at {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
}
Ok(dir)
}
struct KeyPaths {
private: PathBuf,
public_hex: PathBuf,
meta: PathBuf,
}
impl KeyPaths {
fn new(name: &str) -> Result<Self> {
let dir = local_keys_dir()?;
Ok(Self {
private: dir.join(format!("{name}.ed25519")),
public_hex: dir.join(format!("{name}.pub.hex")),
meta: dir.join(format!("{name}.meta.toml")),
})
}
fn exists_any(&self) -> bool {
self.private.exists() || self.public_hex.exists() || self.meta.exists()
}
}
pub(crate) fn run(command: KeypairCommand) -> Result<ExitCode> {
match command {
KeypairCommand::Generate(args) => run_generate(args),
KeypairCommand::List(args) => run_list(&args),
KeypairCommand::Show(args) => run_show(&args),
KeypairCommand::Pubkey(args) => run_pubkey(&args),
KeypairCommand::Delete(args) => run_delete(&args),
}
}
fn run_generate(args: GenerateArgs) -> Result<ExitCode> {
let name = args.name.unwrap_or_else(default_name);
validate_name(&name)?;
let paths = KeyPaths::new(&name)?;
if paths.exists_any() && !args.force {
bail!(
"keypair {name:?} already exists at {} — pass --force to overwrite",
paths.private.display()
);
}
let mut secret_bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret_bytes);
let signing = SigningKey::from_bytes(&secret_bytes);
secret_bytes = [0u8; 32]; let _ = secret_bytes;
let verifying = signing.verifying_key();
let pub_hex = hex::encode(verifying.to_bytes());
let fingerprint = fingerprint_pubkey(&pub_hex);
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let meta = KeyMeta {
schema_version: META_SCHEMA_VERSION,
fingerprint: fingerprint.clone(),
created_at_epoch: now,
backend: "file".to_string(),
note: args.note,
bound_principal: None,
};
write_secret(&paths.private, signing.as_bytes())?;
write_public(&paths.public_hex, &pub_hex)?;
write_meta(&paths.meta, &meta)?;
if args.raw {
println!("{pub_hex}");
} else {
println!(
"{} keypair {} (fingerprint: {})",
Theme::success("generated"),
name.bold(),
fingerprint,
);
println!(" public key (hex): {pub_hex}");
println!(" private path: {}", paths.private.display());
println!(" next step: astrid invite redeem <TOKEN> --keypair {name}");
}
Ok(ExitCode::SUCCESS)
}
fn run_list(args: &ListArgs) -> Result<ExitCode> {
let entries = scan_keys()?;
if args.json {
let serializable: Vec<_> = entries.iter().map(KeyEntry::to_summary).collect();
println!("{}", serde_json::to_string_pretty(&serializable)?);
return Ok(ExitCode::SUCCESS);
}
if entries.is_empty() {
println!("{}", Theme::dimmed("no local keypairs"));
return Ok(ExitCode::SUCCESS);
}
println!(
"{:<24} {:<64} {:<10} BOUND PRINCIPAL",
"NAME", "FINGERPRINT", "CREATED",
);
for entry in entries {
let bound = entry.meta.bound_principal.as_deref().unwrap_or("-");
println!(
"{:<24} {:<64} {:<10} {}",
entry.name, entry.meta.fingerprint, entry.meta.created_at_epoch, bound,
);
}
Ok(ExitCode::SUCCESS)
}
fn run_show(args: &ShowArgs) -> Result<ExitCode> {
validate_name(&args.name)?;
let paths = KeyPaths::new(&args.name)?;
if !paths.meta.exists() {
bail!("keypair {:?} not found", args.name);
}
let meta = read_meta(&paths.meta)?;
let pub_hex = read_public(&paths.public_hex).unwrap_or_else(|_| "<missing>".to_string());
println!("name: {}", args.name);
println!("fingerprint: {}", meta.fingerprint);
println!("created (epoch): {}", meta.created_at_epoch);
println!("backend: {}", meta.backend);
if let Some(p) = &meta.bound_principal {
println!("bound principal: {p}");
}
if let Some(n) = &meta.note {
println!("note: {n}");
}
println!("public key (hex): {pub_hex}");
println!("private path: {}", paths.private.display());
Ok(ExitCode::SUCCESS)
}
fn run_pubkey(args: &PubkeyArgs) -> Result<ExitCode> {
validate_name(&args.name)?;
let paths = KeyPaths::new(&args.name)?;
let pub_hex = read_public(&paths.public_hex)
.with_context(|| format!("read public key for {:?}", args.name))?;
match args.format {
PubkeyFormat::Hex => println!("{pub_hex}"),
PubkeyFormat::Openssh => {
let bytes = hex::decode(pub_hex.trim()).context("decode public key hex")?;
println!("{}", encode_openssh_ed25519(&bytes));
},
}
Ok(ExitCode::SUCCESS)
}
fn run_delete(args: &DeleteArgs) -> Result<ExitCode> {
validate_name(&args.name)?;
let paths = KeyPaths::new(&args.name)?;
if !paths.exists_any() {
bail!("keypair {:?} not found", args.name);
}
if !args.yes {
eprintln!(
"{} will delete keypair {:?}. Re-run with --yes to confirm.",
Theme::warning("⚠"),
args.name
);
return Ok(ExitCode::from(1));
}
for p in [&paths.private, &paths.public_hex, &paths.meta] {
if p.exists() {
fs::remove_file(p).with_context(|| format!("remove {}", p.display()))?;
}
}
println!("{} keypair {:?}", Theme::success("deleted"), args.name);
Ok(ExitCode::SUCCESS)
}
pub(crate) fn load_public_key_hex(name: &str) -> Result<String> {
validate_name(name)?;
let paths = KeyPaths::new(name)?;
read_public(&paths.public_hex).with_context(|| format!("read public key for {name:?}"))
}
pub(crate) fn record_binding(name: &str, principal: &PrincipalId) -> Result<()> {
validate_name(name)?;
let paths = KeyPaths::new(name)?;
if !paths.meta.exists() {
return Ok(());
}
let mut meta = read_meta(&paths.meta)?;
meta.bound_principal = Some(principal.to_string());
write_meta(&paths.meta, &meta)?;
Ok(())
}
fn write_secret(path: &Path, bytes: &[u8; 32]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
drop(f);
fs::rename(&tmp, path).inspect_err(|_| {
let _ = fs::remove_file(&tmp);
})?;
}
#[cfg(not(unix))]
{
fs::write(path, bytes)?;
}
Ok(())
}
fn write_public(path: &Path, hex: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
fs::write(&tmp, format!("{hex}\n").as_bytes())
.with_context(|| format!("write public key to {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644))?;
}
fs::rename(&tmp, path).inspect_err(|_| {
let _ = fs::remove_file(&tmp);
})?;
Ok(())
}
fn write_meta(path: &Path, meta: &KeyMeta) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(meta).context("serialise keypair meta")?;
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
fs::write(&tmp, text.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
}
fs::rename(&tmp, path).inspect_err(|_| {
let _ = fs::remove_file(&tmp);
})?;
Ok(())
}
fn read_public(path: &Path) -> Result<String> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let trimmed = raw.trim().to_string();
if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("{} is not a 64-char hex public key", path.display());
}
Ok(trimmed)
}
fn read_meta(path: &Path) -> Result<KeyMeta> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let meta: KeyMeta =
toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
if meta.schema_version > META_SCHEMA_VERSION {
bail!(
"keypair {} was written by a newer astrid (schema {} > {})",
path.display(),
meta.schema_version,
META_SCHEMA_VERSION
);
}
Ok(meta)
}
struct KeyEntry {
name: String,
meta: KeyMeta,
}
impl KeyEntry {
fn to_summary(&self) -> serde_json::Value {
serde_json::json!({
"name": self.name,
"fingerprint": self.meta.fingerprint,
"created_at_epoch": self.meta.created_at_epoch,
"backend": self.meta.backend,
"note": self.meta.note,
"bound_principal": self.meta.bound_principal,
})
}
}
fn scan_keys() -> Result<Vec<KeyEntry>> {
let Ok(dir) = local_keys_dir() else {
return Ok(Vec::new());
};
let read_dir = match fs::read_dir(&dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).context("list keypair dir"),
};
let mut out = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
let Some(name) = path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_suffix(".meta.toml"))
else {
continue;
};
match read_meta(&path) {
Ok(meta) => out.push(KeyEntry {
name: name.to_string(),
meta,
}),
Err(e) => {
tracing::warn!(name = %name, error = %e, "skipping unreadable keypair meta");
},
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("keypair name must not be empty");
}
if name.len() > MAX_NAME_LEN {
bail!(
"keypair name must be at most {MAX_NAME_LEN} chars (got {})",
name.len()
);
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
bail!("keypair name {name:?} contains invalid chars; only a-z, 0-9, '-' are allowed");
}
Ok(())
}
fn default_name() -> String {
use rand::RngCore;
let mut bytes = [0u8; 4];
rand::rngs::OsRng.fill_bytes(&mut bytes);
format!("key-{}", hex::encode(bytes))
}
fn fingerprint_pubkey(hex_pub: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("ed25519:{hex_pub}").as_bytes());
hex::encode(hasher.finalize())
}
fn encode_openssh_ed25519(pubkey: &[u8]) -> String {
use base64::Engine;
let mut blob = Vec::with_capacity(4 + 11 + 4 + 32);
let typ = b"ssh-ed25519";
blob.extend_from_slice(&u32::try_from(typ.len()).unwrap_or(0).to_be_bytes());
blob.extend_from_slice(typ);
blob.extend_from_slice(&u32::try_from(pubkey.len()).unwrap_or(0).to_be_bytes());
blob.extend_from_slice(pubkey);
format!(
"ssh-ed25519 {}",
base64::engine::general_purpose::STANDARD.encode(&blob)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_name_accepts_well_formed() {
validate_name("laptop").unwrap();
validate_name("a").unwrap();
validate_name("key-2026-05").unwrap();
validate_name(&"a".repeat(MAX_NAME_LEN)).unwrap();
}
#[test]
fn validate_name_rejects_bad_input() {
assert!(validate_name("").is_err());
assert!(validate_name("UPPER").is_err());
assert!(validate_name("has space").is_err());
assert!(validate_name("../etc/passwd").is_err());
assert!(validate_name(&"a".repeat(MAX_NAME_LEN + 1)).is_err());
}
#[test]
fn fingerprint_is_stable_and_distinct() {
let a = fingerprint_pubkey(&"a".repeat(64));
let b = fingerprint_pubkey(&"a".repeat(64));
let c = fingerprint_pubkey(&"b".repeat(64));
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 64);
}
#[test]
fn openssh_encoding_round_trips_against_a_known_vector() {
let pubkey = [0u8; 32];
let encoded = encode_openssh_ed25519(&pubkey);
assert!(encoded.starts_with("ssh-ed25519 "));
let body = encoded.trim_start_matches("ssh-ed25519 ");
assert_eq!(body.len(), 68);
}
}