//! Local device key storage and git signing configuration. //! //! Keys live under `~/.config/relicario/devices//`: //! 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 { let config = dirs::config_dir() .ok_or_else(|| anyhow::anyhow!("no config directory available"))?; Ok(config.join("relicario").join("devices")) } /// `~/.config/relicario/devices//` pub fn device_dir(name: &str) -> Result { Ok(devices_dir()?.join(name)) } /// Read the current device name from `devices/current`, or `None` if not set. pub fn current_device() -> Result> { 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> { 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 { 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> { 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 { 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(()) }