use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use std::io::Read;
use std::path::Path;
use vgi_core::create_ssh_signature;
use crate::config::{self, SigningConfig};
use crate::policy;
use crate::vta;
pub const SIGNING_KEY_ENV: &str = "DID_GIT_SIGN_KEY";
pub const SIGNING_KEY_GIT_CONFIG: &str = "did-git-sign.key";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySource {
Env,
GitConfig,
ConfigFile,
}
impl std::fmt::Display for KeySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
KeySource::Env => "the DID_GIT_SIGN_KEY environment variable",
KeySource::GitConfig => "git config did-git-sign.key",
KeySource::ConfigFile => "the did-git-sign config file",
};
f.write_str(s)
}
}
fn select_signing_key(
config_did: &str,
env: Option<&str>,
git: Option<&str>,
) -> (String, KeySource) {
let clean = |v: Option<&str>| {
v.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
if let Some(v) = clean(env) {
return (v, KeySource::Env);
}
if let Some(v) = clean(git) {
return (v, KeySource::GitConfig);
}
(config_did.to_string(), KeySource::ConfigFile)
}
fn git_config_signing_key() -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", SIGNING_KEY_GIT_CONFIG])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!v.is_empty()).then_some(v)
}
fn resolve_signing_key(config_did: &str) -> (String, KeySource) {
let env = std::env::var(SIGNING_KEY_ENV).ok();
let git = git_config_signing_key();
select_signing_key(config_did, env.as_deref(), git.as_deref())
}
pub async fn handle_sign(
config_path: &Path,
namespace: &str,
sign_file: Option<&Path>,
) -> Result<()> {
let data = if let Some(path) = sign_file {
std::fs::read(path)
.with_context(|| format!("failed to read file to sign: {}", path.display()))?
} else {
let mut buf = Vec::new();
std::io::stdin()
.read_to_end(&mut buf)
.context("failed to read data from stdin")?;
buf
};
let decision = policy::evaluate(namespace, sign_file, &data);
policy::write_audit(&decision);
if !decision.allowed {
anyhow::bail!(
"did-git-sign: signing refused by policy (parent process {:?} not in allow-list; \
set DID_GIT_SIGN_BYPASS_POLICY=1 to override). \
Attempt recorded in {}.",
decision.parent_name.as_deref().unwrap_or("<unknown>"),
policy::audit_log_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<audit log unavailable>".to_string())
);
}
let cfg = SigningConfig::load(config_path)?;
let (did_key_id, source) = resolve_signing_key(&cfg.did_key_id);
if did_key_id != cfg.did_key_id && config::load_vta_credentials(&did_key_id).is_err() {
anyhow::bail!(
"did-git-sign: the signing persona '{did_key_id}' selected via {source} has no \
stored credentials. Run `did-git-sign init` for that persona, or clear the \
override ({} / `git config --unset {}`).",
SIGNING_KEY_ENV,
SIGNING_KEY_GIT_CONFIG,
);
}
let cfg = SigningConfig {
did_key_id,
user_name: cfg.user_name,
};
let (client, creds) = vta::authenticate(&cfg).await?;
let seed = vta::get_signing_key(&client, &creds.key_id).await?;
let signing_key = SigningKey::from_bytes(seed.as_bytes());
let verifying_key = signing_key.verifying_key();
let signature = create_ssh_signature(&signing_key, &verifying_key, namespace, &data)?;
if let Some(path) = sign_file {
let mut sig_os = path.as_os_str().to_owned();
sig_os.push(".sig");
let sig_path = std::path::PathBuf::from(sig_os);
std::fs::write(&sig_path, signature.as_bytes())
.with_context(|| format!("failed to write signature to {}", sig_path.display()))?;
} else {
print!("{signature}");
}
Ok(())
}
pub fn test_sign(
signing_key: &SigningKey,
verifying_key: &ed25519_dalek::VerifyingKey,
data: &[u8],
) -> Result<()> {
let signature = create_ssh_signature(signing_key, verifying_key, "git", data)?;
if !signature.starts_with("-----BEGIN SSH SIGNATURE-----") {
anyhow::bail!("signature output has invalid format");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signing_key_selection_precedence() {
let (key, src) = select_signing_key("cfg#k", Some("env#k"), Some("git#k"));
assert_eq!(key, "env#k");
assert_eq!(src, KeySource::Env);
let (key, src) = select_signing_key("cfg#k", None, Some("git#k"));
assert_eq!(key, "git#k");
assert_eq!(src, KeySource::GitConfig);
let (key, src) = select_signing_key("cfg#k", None, None);
assert_eq!(key, "cfg#k");
assert_eq!(src, KeySource::ConfigFile);
}
#[test]
fn signing_key_selection_ignores_blank_overrides() {
let (key, src) = select_signing_key("cfg#k", Some(" "), Some("git#k"));
assert_eq!(key, "git#k");
assert_eq!(src, KeySource::GitConfig);
let (key, src) = select_signing_key("cfg#k", Some(""), None);
assert_eq!(key, "cfg#k");
assert_eq!(src, KeySource::ConfigFile);
let (key, _) = select_signing_key("cfg#k", Some(" env#k \n"), None);
assert_eq!(key, "env#k");
}
#[test]
fn key_source_messages_name_the_origin() {
assert!(KeySource::Env.to_string().contains("DID_GIT_SIGN_KEY"));
assert!(
KeySource::GitConfig
.to_string()
.contains("did-git-sign.key")
);
}
#[test]
fn test_test_sign_accepts_valid_key() {
let seed = [0xAA; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
assert!(test_sign(&signing_key, &verifying_key, b"test data").is_ok());
}
#[test]
fn sig_path_appends_dot_sig_not_replaces_extension() {
let base = std::path::Path::new("/tmp/buffer.diff");
let mut sig_os = base.as_os_str().to_owned();
sig_os.push(".sig");
let sig_path = std::path::PathBuf::from(sig_os);
assert_eq!(sig_path, std::path::PathBuf::from("/tmp/buffer.diff.sig"));
let base2 = std::path::Path::new("/tmp/COMMIT_EDITMSG");
let mut sig_os2 = base2.as_os_str().to_owned();
sig_os2.push(".sig");
let sig_path2 = std::path::PathBuf::from(sig_os2);
assert_eq!(
sig_path2,
std::path::PathBuf::from("/tmp/COMMIT_EDITMSG.sig")
);
}
}