use anyhow::{Context, Result};
use ed25519_dalek::SigningKey;
use std::io::Read;
use std::path::Path;
use vgi_core::{
GIT_SSHSIG_NAMESPACE, assemble_ssh_signature, create_ssh_signature, sshsig_message_hash,
};
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";
pub const SIGNER_ENV: &str = "DID_GIT_SIGN_SIGNER";
pub const SIGNER_GIT_CONFIG: &str = "did-git-sign.signer";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignerMode {
Vta,
Auto,
Export,
}
impl std::fmt::Display for SignerMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SignerMode::Vta => "vta",
SignerMode::Auto => "auto",
SignerMode::Export => "export",
})
}
}
impl SignerMode {
fn parse(value: &str) -> Result<Self> {
match value.trim() {
"vta" => Ok(SignerMode::Vta),
"auto" | "" => Ok(SignerMode::Auto),
"export" => Ok(SignerMode::Export),
other => anyhow::bail!(
"did-git-sign: unknown signer {other:?} (from {SIGNER_ENV} or \
`git config {SIGNER_GIT_CONFIG}`); expected vta, auto or export"
),
}
}
}
pub fn resolve_signer_mode() -> Result<SignerMode> {
if let Ok(v) = std::env::var(SIGNER_ENV)
&& !v.trim().is_empty()
{
return SignerMode::parse(&v);
}
match git_config_get(SIGNER_GIT_CONFIG) {
Some(v) => SignerMode::parse(&v),
None => Ok(SignerMode::Auto),
}
}
pub async fn sign_with_vta(
client: &vta_sdk::client::VtaClient,
key_id: &str,
namespace: &str,
data: &[u8],
mode: SignerMode,
) -> Result<String> {
if mode != SignerMode::Export {
match vta::sign_sshsig(client, key_id, &sshsig_message_hash(data)).await? {
vta::RemoteSignature::Signed(raw) => {
let verifying_key = vta::signing_public_key(client, key_id).await?;
return assemble_ssh_signature(&verifying_key, namespace, data, &raw);
}
vta::RemoteSignature::Unsupported if mode == SignerMode::Vta => anyhow::bail!(
"did-git-sign: this VTA does not serve keys/sign-sshsig, and {SIGNER_GIT_CONFIG} \
is `vta`, so the key will not be exported to sign locally. Upgrade the VTA, or \
set `git config {SIGNER_GIT_CONFIG} auto` to allow the export."
),
vta::RemoteSignature::Unsupported => eprintln!(
"did-git-sign: warning: this VTA does not serve keys/sign-sshsig; exporting the \
key to sign locally. Set `git config {SIGNER_GIT_CONFIG} vta` to refuse instead."
),
}
}
let seed = vta::get_signing_key(client, key_id).await?;
let signing_key = SigningKey::from_bytes(seed.as_bytes());
let verifying_key = signing_key.verifying_key();
create_ssh_signature(&signing_key, &verifying_key, namespace, data)
}
#[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> {
git_config_get(SIGNING_KEY_GIT_CONFIG)
}
fn git_config_get(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.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 const MAX_SIGN_INPUT: u64 = 16 * 1024 * 1024;
fn read_bounded(input: impl Read) -> Result<Vec<u8>> {
let mut buf = Vec::new();
input.take(MAX_SIGN_INPUT + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_SIGN_INPUT {
anyhow::bail!(
"refusing to sign more than {MAX_SIGN_INPUT} bytes: git's commit and tag objects \
are far smaller"
);
}
Ok(buf)
}
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 {
let file = std::fs::File::open(path)
.with_context(|| format!("failed to read file to sign: {}", path.display()))?;
read_bounded(file)
.with_context(|| format!("failed to read file to sign: {}", path.display()))?
} else {
read_bounded(std::io::stdin()).context("failed to read data from stdin")?
};
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 mode = resolve_signer_mode()?;
let (client, creds) = vta::authenticate(&cfg).await?;
let signature = sign_with_vta(&client, &creds.key_id, namespace, &data, mode).await?;
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(())
}
#[cfg(test)]
mod tests {
use super::*;
const SIGNER: &str = "did:webvh:QmSigner:example.com";
#[test]
fn input_up_to_the_bound_is_read_and_past_it_refused() {
let limit = MAX_SIGN_INPUT as usize;
assert_eq!(read_bounded(&vec![7u8; limit][..]).unwrap().len(), limit);
let err = read_bounded(&vec![7u8; limit + 1][..]).unwrap_err();
assert!(err.to_string().contains("refusing to sign"), "{err}");
assert!(read_bounded(std::io::repeat(0)).is_err());
}
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 signer_mode_parses_the_three_settings_and_refuses_others() {
assert_eq!(SignerMode::parse("vta").unwrap(), SignerMode::Vta);
assert_eq!(SignerMode::parse(" auto\n").unwrap(), SignerMode::Auto);
assert_eq!(SignerMode::parse("").unwrap(), SignerMode::Auto);
assert_eq!(SignerMode::parse("export").unwrap(), SignerMode::Export);
let error = SignerMode::parse("remote").unwrap_err().to_string();
assert!(error.contains(SIGNER_GIT_CONFIG), "{error}");
}
#[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 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")
);
}
}