use std::fs;
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use archon_core::crypto::{public_key_from_seed, SEED_SIZE};
use archon_core::hexbytes::{pubkey_from_hex, seed_from_hex};
use archon_core::keycodec::{pkcs8_pem_to_seed, seed_to_pkcs8_pem};
use archon_core::keytext::encode_key;
use crate::keystore;
pub const USAGE: &str =
"usage: archon key <add <name> [--seed <hex>|--seed-file <file>|--pkcs8 <file>]|\
list [--json]|rm <name> [--force]|default [<name>]|export <name> --reveal --out <file>>\n \
the password-protected seed store; keys live in $ARCHON_HOME/keys (default ~/.archon). \
Password: interactive prompt, or ARCHON_KEY_PASSWORD / --password-fd <n>, never argv.";
fn archon_home() -> Result<PathBuf, String> {
if let Ok(h) = std::env::var("ARCHON_HOME") {
if !h.is_empty() {
return Ok(PathBuf::from(h));
}
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "could not resolve the home directory (set ARCHON_HOME)".to_string())?;
Ok(PathBuf::from(home).join(".archon"))
}
fn store_dir() -> Result<PathBuf, String> {
Ok(archon_home()?.join("keys"))
}
fn key_path(name: &str) -> Result<PathBuf, String> {
keystore::validate_name(name)?;
Ok(store_dir()?.join(name))
}
fn write_key_file(path: &Path, blob: &[u8]) -> Result<(), String> {
let dir = path
.parent()
.ok_or_else(|| "the key path has no directory".to_string())?;
fs::create_dir_all(dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
let tmp = dir.join(format!(".tmp-{}", std::process::id()));
{
let mut f = fs::File::create(&tmp)
.map_err(|e| format!("could not create {}: {e}", tmp.display()))?;
f.write_all(blob)
.map_err(|e| format!("could not write: {e}"))?;
f.sync_all().map_err(|e| format!("could not flush: {e}"))?;
}
set_owner_only(&tmp)?;
fs::rename(&tmp, path).map_err(|e| {
let _ = fs::remove_file(&tmp);
format!("could not place {}: {e}", path.display())
})
}
#[cfg(unix)]
fn set_owner_only(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|e| format!("could not set permissions: {e}"))
}
#[cfg(not(unix))]
fn set_owner_only(_path: &Path) -> Result<(), String> {
Ok(())
}
fn list_key_names() -> Result<Vec<String>, String> {
let dir = store_dir()?;
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Ok(Vec::new()),
};
let mut names: Vec<String> = entries
.filter_map(Result::ok)
.filter(|e| e.path().is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with(".tmp-"))
.collect();
names.sort();
Ok(names)
}
pub fn take_password_fd(args: &[String]) -> Result<(Vec<String>, Option<i32>), String> {
let mut rest = Vec::with_capacity(args.len());
let mut fd = None;
let mut i = 0;
while i < args.len() {
if args[i] == "--password-fd" {
let v = args
.get(i + 1)
.ok_or_else(|| "flag \"--password-fd\" needs a value".to_string())?;
let n: i32 = v
.parse()
.map_err(|_| format!("--password-fd: {v:?} is not a file descriptor"))?;
fd = Some(n);
i += 2;
continue;
}
rest.push(args[i].clone());
i += 1;
}
Ok((rest, fd))
}
fn trim_newline(mut b: Vec<u8>) -> Vec<u8> {
while matches!(b.last(), Some(b'\n') | Some(b'\r')) {
b.pop();
}
b
}
pub fn read_password(fd: Option<i32>, confirm: bool) -> Result<Vec<u8>, String> {
if let Some(n) = fd {
let mut buf = Vec::new();
read_from_fd(n, &mut buf)?;
return Ok(trim_newline(buf));
}
if let Ok(v) = std::env::var("ARCHON_KEY_PASSWORD") {
return Ok(v.into_bytes());
}
if !std::io::stdin().is_terminal() {
return Err(
"no password: stdin is not a terminal — set ARCHON_KEY_PASSWORD or pass --password-fd <n>"
.to_string(),
);
}
let first = rpassword::prompt_password("password: ")
.map_err(|e| format!("could not read the password: {e}"))?;
if confirm {
let second = rpassword::prompt_password("password (again): ")
.map_err(|e| format!("could not read the password: {e}"))?;
if first != second {
return Err("the two passwords differ".to_string());
}
}
Ok(first.into_bytes())
}
#[cfg(unix)]
fn read_from_fd(n: i32, buf: &mut Vec<u8>) -> Result<(), String> {
let path = format!("/dev/fd/{n}");
let mut f =
fs::File::open(&path).map_err(|e| format!("--password-fd: could not open {path}: {e}"))?;
refuse_loose_password_file(&f)?;
f.read_to_end(buf)
.map_err(|e| format!("--password-fd: could not read: {e}"))?;
Ok(())
}
#[cfg(unix)]
fn refuse_loose_password_file(f: &fs::File) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = f.metadata() else {
return Ok(());
};
if !meta.is_file() {
return Ok(());
}
let perm = meta.permissions().mode() & 0o777;
if perm & 0o077 != 0 {
return Err(format!(
"--password-fd: the password file is readable by others (mode {perm:04o}); chmod 600 it"
));
}
Ok(())
}
#[cfg(not(unix))]
fn read_from_fd(n: i32, buf: &mut Vec<u8>) -> Result<(), String> {
if n != 0 {
return Err(format!(
"--password-fd {n} is not available on this platform; use 0 (stdin) or ARCHON_KEY_PASSWORD"
));
}
std::io::stdin()
.read_to_end(buf)
.map_err(|e| format!("--password-fd: could not read: {e}"))?;
Ok(())
}
pub fn require_named_key(name: &str) -> Result<(), String> {
let path = key_path(name)?;
if !path.exists() {
return Err(format!("no key named {name:?} in archon's store"));
}
Ok(())
}
pub fn read_default_key_name() -> Result<Option<String>, String> {
let pointer = archon_home()?.join("default");
let Ok(raw) = fs::read_to_string(&pointer) else {
return Ok(None);
};
let name = raw.trim();
if name.is_empty() {
return Ok(None);
}
Ok(Some(name.to_string()))
}
pub fn unlock_named_key(name: &str, fd: Option<i32>) -> Result<[u8; SEED_SIZE], String> {
let path = key_path(name)?;
let raw = fs::read(&path).map_err(|_| format!("no key named {name:?} in archon's store"))?;
let password = read_password(fd, false)?;
keystore::open(&raw, &password)
}
pub fn seal_and_write(path: &Path, seed: &[u8], password: &[u8]) -> Result<String, String> {
let mut salt = [0u8; keystore::SALT_SIZE];
let mut nonce = [0u8; keystore::NONCE_SIZE];
getrandom::fill(&mut salt).map_err(|e| format!("could not read OS randomness: {e}"))?;
getrandom::fill(&mut nonce).map_err(|e| format!("could not read OS randomness: {e}"))?;
let blob = keystore::seal(
seed,
password,
&salt,
&nonce,
keystore::KeyParams::default(),
)?;
write_key_file(path, &blob)?;
let mut fixed = [0u8; SEED_SIZE];
fixed.copy_from_slice(seed);
Ok(encode_key(&public_key_from_seed(&fixed)))
}
pub fn store_generated(name: &str, seed: &[u8], fd: Option<i32>) -> Result<(), String> {
keystore::validate_name(name)?;
let path = key_path(name)?;
if path.exists() {
return Err(format!(
"a key named {name:?} already exists; remove it first (archon key rm {name})"
));
}
let password = read_password(fd, true)?;
let principal = seal_and_write(&path, seed, &password)?;
println!("generated and stored {name} ({principal}).");
Ok(())
}
fn seed_from_hexish(s: &str) -> Result<[u8; SEED_SIZE], String> {
let s = s.trim();
match s.len() {
64 => seed_from_hex(s),
128 => {
let seed = seed_from_hex(&s[..64])?;
let claimed = pubkey_from_hex(&s[64..])?;
if public_key_from_seed(&seed) != claimed {
return Err(
"the public half does not match the seed: this is not a consistent private key"
.to_string(),
);
}
Ok(seed)
}
n => Err(format!(
"expected 64 hex characters (a seed) or 128 (seed then public key), got {n}"
)),
}
}
pub fn run_add(args: &[String]) -> Result<(), String> {
let name = args.first().ok_or_else(|| USAGE.to_string())?.clone();
keystore::validate_name(&name)?;
let (rest, fd) = take_password_fd(&args[1..])?;
let (mut seed_hex, mut seed_file, mut pkcs8_file) = (None, None, None);
let mut i = 0;
while i < rest.len() {
let value = rest
.get(i + 1)
.ok_or_else(|| format!("flag {:?} needs a value\n{USAGE}", rest[i]))?;
match rest[i].as_str() {
"--seed" => seed_hex = Some(value.clone()),
"--seed-file" => seed_file = Some(value.clone()),
"--pkcs8" => pkcs8_file = Some(value.clone()),
other => return Err(format!("unknown flag {other:?}\n{USAGE}")),
}
i += 2;
}
if [&seed_hex, &seed_file, &pkcs8_file]
.iter()
.filter(|o| o.is_some())
.count()
> 1
{
return Err(format!(
"--seed, --seed-file and --pkcs8 are mutually exclusive\n{USAGE}"
));
}
let path = key_path(&name)?;
if path.exists() {
return Err(format!(
"a key named {name:?} already exists; remove it first (archon key rm {name})"
));
}
let (seed, from) = match (&seed_hex, &seed_file, &pkcs8_file) {
(Some(h), _, _) => (
seed_from_hexish(h).map_err(|e| format!("--seed: {e}"))?,
Some("--seed".to_string()),
),
(_, Some(f), _) => {
let raw = fs::read_to_string(f).map_err(|e| format!("could not read {f:?}: {e}"))?;
(
seed_from_hexish(&raw).map_err(|e| format!("{f}: {e}"))?,
Some(f.clone()),
)
}
(_, _, Some(f)) => {
let raw = fs::read_to_string(f).map_err(|e| format!("could not read {f:?}: {e}"))?;
(
pkcs8_pem_to_seed(&raw).map_err(|e| format!("{f}: {e}"))?,
Some(f.clone()),
)
}
_ => {
let mut s = [0u8; SEED_SIZE];
getrandom::fill(&mut s).map_err(|e| format!("could not read OS randomness: {e}"))?;
(s, None)
}
};
let password = read_password(fd, true)?;
let principal = seal_and_write(&path, &seed, &password)?;
match from {
None => println!("generated and stored {name} ({principal})."),
Some(src) => {
println!("stored {name} ({principal}) from {src}; the source file is untouched.")
}
}
Ok(())
}
pub fn run_list(args: &[String]) -> Result<(), String> {
let mut as_json = false;
for a in args {
if a != "--json" {
return Err(format!("unknown flag {a:?}\n{USAGE}"));
}
as_json = true;
}
let mut rows: Vec<(String, String)> = Vec::new();
for n in list_key_names()? {
let Ok(path) = key_path(&n) else { continue };
let Ok(raw) = fs::read(&path) else { continue };
let Ok(h) = keystore::parse_header(&raw) else {
continue;
};
rows.push((n, encode_key(&h.public_key)));
}
if as_json {
let body: Vec<String> = rows
.iter()
.map(|(n, p)| format!("{{\"name\":{},\"principal\":\"{p}\"}}", json_string(n)))
.collect();
println!("[{}]", body.join(","));
return Ok(());
}
for (n, p) in rows {
println!("{n}\t{p}");
}
Ok(())
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c => out.push(c),
}
}
out.push('"');
out
}
pub fn run_rm(args: &[String]) -> Result<(), String> {
let name = args.first().ok_or_else(|| USAGE.to_string())?;
let mut force = false;
for a in &args[1..] {
if a != "--force" {
return Err(format!("unknown flag {a:?}\n{USAGE}"));
}
force = true;
}
let path = key_path(name)?;
let raw = fs::read(&path).map_err(|_| format!("no key named {name:?} in archon's store"))?;
let parsed = keystore::parse_header(&raw);
if let Err(why) = &parsed {
if !force {
return Err(format!("not an archon key file: {}: {why}", path.display()));
}
}
fs::remove_file(&path).map_err(|e| format!("could not remove {}: {e}", path.display()))?;
match parsed {
Err(why) => println!(
"removed {name} (unreadable header: {why}) from archon's store at {}; \
any copy of this key outside it is untouched.",
path.display()
),
Ok(h) => println!(
"removed {name} ({}) from archon's store at {}; \
any copy of this key outside it is untouched.",
encode_key(&h.public_key),
path.display()
),
}
Ok(())
}
pub fn run_default(args: &[String]) -> Result<(), String> {
let pointer = archon_home()?.join("default");
match args.len() {
0 => {
let name =
read_default_key_name()?.ok_or_else(|| "no default key is set".to_string())?;
println!("{name}");
Ok(())
}
1 => {
let name = &args[0];
require_named_key(name)?;
if let Some(dir) = pointer.parent() {
fs::create_dir_all(dir)
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
}
fs::write(&pointer, format!("{name}\n"))
.map_err(|e| format!("could not write {}: {e}", pointer.display()))?;
println!("default key is now {name}.");
Ok(())
}
_ => Err(USAGE.to_string()),
}
}
pub fn run_export(args: &[String]) -> Result<(), String> {
let name = args.first().ok_or_else(|| USAGE.to_string())?.clone();
let (rest, fd) = take_password_fd(&args[1..])?;
let (mut reveal, mut out) = (false, String::new());
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--reveal" => {
reveal = true;
i += 1;
}
"--out" => {
out = rest
.get(i + 1)
.ok_or_else(|| format!("flag \"--out\" needs a value\n{USAGE}"))?
.clone();
i += 2;
}
other => return Err(format!("unknown flag {other:?}\n{USAGE}")),
}
}
if !reveal {
return Err("refusing to export a seed without --reveal".to_string());
}
if out.is_empty() {
return Err(
"refusing to write a seed to stdout: pass --out <file>, or --out - to mean it"
.to_string(),
);
}
let seed = unlock_named_key(&name, fd)?;
let pem = seed_to_pkcs8_pem(&seed)?;
if out == "-" {
print!("{pem}");
eprintln!("wrote the seed of {name} to stdout; the store's copy remains.");
return Ok(());
}
fs::write(&out, pem.as_bytes()).map_err(|e| format!("could not write {out:?}: {e}"))?;
set_owner_only(Path::new(&out))?;
println!("wrote the seed of {name} to {out}; the store's copy remains.");
Ok(())
}