From 17df315f0e684c2ec94f09dee874d22d1abc715a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 19 Jun 2026 22:55:03 -0400 Subject: [PATCH] feat(cli/device): current_device_seed + current_device_pubkey helpers Read the active device's ed25519 seed/pubkey from devices//signing.{key,pub}. Adds ssh-key (0.6) as a CLI dep (already at 0.6.7 in the workspace lock via relicario-core) and ed25519-dalek as a dev-dep for the round-trip test. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + crates/relicario-cli/Cargo.toml | 2 + crates/relicario-cli/src/device.rs | 89 ++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1705b66..ffaf13f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,6 +2166,7 @@ dependencies = [ "clap_complete", "data-encoding", "dirs", + "ed25519-dalek", "hex", "image", "predicates", @@ -2177,6 +2178,7 @@ dependencies = [ "rqrr", "serde", "serde_json", + "ssh-key", "tar", "tempfile", "url", diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index a5a853f..db05181 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -30,9 +30,11 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"] rqrr = "0.7" reqwest = { version = "0.12", features = ["blocking", "json"] } qrcode = { version = "0.14", features = ["svg"] } +ssh-key = { version = "0.6", features = ["ed25519", "std"] } [dev-dependencies] assert_cmd = "2" predicates = "3" tempfile = "3" serde_json = "1" +ed25519-dalek = "2" diff --git a/crates/relicario-cli/src/device.rs b/crates/relicario-cli/src/device.rs index 25cf775..5db7f1e 100644 --- a/crates/relicario-cli/src/device.rs +++ b/crates/relicario-cli/src/device.rs @@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result> { Ok(Zeroizing::new(key)) } +/// Read the active device's ed25519 public key (OpenSSH single-line format, +/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`. +/// +/// Errors if no device is selected (`devices/current` missing/empty) — the +/// caller should hint the user to run `relicario device add` first. +pub fn current_device_pubkey() -> Result { + let name = current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let path = device_dir(&name)?.join("signing.pub"); + let pubkey = fs::read_to_string(&path) + .with_context(|| format!("read signing.pub for device '{name}'"))?; + let trimmed = pubkey.trim(); + if trimmed.is_empty() { + anyhow::bail!("signing.pub for device '{name}' is empty"); + } + Ok(trimmed.to_string()) +} + +/// Read the active device's 32-byte ed25519 seed from `signing.key` +/// (OpenSSH private-key format). +/// +/// The seed is the secret scalar used to sign org commits and to unwrap the +/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no +/// device is selected, the key file is unreadable, or the key is not ed25519. +pub fn current_device_seed() -> Result> { + let name = current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + // load_signing_key reads signing.key as OpenSSH private-key text. + let pem = load_signing_key(&name)?; + let private = ssh_key::PrivateKey::from_openssh(pem.as_str()) + .map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?; + let keypair = private + .key_data() + .ed25519() + .ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?; + // Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7 + // private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped. + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(keypair.private.as_ref()); + Ok(seed) +} + /// Load the deploy private key for a device. #[allow(dead_code)] pub fn load_deploy_key(name: &str) -> Result> { @@ -127,6 +169,53 @@ pub fn delete_device_keys(name: &str) -> Result<()> { Ok(()) } +#[cfg(test)] +mod seed_helper_tests { + use super::*; + use std::sync::Mutex; + + // dirs::config_dir() reads process-wide env; serialize these tests. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn current_device_seed_and_pubkey_round_trip() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + + // Generate a real ed25519 device keypair (OpenSSH text) via core. + let (private_openssh, public_openssh) = + relicario_core::device::generate_keypair().unwrap(); + + // Lay out devices/test-dev/{signing.key,signing.pub} + devices/current. + let dir = device_dir("test-dev").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap(); + std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap(); + set_current_device("test-dev").unwrap(); + + // pubkey helper returns exactly the stored OpenSSH public line. + let got_pub = current_device_pubkey().unwrap(); + assert_eq!(got_pub.trim(), public_openssh.trim()); + + // seed helper returns the 32-byte ed25519 seed; re-derive the public + // key from it and confirm it matches. + let seed = current_device_seed().unwrap(); + let signing = ed25519_dalek::SigningKey::from_bytes(&seed); + let derived = signing.verifying_key(); + let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap(); + let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref(); + assert_eq!(derived.as_bytes().as_slice(), parsed_bytes); + + // restore env + match prev_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } +} + /// Configure git in `vault_root` to: /// - sign commits with the device's signing key (SSH format) /// - push via SSH using the device's deploy key