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>
259 lines
9.5 KiB
Rust
259 lines
9.5 KiB
Rust
//! Local device key storage and git signing configuration.
|
|
//!
|
|
//! Keys live under `~/.config/relicario/devices/<device-name>/`:
|
|
//! signing.key — ed25519 private key (OpenSSH, 0600)
|
|
//! signing.pub — ed25519 public key (OpenSSH single line)
|
|
//! deploy.key — ed25519 private key for git push (OpenSSH, 0600)
|
|
//! deploy.pub — ed25519 public key registered as Gitea deploy key
|
|
//! gitea_key_id — numeric Gitea deploy key ID for later revocation
|
|
//!
|
|
//! The file `~/.config/relicario/devices/current` holds the active device name
|
|
//! (one plain-text line).
|
|
|
|
use std::fs::{self, Permissions};
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
use zeroize::Zeroizing;
|
|
|
|
/// `~/.config/relicario/devices/`
|
|
pub fn devices_dir() -> Result<PathBuf> {
|
|
let config = dirs::config_dir()
|
|
.ok_or_else(|| anyhow::anyhow!("no config directory available"))?;
|
|
Ok(config.join("relicario").join("devices"))
|
|
}
|
|
|
|
/// `~/.config/relicario/devices/<name>/`
|
|
pub fn device_dir(name: &str) -> Result<PathBuf> {
|
|
Ok(devices_dir()?.join(name))
|
|
}
|
|
|
|
/// Read the current device name from `devices/current`, or `None` if not set.
|
|
pub fn current_device() -> Result<Option<String>> {
|
|
let path = devices_dir()?.join("current");
|
|
if !path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let name = fs::read_to_string(&path)
|
|
.context("read current device")?
|
|
.trim()
|
|
.to_string();
|
|
if name.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(name))
|
|
}
|
|
}
|
|
|
|
/// Write the active device name to `devices/current`.
|
|
pub fn set_current_device(name: &str) -> Result<()> {
|
|
let dir = devices_dir()?;
|
|
fs::create_dir_all(&dir).context("create devices dir")?;
|
|
fs::write(dir.join("current"), format!("{name}\n"))
|
|
.context("write current device")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Store all keys for a device, applying restrictive permissions on private
|
|
/// key files on Unix.
|
|
pub fn store_device_keys(
|
|
name: &str,
|
|
signing_private: &str,
|
|
signing_public: &str,
|
|
deploy_private: &str,
|
|
deploy_public: &str,
|
|
gitea_key_id: u64,
|
|
) -> Result<()> {
|
|
let dir = device_dir(name)?;
|
|
fs::create_dir_all(&dir).context("create device dir")?;
|
|
|
|
fs::write(dir.join("signing.key"), signing_private)
|
|
.context("write signing.key")?;
|
|
fs::write(dir.join("signing.pub"), signing_public)
|
|
.context("write signing.pub")?;
|
|
fs::write(dir.join("deploy.key"), deploy_private)
|
|
.context("write deploy.key")?;
|
|
fs::write(dir.join("deploy.pub"), deploy_public)
|
|
.context("write deploy.pub")?;
|
|
fs::write(dir.join("gitea_key_id"), gitea_key_id.to_string())
|
|
.context("write gitea_key_id")?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(dir.join("signing.key"), Permissions::from_mode(0o600))
|
|
.context("chmod signing.key")?;
|
|
fs::set_permissions(dir.join("deploy.key"), Permissions::from_mode(0o600))
|
|
.context("chmod deploy.key")?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load the signing private key for a device.
|
|
#[allow(dead_code)]
|
|
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
|
let path = device_dir(name)?.join("signing.key");
|
|
let key = fs::read_to_string(&path)
|
|
.with_context(|| format!("read signing key for device '{name}'"))?;
|
|
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>> {
|
|
let path = device_dir(name)?.join("deploy.key");
|
|
let key = fs::read_to_string(&path)
|
|
.with_context(|| format!("read deploy key for device '{name}'"))?;
|
|
Ok(Zeroizing::new(key))
|
|
}
|
|
|
|
/// Load the Gitea deploy key ID for a device.
|
|
pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
|
let path = device_dir(name)?.join("gitea_key_id");
|
|
let id_str = fs::read_to_string(&path)
|
|
.with_context(|| format!("read Gitea key ID for device '{name}'"))?;
|
|
id_str.trim().parse().context("parse Gitea key ID")
|
|
}
|
|
|
|
/// Delete the local key directory for a device.
|
|
#[allow(dead_code)]
|
|
pub fn delete_device_keys(name: &str) -> Result<()> {
|
|
let dir = device_dir(name)?;
|
|
if dir.exists() {
|
|
fs::remove_dir_all(&dir)
|
|
.with_context(|| format!("delete device dir for '{name}'"))?;
|
|
}
|
|
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
|
|
pub fn configure_git_signing(vault_root: &std::path::Path, name: &str) -> Result<()> {
|
|
let dir = device_dir(name)?;
|
|
let signing_key = dir.join("signing.key");
|
|
let deploy_key = dir.join("deploy.key");
|
|
|
|
// gpg.format = ssh so git uses SSH-format signing
|
|
crate::helpers::git_command(vault_root, &["config", "gpg.format", "ssh"])
|
|
.status()
|
|
.context("git config gpg.format")?;
|
|
|
|
// user.signingkey = path to the private key file
|
|
crate::helpers::git_command(
|
|
vault_root,
|
|
&["config", "user.signingkey", &signing_key.to_string_lossy()],
|
|
)
|
|
.status()
|
|
.context("git config user.signingkey")?;
|
|
|
|
// commit.gpgsign = true
|
|
crate::helpers::git_command(vault_root, &["config", "commit.gpgsign", "true"])
|
|
.status()
|
|
.context("git config commit.gpgsign")?;
|
|
|
|
// core.sshCommand — use only the deploy key for push
|
|
let ssh_cmd = format!(
|
|
"ssh -i {} -o IdentitiesOnly=yes",
|
|
deploy_key.display()
|
|
);
|
|
crate::helpers::git_command(
|
|
vault_root,
|
|
&["config", "core.sshCommand", &ssh_cmd],
|
|
)
|
|
.status()
|
|
.context("git config core.sshCommand")?;
|
|
|
|
Ok(())
|
|
}
|