Skip to main content

auths_verifier/
commit.rs

1//! Git commit SSH signature extraction and verification.
2//!
3//! Provides native Rust verification of SSH-signed git commits,
4//! replacing the `ssh-keygen -Y verify` subprocess pipeline.
5
6use std::path::Path;
7
8use sha2::{Digest, Sha256, Sha512};
9
10use crate::commit_error::CommitVerificationError;
11use crate::core::Ed25519PublicKey;
12use crate::ssh_sig::parse_sshsig_pem;
13
14/// A successfully verified commit signature.
15///
16/// Usage:
17/// ```ignore
18/// let verified = verify_commit_signature(content, &keys, provider, None).await?;
19/// println!("Signed by: {}", hex::encode(verified.signer_key.as_bytes()));
20/// ```
21#[derive(Debug)]
22pub struct VerifiedCommit {
23    /// The Ed25519 public key that produced the valid signature.
24    pub signer_key: Ed25519PublicKey,
25}
26
27/// Verify an SSH-signed git commit against a list of allowed Ed25519 keys.
28///
29/// Args:
30/// * `commit_content`: Raw output of `git cat-file commit <sha>`.
31/// * `allowed_keys`: Ed25519 public keys authorized to sign.
32/// * `provider`: Crypto backend for Ed25519 verification.
33/// * `repo_path`: Optional path to the git repository. When provided, the
34///   verifier uses this path for any repo-relative operations instead of
35///   requiring callers to `chdir`.
36///
37/// Usage:
38/// ```ignore
39/// let verified = verify_commit_signature(content, &keys, &provider, Some(Path::new("/repo"))).await?;
40/// ```
41pub async fn verify_commit_signature(
42    commit_content: &[u8],
43    allowed_keys: &[Ed25519PublicKey],
44    provider: &dyn auths_crypto::CryptoProvider,
45    _repo_path: Option<&Path>,
46) -> Result<VerifiedCommit, CommitVerificationError> {
47    let content_str = std::str::from_utf8(commit_content)
48        .map_err(|e| CommitVerificationError::CommitParseFailed(format!("invalid UTF-8: {e}")))?;
49
50    if content_str.contains("-----BEGIN PGP SIGNATURE-----") {
51        return Err(CommitVerificationError::GpgNotSupported);
52    }
53
54    let extracted = extract_ssh_signature(content_str)?;
55    let envelope = parse_sshsig_pem(&extracted.signature_pem)?;
56
57    if envelope.namespace != "git" {
58        return Err(CommitVerificationError::NamespaceMismatch {
59            expected: "git".into(),
60            found: envelope.namespace,
61        });
62    }
63
64    if !allowed_keys.contains(&envelope.public_key) {
65        return Err(CommitVerificationError::UnknownSigner);
66    }
67
68    let signed_data = compute_sshsig_signed_data(
69        &envelope.namespace,
70        &envelope.hash_algorithm,
71        extracted.signed_payload.as_bytes(),
72    )?;
73
74    provider
75        .verify_ed25519(
76            envelope.public_key.as_bytes(),
77            &signed_data,
78            &envelope.signature,
79        )
80        .await
81        .map_err(|_| CommitVerificationError::SignatureInvalid)?;
82
83    Ok(VerifiedCommit {
84        signer_key: envelope.public_key,
85    })
86}
87
88/// Extracted SSH signature and signed payload from a git commit object.
89#[derive(Debug)]
90pub struct ExtractedSignature {
91    /// The SSH signature PEM block.
92    pub signature_pem: String,
93    /// The commit content with the gpgsig header removed (the signed payload).
94    pub signed_payload: String,
95}
96
97/// Extract the SSH signature PEM and signed payload from a raw git commit object.
98///
99/// The signed payload is the commit object with the `gpgsig` header block removed,
100/// preserving exact byte content including trailing newlines.
101///
102/// Args:
103/// * `commit_content`: The raw commit object as a string.
104///
105/// Usage:
106/// ```ignore
107/// let extracted = extract_ssh_signature(content)?;
108/// ```
109pub fn extract_ssh_signature(
110    commit_content: &str,
111) -> Result<ExtractedSignature, CommitVerificationError> {
112    if !commit_content.contains("-----BEGIN SSH SIGNATURE-----") {
113        return Err(CommitVerificationError::UnsignedCommit);
114    }
115
116    let mut sig_lines: Vec<&str> = Vec::new();
117    let mut payload = String::with_capacity(commit_content.len());
118    let mut in_sig = false;
119
120    let mut remaining = commit_content;
121    while !remaining.is_empty() {
122        let (line_with_nl, rest) = match remaining.find('\n') {
123            Some(i) => (&remaining[..=i], &remaining[i + 1..]),
124            None => (remaining, ""),
125        };
126        remaining = rest;
127
128        let line = line_with_nl.strip_suffix('\n').unwrap_or(line_with_nl);
129
130        if line.starts_with("gpgsig ") {
131            in_sig = true;
132            sig_lines.push(line.strip_prefix("gpgsig ").unwrap_or(line));
133        } else if in_sig && line.starts_with(' ') {
134            sig_lines.push(line.strip_prefix(' ').unwrap_or(line));
135        } else {
136            in_sig = false;
137            payload.push_str(line_with_nl);
138        }
139    }
140
141    if sig_lines.is_empty() {
142        return Err(CommitVerificationError::UnsignedCommit);
143    }
144
145    let signature_pem = sig_lines.join("\n");
146
147    Ok(ExtractedSignature {
148        signature_pem,
149        signed_payload: payload,
150    })
151}
152
153/// Construct the SSHSIG "signed data" blob.
154///
155/// This is the data that the Ed25519 signature actually covers:
156/// ```text
157/// "SSHSIG" (6 raw bytes)
158/// string  namespace
159/// string  reserved (empty)
160/// string  hash_algorithm
161/// string  H(message)
162/// ```
163fn compute_sshsig_signed_data(
164    namespace: &str,
165    hash_algorithm: &str,
166    message: &[u8],
167) -> Result<Vec<u8>, CommitVerificationError> {
168    let hash = match hash_algorithm {
169        "sha512" => {
170            let mut hasher = Sha512::new();
171            hasher.update(message);
172            hasher.finalize().to_vec()
173        }
174        "sha256" => {
175            let mut hasher = Sha256::new();
176            hasher.update(message);
177            hasher.finalize().to_vec()
178        }
179        other => {
180            return Err(CommitVerificationError::HashAlgorithmUnsupported(
181                other.into(),
182            ));
183        }
184    };
185
186    let mut blob = Vec::new();
187
188    // Magic preamble (raw bytes, NOT length-prefixed)
189    blob.extend_from_slice(b"SSHSIG");
190
191    // Namespace
192    blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
193    blob.extend_from_slice(namespace.as_bytes());
194
195    // Reserved (empty)
196    blob.extend_from_slice(&0u32.to_be_bytes());
197
198    // Hash algorithm
199    blob.extend_from_slice(&(hash_algorithm.len() as u32).to_be_bytes());
200    blob.extend_from_slice(hash_algorithm.as_bytes());
201
202    // Hash of message
203    blob.extend_from_slice(&(hash.len() as u32).to_be_bytes());
204    blob.extend_from_slice(&hash);
205
206    Ok(blob)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    const SIGNED_COMMIT: &str = "tree abc123\n\
214        parent def456\n\
215        author Test <test@test.com> 1700000000 +0000\n\
216        committer Test <test@test.com> 1700000000 +0000\n\
217        gpgsig -----BEGIN SSH SIGNATURE-----\n \
218        U1NIU0lHAAAAAQ==\n \
219        -----END SSH SIGNATURE-----\n\
220        \n\
221        test commit message\n";
222
223    const UNSIGNED_COMMIT: &str = "tree abc123\n\
224        parent def456\n\
225        author Test <test@test.com> 1700000000 +0000\n\
226        committer Test <test@test.com> 1700000000 +0000\n\
227        \n\
228        test commit message\n";
229
230    const GPG_COMMIT: &str = "tree abc123\n\
231        gpgsig -----BEGIN PGP SIGNATURE-----\n \
232        iQEzBAAB\n \
233        -----END PGP SIGNATURE-----\n\
234        \n\
235        test commit message\n";
236
237    #[test]
238    fn extract_returns_unsigned_for_plain_commit() {
239        let err = extract_ssh_signature(UNSIGNED_COMMIT).unwrap_err();
240        assert!(matches!(err, CommitVerificationError::UnsignedCommit));
241    }
242
243    #[test]
244    fn extract_signature_present() {
245        let result = extract_ssh_signature(SIGNED_COMMIT).unwrap();
246        assert!(result.signature_pem.contains("BEGIN SSH SIGNATURE"));
247        assert!(!result.signed_payload.contains("gpgsig"));
248        assert!(result.signed_payload.contains("tree abc123"));
249        assert!(result.signed_payload.contains("test commit message"));
250    }
251
252    #[test]
253    fn extract_preserves_trailing_newline() {
254        let result = extract_ssh_signature(SIGNED_COMMIT).unwrap();
255        assert!(result.signed_payload.ends_with('\n'));
256    }
257
258    #[test]
259    fn gpg_commit_detected_by_verify() {
260        let content = GPG_COMMIT.as_bytes();
261        let rt = tokio::runtime::Builder::new_current_thread()
262            .build()
263            .unwrap();
264        let provider = auths_crypto::RingCryptoProvider;
265        let result = rt.block_on(verify_commit_signature(content, &[], &provider, None));
266        assert!(matches!(
267            result,
268            Err(CommitVerificationError::GpgNotSupported)
269        ));
270    }
271}