use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey};
use thiserror::Error;
const SSH_NAMESPACE: &str = "git";
#[derive(Debug, Error)]
pub enum TrustedKeyError {
#[error("invalid PGP public key: {0}")]
Pgp(String),
#[error("invalid SSH public key: {0}")]
Ssh(String),
#[error("unrecognized key format (expected an armored PGP key or an OpenSSH public key)")]
Unrecognized,
}
#[derive(Default)]
pub struct TrustedKeys {
pgp: Vec<SignedPublicKey>,
ssh: Vec<ssh_key::PublicKey>,
}
impl TrustedKeys {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.pgp.is_empty() && self.ssh.is_empty()
}
pub fn add_key_text(&mut self, text: &str) -> Result<(), TrustedKeyError> {
let trimmed = text.trim_start();
if trimmed.starts_with("-----BEGIN PGP") {
self.add_pgp_armored(text)
} else if is_openssh_public_key(trimmed) {
self.add_ssh_openssh(text)
} else {
Err(TrustedKeyError::Unrecognized)
}
}
pub fn add_pgp_armored(&mut self, armored: &str) -> Result<(), TrustedKeyError> {
let (key, _headers) = SignedPublicKey::from_armor_single(armored.as_bytes())
.map_err(|e| TrustedKeyError::Pgp(e.to_string()))?;
self.pgp.push(key);
Ok(())
}
pub fn add_ssh_openssh(&mut self, openssh: &str) -> Result<(), TrustedKeyError> {
let key = ssh_key::PublicKey::from_openssh(openssh.trim())
.map_err(|e| TrustedKeyError::Ssh(e.to_string()))?;
self.ssh.push(key);
Ok(())
}
}
fn is_openssh_public_key(line: &str) -> bool {
matches!(
line.split_whitespace().next(),
Some(
"ssh-ed25519"
| "ssh-rsa"
| "ssh-dss"
| "ecdsa-sha2-nistp256"
| "ecdsa-sha2-nistp384"
| "ecdsa-sha2-nistp521"
| "sk-ssh-ed25519@openssh.com"
| "sk-ecdsa-sha2-nistp256@openssh.com"
)
)
}
pub(crate) fn verify_commit_object(raw: &[u8], trusted: &TrustedKeys) -> Result<(), String> {
let (payload, sig) =
split_commit_signature(raw).ok_or_else(|| "commit is not signed".to_string())?;
let sig_str =
std::str::from_utf8(&sig).map_err(|_| "signature is not valid UTF-8".to_string())?;
let banner = sig_str.trim_start();
if banner.starts_with("-----BEGIN PGP SIGNATURE-----") {
verify_pgp(&payload, sig_str, trusted)
} else if banner.starts_with("-----BEGIN SSH SIGNATURE-----") {
verify_ssh(&payload, sig_str, trusted)
} else {
Err("unrecognized signature format".to_string())
}
}
fn verify_pgp(payload: &[u8], armored_sig: &str, trusted: &TrustedKeys) -> Result<(), String> {
let (sig, _headers) = DetachedSignature::from_armor_single(armored_sig.as_bytes())
.map_err(|e| format!("malformed PGP signature: {e}"))?;
if trusted.pgp.is_empty() {
return Err("no trusted PGP keys configured".to_string());
}
for key in &trusted.pgp {
if sig.verify(key, payload).is_ok() {
return Ok(());
}
for subkey in &key.public_subkeys {
if sig.verify(subkey, payload).is_ok() {
return Ok(());
}
}
}
Err("signature is not valid for any trusted PGP key".to_string())
}
fn verify_ssh(payload: &[u8], pem_sig: &str, trusted: &TrustedKeys) -> Result<(), String> {
let sshsig =
ssh_key::SshSig::from_pem(pem_sig).map_err(|e| format!("malformed SSH signature: {e}"))?;
if trusted.ssh.is_empty() {
return Err("no trusted SSH keys configured".to_string());
}
for key in &trusted.ssh {
if key.verify(SSH_NAMESPACE, payload, &sshsig).is_ok() {
return Ok(());
}
}
Err("signature is not valid for any trusted SSH key".to_string())
}
fn split_commit_signature(raw: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
const HEADER: &[u8] = b"gpgsig ";
let mut payload = Vec::with_capacity(raw.len());
let mut sig = Vec::new();
let mut found = false;
let mut i = 0;
while i < raw.len() {
let (line, next) = read_line(raw, i);
if line.is_empty() {
payload.extend_from_slice(&raw[i..]);
return if found { Some((payload, sig)) } else { None };
}
if !found && line.starts_with(HEADER) {
found = true;
sig.extend_from_slice(&line[HEADER.len()..]);
i = next;
while i < raw.len() {
let (cont, cnext) = read_line(raw, i);
if cont.first() == Some(&b' ') {
sig.push(b'\n');
sig.extend_from_slice(&cont[1..]);
i = cnext;
} else {
break;
}
}
continue;
}
payload.extend_from_slice(line);
payload.push(b'\n');
i = next;
}
if found { Some((payload, sig)) } else { None }
}
fn read_line(raw: &[u8], start: usize) -> (&[u8], usize) {
match raw[start..].iter().position(|&b| b == b'\n') {
Some(p) => (&raw[start..start + p], start + p + 1),
None => (&raw[start..], raw.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SSH_PRIVATE: &str = "\
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBmFUP3SDH5k28ErT2na8g4asrcsI4STLcmDImAF0WjDwAAAIiFW+7uhVvu
7gAAAAtzc2gtZWQyNTUxOQAAACBmFUP3SDH5k28ErT2na8g4asrcsI4STLcmDImAF0WjDw
AAAEAgsZE1vrnYoatnjRDx6BGE9PeOViG9mgDVkCbPj8unnmYVQ/dIMfmTbwStPadryDhq
ytywjhJMtyYMiYAXRaMPAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----
";
const TEST_SSH_PUBLIC: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGYVQ/dIMfmTbwStPadryDhqytywjhJMtyYMiYAXRaMP cljrs-test";
fn assemble_signed_commit(payload: &[u8], armored_sig: &str) -> Vec<u8> {
let split = payload
.windows(2)
.position(|w| w == b"\n\n")
.expect("payload has a header/message separator");
let headers = &payload[..=split]; let message = &payload[split + 1..];
let mut out = Vec::new();
out.extend_from_slice(headers);
out.extend_from_slice(b"gpgsig ");
for (i, line) in armored_sig.lines().enumerate() {
if i > 0 {
out.push(b'\n');
out.push(b' ');
}
out.extend_from_slice(line.as_bytes());
}
out.push(b'\n');
out.extend_from_slice(message);
out
}
fn sign_payload_ssh(payload: &[u8]) -> String {
let key = ssh_key::PrivateKey::from_openssh(TEST_SSH_PRIVATE).expect("parse private key");
let sig = ssh_key::SshSig::sign(&key, SSH_NAMESPACE, ssh_key::HashAlg::Sha512, payload)
.expect("sign");
sig.to_pem(ssh_key::LineEnding::LF).expect("pem")
}
const PAYLOAD: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author Test <t@example.com> 0 +0000\n\
committer Test <t@example.com> 0 +0000\n\
\n\
signed commit\n";
#[test]
fn ssh_signed_commit_verifies_with_trusted_key() {
let pem = sign_payload_ssh(PAYLOAD);
let raw = assemble_signed_commit(PAYLOAD, &pem);
let mut trusted = TrustedKeys::new();
trusted.add_ssh_openssh(TEST_SSH_PUBLIC).unwrap();
assert!(verify_commit_object(&raw, &trusted).is_ok());
}
#[test]
fn ssh_signed_commit_fails_with_empty_trust() {
let pem = sign_payload_ssh(PAYLOAD);
let raw = assemble_signed_commit(PAYLOAD, &pem);
let err = verify_commit_object(&raw, &TrustedKeys::new()).unwrap_err();
assert!(err.contains("no trusted SSH keys"), "got: {err}");
}
#[test]
fn ssh_signed_commit_fails_with_untrusted_key() {
let pem = sign_payload_ssh(PAYLOAD);
let mut tampered = PAYLOAD.to_vec();
tampered.extend_from_slice(b"extra\n");
let raw = assemble_signed_commit(&tampered, &pem);
let mut trusted = TrustedKeys::new();
trusted.add_ssh_openssh(TEST_SSH_PUBLIC).unwrap();
assert!(verify_commit_object(&raw, &trusted).is_err());
}
#[test]
fn add_key_text_autodetects_ssh() {
let mut trusted = TrustedKeys::new();
trusted.add_key_text(TEST_SSH_PUBLIC).expect("ssh key");
assert!(!trusted.is_empty());
}
#[test]
fn unsigned_commit_has_no_signature() {
let raw = b"tree 0000000000000000000000000000000000000000\n\
author A <a@example.com> 0 +0000\n\
committer A <a@example.com> 0 +0000\n\
\n\
hello\n";
assert!(split_commit_signature(raw).is_none());
}
#[test]
fn splits_payload_and_signature() {
let raw = b"tree 0000000000000000000000000000000000000000\n\
author A <a@example.com> 0 +0000\n\
committer A <a@example.com> 0 +0000\n\
gpgsig -----BEGIN SSH SIGNATURE-----\n\
\x20line1\n\
\x20line2\n\
\x20-----END SSH SIGNATURE-----\n\
\n\
subject\n";
let (payload, sig) = split_commit_signature(raw).expect("signed");
assert!(!payload.windows(6).any(|w| w == b"gpgsig"));
assert!(payload.ends_with(b"\nsubject\n"));
assert!(payload.starts_with(b"tree "));
let sig = String::from_utf8(sig).unwrap();
assert_eq!(
sig,
"-----BEGIN SSH SIGNATURE-----\nline1\nline2\n-----END SSH SIGNATURE-----"
);
}
}