use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{self, SigningConfig, VtaCredentials};
pub struct InstallArgs<'a> {
pub global: bool,
pub did_key_id: String,
pub vta_key_id: String,
pub credential_did: String,
pub credential_private_key_mb: String,
pub vta_did: String,
pub vta_url: String,
pub mediator_did: Option<String>,
pub user_name: Option<String>,
pub verifying_key: &'a [u8; 32],
}
pub struct InstallResult {
pub config_path: PathBuf,
pub ssh_public_key: String,
pub overridden_global_signing_key: Option<String>,
}
pub fn install(args: InstallArgs<'_>) -> Result<InstallResult> {
let cfg = SigningConfig {
did_key_id: args.did_key_id.clone(),
user_name: args.user_name,
};
let vta_creds = VtaCredentials {
vta_url: args.vta_url,
vta_did: args.vta_did,
credential_did: args.credential_did,
private_key_multibase: args.credential_private_key_mb,
key_id: args.vta_key_id,
mediator_did: args.mediator_did,
};
let config_path = if args.global {
SigningConfig::default_global_path()?
} else {
SigningConfig::repo_local_path()
};
cfg.save(&config_path)?;
config::store_vta_credentials(&args.did_key_id, &vta_creds)?;
setup_git(&config_path, &cfg, args.global)?;
let entry = allowed_signers_entry(&cfg, args.verifying_key);
let config_dir = config_path.parent().unwrap_or(Path::new("."));
setup_allowed_signers(config_dir, &entry, args.global)?;
let overridden_global_signing_key = (!args.global)
.then(|| {
std::process::Command::new("git")
.args(["config", "--global", "user.signingKey"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
})
.flatten();
Ok(InstallResult {
config_path,
ssh_public_key: ssh_public_key_string(args.verifying_key),
overridden_global_signing_key,
})
}
pub fn uninstall(global: bool, did_key_id: &str) -> Result<UninstallResult> {
let mut summary = UninstallResult::default();
let config_path = if global {
SigningConfig::default_global_path()?
} else {
SigningConfig::repo_local_path()
};
if config_path.exists() {
match std::fs::remove_file(&config_path) {
Ok(()) => {
summary.removed_config_file = Some(config_path.clone());
}
Err(e) => {
summary
.warnings
.push(format!("could not remove {}: {e}", config_path.display()));
}
}
}
for suffix in [":vta", ":token"] {
let key = format!("{did_key_id}{suffix}");
if let Ok(entry) = keyring_core::Entry::new(config::KEYRING_SERVICE, &key) {
match entry.delete_credential() {
Ok(()) => summary.removed_keyring_entries.push(key),
Err(keyring_core::Error::NoEntry) => {}
Err(e) => {
summary
.warnings
.push(format!("could not remove keyring entry '{key}': {e}"));
}
}
}
}
let signers_path = config_path
.parent()
.unwrap_or(Path::new("."))
.join("allowed_signers");
if signers_path.exists() {
match std::fs::read_to_string(&signers_path) {
Ok(content) => {
let prefix = format!("{did_key_id} ");
let mut kept = Vec::new();
let mut removed = false;
for line in content.lines() {
if line.trim_start().starts_with(&prefix) {
removed = true;
} else {
kept.push(line);
}
}
if removed {
let mut new_content = kept.join("\n");
if !new_content.is_empty() {
new_content.push('\n');
}
if let Err(e) = std::fs::write(&signers_path, new_content) {
summary
.warnings
.push(format!("could not rewrite {}: {e}", signers_path.display()));
} else {
summary.allowed_signers_entry_removed = true;
}
}
}
Err(e) => {
summary
.warnings
.push(format!("could not read {}: {e}", signers_path.display()));
}
}
}
let scope = if global { "--global" } else { "--local" };
for key in [
"user.signingKey",
"gpg.format",
"gpg.ssh.program",
"gpg.ssh.defaultKeyFile",
"gpg.ssh.allowedSignersFile",
"commit.gpgsign",
] {
if git_config_unset(scope, key) {
summary.git_config_keys_unset.push(key.to_string());
}
}
Ok(summary)
}
#[derive(Debug, Default)]
pub struct UninstallResult {
pub removed_config_file: Option<PathBuf>,
pub removed_keyring_entries: Vec<String>,
pub allowed_signers_entry_removed: bool,
pub git_config_keys_unset: Vec<String>,
pub warnings: Vec<String>,
}
fn git_config_unset(scope: &str, key: &str) -> bool {
Command::new("git")
.arg("config")
.arg(scope)
.arg("--unset")
.arg(key)
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn setup_git(config_path: &Path, cfg: &SigningConfig, global: bool) -> Result<()> {
let scope = if global { "--global" } else { "--local" };
let config_path_str = config_path
.to_str()
.context("config path is not valid UTF-8")?;
git_config(scope, "gpg.format", "ssh")?;
git_config(scope, "gpg.ssh.program", "did-git-sign")?;
git_config(scope, "user.signingKey", config_path_str)?;
git_config(scope, "gpg.ssh.defaultKeyFile", config_path_str)?;
git_config(scope, "commit.gpgsign", "true")?;
if let Some(name) = &cfg.user_name {
git_config(scope, "user.name", name)?;
}
Ok(())
}
pub fn allowed_signers_entry(cfg: &SigningConfig, public_key_bytes: &[u8; 32]) -> String {
let pub_b64 = base64_encode_pubkey(public_key_bytes);
format!("{} ssh-ed25519 {}", cfg.did_key_id, pub_b64)
}
pub fn setup_allowed_signers(config_dir: &Path, entry: &str, global: bool) -> Result<()> {
let signers_path = config_dir.join("allowed_signers");
let signers_path_str = signers_path
.to_str()
.context("signers path is not valid UTF-8")?;
let existing = std::fs::read_to_string(&signers_path).unwrap_or_default();
if !existing.contains(entry) {
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(entry);
content.push('\n');
std::fs::write(&signers_path, content)
.with_context(|| format!("failed to write {}", signers_path.display()))?;
}
let scope = if global { "--global" } else { "--local" };
git_config(scope, "gpg.ssh.allowedSignersFile", signers_path_str)?;
Ok(())
}
fn git_config(scope: &str, key: &str, value: &str) -> Result<()> {
let output = Command::new("git")
.arg("config")
.arg(scope)
.arg(key)
.arg(value)
.output()
.context("failed to run git config")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git config {scope} {key} failed: {stderr}");
}
Ok(())
}
pub fn ssh_public_key_string(public_key_bytes: &[u8; 32]) -> String {
format!("ssh-ed25519 {}", base64_encode_pubkey(public_key_bytes))
}
fn base64_encode_pubkey(public_key_bytes: &[u8; 32]) -> String {
use base64::Engine;
let mut blob = Vec::new();
let key_type = b"ssh-ed25519";
blob.extend_from_slice(&(key_type.len() as u32).to_be_bytes());
blob.extend_from_slice(key_type);
blob.extend_from_slice(&(public_key_bytes.len() as u32).to_be_bytes());
blob.extend_from_slice(public_key_bytes);
base64::engine::general_purpose::STANDARD.encode(&blob)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base64_pubkey_format() {
let key = [0u8; 32];
let encoded = base64_encode_pubkey(&key);
assert!(!encoded.is_empty());
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(&encoded)
.unwrap();
assert_eq!(decoded.len(), 51);
}
#[test]
fn test_allowed_signers_entry_format() {
let cfg = SigningConfig {
did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
user_name: None,
};
let key = [0u8; 32];
let entry = allowed_signers_entry(&cfg, &key);
assert!(entry.starts_with("did:webvh:abc:example.com#key-0 ssh-ed25519 "));
}
#[test]
fn test_ssh_public_key_string_format() {
let key = [0u8; 32];
let result = ssh_public_key_string(&key);
assert!(result.starts_with("ssh-ed25519 "));
let b64_part = result.strip_prefix("ssh-ed25519 ").unwrap();
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64_part).unwrap();
assert_eq!(decoded.len(), 51);
}
#[test]
fn test_allowed_signers_entry_contains_valid_ssh_key() {
let cfg = SigningConfig {
did_key_id: "did:webvh:test:host#key-0".to_string(),
user_name: Some("Test User".to_string()),
};
let key = [0xFF; 32];
let entry = allowed_signers_entry(&cfg, &key);
let parts: Vec<&str> = entry.splitn(3, ' ').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "did:webvh:test:host#key-0");
assert_eq!(parts[1], "ssh-ed25519");
assert!(
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, parts[2],).is_ok()
);
}
#[test]
fn test_base64_pubkey_encodes_key_type_and_bytes() {
let key = [0x42; 32];
let encoded = base64_encode_pubkey(&key);
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &encoded).unwrap();
assert_eq!(&decoded[0..4], &(11u32).to_be_bytes());
assert_eq!(&decoded[4..15], b"ssh-ed25519");
assert_eq!(&decoded[15..19], &(32u32).to_be_bytes());
assert_eq!(&decoded[19..51], &[0x42; 32]);
}
#[test]
fn test_setup_allowed_signers_creates_file() {
let dir = tempfile::tempdir().unwrap();
let entry = "did:webvh:test:host#key-0 ssh-ed25519 AAAA";
let signers_path = dir.path().join("allowed_signers");
let content = format!("{entry}\n");
std::fs::write(&signers_path, &content).unwrap();
let read_back = std::fs::read_to_string(&signers_path).unwrap();
assert!(read_back.contains(entry));
}
#[test]
fn test_different_keys_produce_different_ssh_strings() {
let key_a = [0x00; 32];
let key_b = [0xFF; 32];
assert_ne!(ssh_public_key_string(&key_a), ssh_public_key_string(&key_b));
}
struct CwdGuard {
original: std::path::PathBuf,
}
impl CwdGuard {
fn change_to(path: &std::path::Path) -> Self {
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
CwdGuard { original }
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original);
}
}
#[test]
#[serial_test::serial]
fn setup_git_never_writes_user_email() {
let dir = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
let original_cwd = std::env::current_dir().unwrap();
{
let _cwd = CwdGuard::change_to(dir.path());
let config_path = dir.path().join(".did-git-sign.json");
let cfg = SigningConfig {
did_key_id: "did:webvh:test#key-0".to_string(),
user_name: None,
};
setup_git(&config_path, &cfg, false).unwrap();
}
assert_eq!(
std::env::current_dir().unwrap(),
original_cwd,
"CwdGuard must restore the original directory on drop"
);
let out = std::process::Command::new("git")
.args([
"-C",
dir.path().to_str().unwrap(),
"config",
"--local",
"user.email",
])
.output()
.unwrap();
assert!(
!out.status.success(),
"user.email must not be set by setup_git; found: {}",
String::from_utf8_lossy(&out.stdout).trim(),
);
}
}