feat(cli): implement device add with signing + deploy key
- Create crates/relicario-cli/src/device.rs: local key storage under
~/.config/relicario/devices/<name>/, 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 <noreply@anthropic.com>
This commit is contained in:
166
crates/relicario-cli/src/device.rs
Normal file
166
crates/relicario-cli/src/device.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
//! 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.
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the deploy private key for a device.
|
||||||
|
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.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! See module docs for the unlock flow and vault layout.
|
//! See module docs for the unlock flow and vault layout.
|
||||||
|
|
||||||
|
mod device;
|
||||||
|
mod gitea;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod session;
|
mod session;
|
||||||
mod gitea;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -189,6 +190,12 @@ enum Commands {
|
|||||||
/// Passphrase to score, or `-` to read from stdin.
|
/// Passphrase to score, or `-` to read from stdin.
|
||||||
passphrase: String,
|
passphrase: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manage registered devices (signing keys + deploy keys).
|
||||||
|
Device {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: DeviceAction,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[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/<name>/`. 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<String>,
|
||||||
|
/// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
/// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
|
||||||
|
#[arg(long)]
|
||||||
|
owner: Option<String>,
|
||||||
|
/// Gitea repository name (overrides RELICARIO_GITEA_REPO).
|
||||||
|
#[arg(long)]
|
||||||
|
repo: Option<String>,
|
||||||
|
/// 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<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -380,6 +435,7 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
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`)");
|
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Device management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a `GiteaClient` from flags or environment variables.
|
||||||
|
fn load_gitea_client(
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
owner: Option<String>,
|
||||||
|
repo: Option<String>,
|
||||||
|
) -> Result<crate::gitea::GiteaClient> {
|
||||||
|
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<DeviceEntry> = 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<DeviceEntry> = 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<RevokedEntry> = 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<DeviceEntry> = 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user