diff --git a/crates/relicario-cli/src/commands/device.rs b/crates/relicario-cli/src/commands/device.rs new file mode 100644 index 0000000..6627d12 --- /dev/null +++ b/crates/relicario-cli/src/commands/device.rs @@ -0,0 +1,257 @@ +//! `relicario device {add, revoke, list}` — device key management. +//! +//! Note: command bodies live here as `crate::commands::device`. Local key +//! storage and git-signing config live separately in `crate::device`. + +use anyhow::Result; + +use crate::DeviceAction; + +/// 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)) +} + +pub 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 (always both — revoked.json + // was just written above so it is guaranteed to exist). + let add_args = [ + "add", + ".relicario/devices.json", + ".relicario/revoked.json", + ]; + 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); + eprintln!("Revoked signing key: {}", device.public_key); + 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} SIGNING KEY (prefix)", "NAME", "ADDED"); + 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(()) + } + } +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index 2079ce3..98198d1 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod attach; pub mod backup; +pub mod device; pub mod generate; pub mod get; pub mod import; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a3fc45c..acbea28 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -456,7 +456,7 @@ fn main() -> Result<()> { Ok(()) } Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase), - Commands::Device { action } => cmd_device(action), + Commands::Device { action } => commands::device::cmd_device(action), Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd), } } @@ -979,254 +979,4 @@ fn push_history( }); } -// ── 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 (always both — revoked.json - // was just written above so it is guaranteed to exist). - let add_args = [ - "add", - ".relicario/devices.json", - ".relicario/revoked.json", - ]; - 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); - eprintln!("Revoked signing key: {}", device.public_key); - 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} SIGNING KEY (prefix)", "NAME", "ADDED"); - 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(()) - } - } -}