//! `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. crate::helpers::git_run( &root, &["add", ".relicario/devices.json"], &format!("device register \"{name}\": git add .relicario/devices.json"), )?; let msg = format!("device: register {}", name); crate::helpers::git_run( &root, &["commit", "-m", &msg], &format!("device register \"{name}\": git commit"), )?; 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", ]; crate::helpers::git_run( &root, &add_args, &format!("device revoke \"{name}\": git add devices.json + revoked.json"), )?; let msg = format!("device: revoke {}", name); crate::helpers::git_run( &root, &["commit", "-m", &msg], &format!("device revoke \"{name}\": git commit"), )?; 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(()) } } }