From b1f9f2fbfcb8403e43cc4acc12ab3ec655676279 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 12:19:55 -0400 Subject: [PATCH] feat(cli): implement device add with signing + deploy key - Create crates/relicario-cli/src/device.rs: local key storage under ~/.config/relicario/devices//, current-device tracking, and git signing config (gpg.format=ssh, user.signingkey, core.sshCommand) - Add Device command to CLI with add/revoke/list subcommands - cmd_device add: generates two ed25519 keypairs (signing + deploy), registers deploy key via Gitea API, stores keys at 0600, configures git SSH signing, updates .relicario/devices.json and commits - Gitea config read from flags or RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO} - --no-gitea flag skips API registration for non-Gitea remotes Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/device.rs | 166 ++++++++++++++++ crates/relicario-cli/src/main.rs | 308 ++++++++++++++++++++++++++++- 2 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 crates/relicario-cli/src/device.rs diff --git a/crates/relicario-cli/src/device.rs b/crates/relicario-cli/src/device.rs new file mode 100644 index 0000000..6ec92e5 --- /dev/null +++ b/crates/relicario-cli/src/device.rs @@ -0,0 +1,166 @@ +//! 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. +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)) +} + +/// Load the deploy private key for a device. +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. +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(()) +} + +/// 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(()) +} diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index f884db7..3939cb5 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -2,9 +2,10 @@ //! //! See module docs for the unlock flow and vault layout. +mod device; +mod gitea; mod helpers; mod session; -mod gitea; use std::path::PathBuf; @@ -189,6 +190,12 @@ enum Commands { /// Passphrase to score, or `-` to read from stdin. passphrase: String, }, + + /// Manage registered devices (signing keys + deploy keys). + Device { + #[command(subcommand)] + action: DeviceAction, + }, } #[derive(Subcommand)] @@ -348,6 +355,54 @@ enum ImportAction { }, } +#[derive(Subcommand)] +enum DeviceAction { + /// Register this machine as a new device. + /// + /// Generates two ed25519 keypairs: one for signing commits, one for push + /// access (deploy key). The deploy public key is registered via the Gitea + /// API. Both private keys are stored locally in + /// `~/.config/relicario/devices//`. The vault's `.relicario/devices.json` + /// is updated and committed. + /// + /// Required environment variables (or flags): + /// RELICARIO_GITEA_URL — e.g. https://git.example.com + /// RELICARIO_GITEA_TOKEN — personal access token with repo write access + /// RELICARIO_GITEA_OWNER — repository owner + /// RELICARIO_GITEA_REPO — repository name + Add { + /// Human-readable name for this device (e.g. "laptop-2026"). + #[arg(long)] + name: String, + /// Gitea API base URL (overrides RELICARIO_GITEA_URL). + #[arg(long)] + gitea_url: Option, + /// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN). + #[arg(long)] + gitea_token: Option, + /// Gitea repository owner (overrides RELICARIO_GITEA_OWNER). + #[arg(long)] + owner: Option, + /// Gitea repository name (overrides RELICARIO_GITEA_REPO). + #[arg(long)] + repo: Option, + /// Skip Gitea API registration (useful when the remote is not Gitea). + #[arg(long)] + no_gitea: bool, + }, + /// Revoke a registered device. + /// + /// Removes the device from `devices.json`, adds it to `revoked.json`, + /// deletes the deploy key from Gitea, and commits the change. + Revoke { + /// Name of the device to revoke. + #[arg(long)] + name: String, + }, + /// List registered devices. + List, +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -380,6 +435,7 @@ fn main() -> Result<()> { Ok(()) } Commands::Rate { passphrase } => cmd_rate(passphrase), + Commands::Device { action } => cmd_device(action), } } @@ -2228,3 +2284,253 @@ fn cmd_rate(passphrase: String) -> Result<()> { println!("note: init requires score ≥ 3 (see `relicario init`)"); Ok(()) } + +// ── Device management ───────────────────────────────────────────────────────── + +/// Build a `GiteaClient` from flags or environment variables. +fn load_gitea_client( + gitea_url: Option, + gitea_token: Option, + owner: Option, + repo: Option, +) -> Result { + let url = gitea_url + .or_else(|| std::env::var("RELICARIO_GITEA_URL").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL" + ))?; + let token = gitea_token + .or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN" + ))?; + let owner = owner + .or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER" + ))?; + let repo = repo + .or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok()) + .ok_or_else(|| anyhow::anyhow!( + "Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO" + ))?; + Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo)) +} + +fn cmd_device(action: DeviceAction) -> Result<()> { + use std::fs; + use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair}; + + let root = crate::helpers::vault_dir()?; + let relicario_dir = root.join(".relicario"); + let devices_path = relicario_dir.join("devices.json"); + + match action { + DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => { + // Guard: don't overwrite an already-registered device name. + let existing: Vec = fs::read(&devices_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + if existing.iter().any(|d| d.name == name) { + anyhow::bail!("a device named '{}' is already registered", name); + } + + eprintln!("Generating signing keypair..."); + let (signing_priv, signing_pub) = generate_keypair() + .map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?; + + eprintln!("Generating deploy keypair..."); + let (deploy_priv, deploy_pub) = generate_keypair() + .map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?; + + // Optionally register deploy key with Gitea. + let gitea_key_id: u64 = if no_gitea { + eprintln!("Skipping Gitea deploy key registration (--no-gitea)."); + 0 + } else { + let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?; + let key_title = format!("relicario-{}", name); + eprintln!("Registering deploy key '{}' with Gitea...", key_title); + client.create_deploy_key(&key_title, &deploy_pub)? + }; + + // Store keys locally with proper permissions. + crate::device::store_device_keys( + &name, + &signing_priv, + &signing_pub, + &deploy_priv, + &deploy_pub, + gitea_key_id, + )?; + + // Mark as current device. + crate::device::set_current_device(&name)?; + + // Configure git signing + SSH deploy key in the vault repo. + crate::device::configure_git_signing(&root, &name)?; + + // Update devices.json. + let current_name = name.clone(); + let mut devices = existing; + devices.push(DeviceEntry { + name: name.clone(), + public_key: signing_pub.clone(), + added_at: relicario_core::now_unix(), + added_by: current_name, + }); + fs::create_dir_all(&relicario_dir)?; + fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; + + // Commit the update. + let status = crate::helpers::git_command( + &root, + &["add", ".relicario/devices.json"], + ) + .status()?; + if !status.success() { + anyhow::bail!("git add .relicario/devices.json failed"); + } + let msg = format!("device: register {}", name); + let status = crate::helpers::git_command(&root, &["commit", "-m", &msg]) + .status()?; + if !status.success() { + anyhow::bail!("git commit failed"); + } + + eprintln!("Device '{}' registered.", name); + eprintln!("Signing public key:"); + eprintln!(" {}", signing_pub); + if gitea_key_id != 0 { + eprintln!("Gitea deploy key ID: {}", gitea_key_id); + } + Ok(()) + } + + DeviceAction::Revoke { name } => { + // Guard: refuse to revoke the currently active device (would lock + // the user out). They must add another device first. + if let Some(current) = crate::device::current_device()? { + if current == name { + anyhow::bail!( + "cannot revoke the current device '{}' — you would lose \ + push access. Register another device first.", + name + ); + } + } + + // Load devices.json. + let mut devices: Vec = fs::read(&devices_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + + let device = devices + .iter() + .find(|d| d.name == name) + .ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))? + .clone(); + + // Remove from devices.json. + devices.retain(|d| d.name != name); + fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; + + // Append to revoked.json. + let revoked_path = relicario_dir.join("revoked.json"); + let mut revoked: Vec = fs::read(&revoked_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + + let revoked_by = crate::device::current_device()? + .unwrap_or_else(|| "unknown".to_string()); + + revoked.push(RevokedEntry { + name: name.clone(), + public_key: device.public_key.clone(), + revoked_at: relicario_core::now_unix(), + revoked_by, + }); + fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?; + + // Delete deploy key from Gitea (best-effort — don't fail if it + // was already deleted or the config is missing). + if let Ok(key_id) = crate::device::load_gitea_key_id(&name) { + if key_id != 0 { + // Build client from env vars only (no flags in revoke). + match load_gitea_client(None, None, None, None) { + Ok(client) => { + if let Err(e) = client.delete_deploy_key(key_id) { + eprintln!( + "warning: failed to delete Gitea deploy key {}: {}", + key_id, e + ); + } else { + eprintln!("Deleted Gitea deploy key {}.", key_id); + } + } + Err(_) => { + eprintln!( + "warning: Gitea env vars not set — deploy key {} \ + not deleted from Gitea.", + key_id + ); + } + } + } + } + + // Commit devices.json + revoked.json. + let mut paths = vec![".relicario/devices.json"]; + if revoked_path.exists() { + paths.push(".relicario/revoked.json"); + } + let mut add_args = vec!["add"]; + add_args.extend_from_slice(&paths); + let status = crate::helpers::git_command(&root, &add_args).status()?; + if !status.success() { + anyhow::bail!("git add failed"); + } + let msg = format!("device: revoke {}", name); + let status = crate::helpers::git_command(&root, &["commit", "-m", &msg]) + .status()?; + if !status.success() { + anyhow::bail!("git commit failed"); + } + + eprintln!("Device '{}' revoked.", name); + Ok(()) + } + + DeviceAction::List => { + let devices: Vec = fs::read(&devices_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + + let current = crate::device::current_device()?.unwrap_or_default(); + + if devices.is_empty() { + println!("No registered devices."); + return Ok(()); + } + + println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)"); + println!("{}", "-".repeat(72)); + for d in &devices { + let marker = if d.name == current { " *" } else { "" }; + let added = crate::helpers::iso8601(d.added_at); + // Show only the first 40 chars of the public key line for readability. + let key_prefix: String = d.public_key.chars().take(40).collect(); + println!("{:<20} {:<20} {}{}", + d.name, added, key_prefix, marker); + } + if !current.is_empty() { + println!("\n* = current device"); + } + Ok(()) + } + } +}