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(())
|
||||
}
|
||||
Reference in New Issue
Block a user