feat(cli/device): current_device_seed + current_device_pubkey helpers
Read the active device's ed25519 seed/pubkey from
devices/<name>/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) <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
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<String> {
|
||||
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<Zeroizing<[u8; 32]>> {
|
||||
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<Zeroizing<String>> {
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user