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