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",
|
"clap_complete",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"ed25519-dalek",
|
||||||
"hex",
|
"hex",
|
||||||
"image",
|
"image",
|
||||||
"predicates",
|
"predicates",
|
||||||
@@ -2177,6 +2178,7 @@ dependencies = [
|
|||||||
"rqrr",
|
"rqrr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"ssh-key",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"url",
|
"url",
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
|
|||||||
rqrr = "0.7"
|
rqrr = "0.7"
|
||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
qrcode = { version = "0.14", features = ["svg"] }
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
|||||||
@@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
|||||||
Ok(Zeroizing::new(key))
|
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.
|
/// Load the deploy private key for a device.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
@@ -127,6 +169,53 @@ pub fn delete_device_keys(name: &str) -> Result<()> {
|
|||||||
Ok(())
|
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:
|
/// Configure git in `vault_root` to:
|
||||||
/// - sign commits with the device's signing key (SSH format)
|
/// - sign commits with the device's signing key (SSH format)
|
||||||
/// - push via SSH using the device's deploy key
|
/// - push via SSH using the device's deploy key
|
||||||
|
|||||||
Reference in New Issue
Block a user