use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use std::io::Read;
use std::path::Path;
use vgi_core::{GIT_SSHSIG_NAMESPACE, create_ssh_signature};
use crate::config::{self, SigningConfig};
use crate::policy;
use crate::vta;
fn check_namespace(namespace: &str) -> Result<()> {
if namespace == GIT_SSHSIG_NAMESPACE {
return Ok(());
}
anyhow::bail!(
"did-git-sign: refusing to sign in sshsig namespace {namespace:?}. did-git-sign only \
signs git objects, in the {GIT_SSHSIG_NAMESPACE:?} namespace; use ssh-keygen with a \
separate key for other namespaces."
)
}
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())
}
fn bare_did(did_key_id: &str) -> &str {
did_key_id
.split(['#', '?', '/'])
.next()
.unwrap_or(did_key_id)
}
fn check_committer_matches_key(data: &[u8], did_key_id: &str, source: KeySource) -> Result<()> {
use vgi_core::{committer_identity, conflicting_signer_dids, signer_did};
let signing_did = bare_did(did_key_id);
if let Some((trailer, committer)) = conflicting_signer_dids(data) {
anyhow::bail!(
"did-git-sign: Signed-by-DID trailer claims '{trailer}' but committer claims \
'{committer}'. Remove one claim or make them match before signing."
);
}
match signer_did(data) {
Some(claimed) if claimed == signing_did => Ok(()),
Some(claimed) => anyhow::bail!(
"did-git-sign: this commit claims signer '{claimed}' but would be signed with a key \
held by '{signing_did}' (selected via {source}), so it would fail verification as \
unknownKey. Point the claim at the signing key — \
`git config did-git-sign.key '{did_key_id}'` for the Signed-by-DID trailer, or \
`git config user.email` for a legacy DID committer — or select the persona \
matching the claim."
),
None if committer_identity(data).is_none() => Ok(()),
None => anyhow::bail!(
"did-git-sign: no Signed-by-DID trailer and user.email is not a DID, so the commit \
would state no signer identity and fail verification as noSignerDid. Run \
'did-git-sign init' to install the commit-msg hook, or set user.email to \
'{did_key_id}'."
),
}
}
pub async fn handle_sign(
config_path: &Path,
namespace: &str,
sign_file: Option<&Path>,
) -> Result<()> {
check_namespace(namespace)?;
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 {:?} is not git). \
did-git-sign signs only when git runs it as gpg.ssh.program. \
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,
);
}
check_committer_matches_key(&data, &did_key_id, source)?;
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::*;
const SIGNER: &str = "did:webvh:QmSigner:example.com";
fn commit_committed_by(committer: &str) -> Vec<u8> {
format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer A U Thor <{committer}> 1700000000 +0000\n\
\n\
a message\n"
)
.into_bytes()
}
#[test]
fn a_matching_committer_and_key_may_sign() {
let commit = commit_committed_by(&format!("{SIGNER}#key-0"));
assert!(
check_committer_matches_key(&commit, &format!("{SIGNER}#key-0"), KeySource::ConfigFile)
.is_ok()
);
}
#[test]
fn a_different_key_of_the_same_did_still_signs() {
let commit = commit_committed_by(&format!("{SIGNER}#key-0"));
assert!(
check_committer_matches_key(&commit, &format!("{SIGNER}#key-3"), KeySource::GitConfig)
.is_ok()
);
}
#[test]
fn signing_as_one_community_while_claiming_another_is_refused() {
let commit = commit_committed_by("did:webvh:QmOther:community.example#key-0");
let error =
check_committer_matches_key(&commit, &format!("{SIGNER}#key-0"), KeySource::GitConfig)
.unwrap_err()
.to_string();
assert!(error.contains("QmOther"), "names the claim: {error}");
assert!(error.contains("QmSigner"), "names the key's DID: {error}");
assert!(
error.contains("git config did-git-sign.key"),
"names where the selection came from: {error}"
);
}
#[test]
fn a_non_did_committer_without_trailer_is_refused() {
let commit = commit_committed_by("alice@example.com");
let error =
check_committer_matches_key(&commit, &format!("{SIGNER}#key-0"), KeySource::ConfigFile)
.unwrap_err()
.to_string();
assert!(
error.contains("noSignerDid"),
"must refuse when no DID claim exists: {error}"
);
}
#[test]
fn trailer_matching_key_is_accepted() {
let commit = format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A <a@x.com> 1700000000 +0000\n\
committer A <alice@example.com> 1700000000 +0000\n\
\n\
message\n\
\n\
Signed-by-DID: {SIGNER}#key-0\n"
);
assert!(
check_committer_matches_key(
commit.as_bytes(),
&format!("{SIGNER}#key-0"),
KeySource::ConfigFile
)
.is_ok()
);
}
#[test]
fn trailer_conflicting_with_key_is_refused() {
let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A <a@x.com> 1700000000 +0000\n\
committer A <alice@example.com> 1700000000 +0000\n\
\n\
message\n\
\n\
Signed-by-DID: did:webvh:QmOther:other.example#key-0\n";
assert!(
check_committer_matches_key(
commit.as_bytes(),
&format!("{SIGNER}#key-0"),
KeySource::ConfigFile
)
.is_err()
);
}
#[test]
fn conflicting_trailer_and_committer_dids_are_refused() {
let commit = format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A <a@x.com> 1700000000 +0000\n\
committer A <did:webvh:QmOther:other.example#key-0> 1700000000 +0000\n\
\n\
message\n\
\n\
Signed-by-DID: {SIGNER}#key-0\n"
);
let error = check_committer_matches_key(
commit.as_bytes(),
&format!("{SIGNER}#key-0"),
KeySource::ConfigFile,
)
.unwrap_err()
.to_string();
assert!(error.contains("Signed-by-DID"), "names trailer: {error}");
assert!(error.contains("committer"), "names committer: {error}");
}
#[test]
fn a_payload_with_no_committer_is_not_a_claim_to_check() {
assert!(
check_committer_matches_key(b"not a commit object", SIGNER, KeySource::ConfigFile)
.is_ok()
);
}
#[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 only_the_git_namespace_may_sign() {
assert!(check_namespace("git").is_ok());
for namespace in ["file", "email", "", "Git", "git ", "git\0"] {
let error = check_namespace(namespace).unwrap_err().to_string();
assert!(
error.contains("namespace"),
"{namespace:?} must be refused with a namespace error: {error}"
);
}
}
#[tokio::test]
async fn a_non_git_namespace_is_refused_before_anything_is_read() {
let missing = std::path::Path::new("/nonexistent/did-git-sign/buffer");
let error = handle_sign(missing, "file", Some(missing))
.await
.unwrap_err()
.to_string();
assert!(
error.contains("refusing to sign in sshsig namespace \"file\""),
"{error}"
);
}
#[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")
);
}
}