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            // Through the provider port like Ed25519 above — never a direct
88            // backend call, so the same verdict computes on native (ring) and
89            // WASM (WebCrypto/pure-Rust p256) alike.
90            provider
91                .verify_p256(
92                    envelope.public_key.as_bytes(),
93                    &signed_data,
94                    &envelope.signature,
95                )
96                .await
97                .map_err(|_| CommitVerificationError::SignatureInvalid)?;
98        }
99    }
100
101    Ok(VerifiedCommit {
102        signer_key: envelope.public_key,
103    })
104}
105
106/// Extracted SSH signature and signed payload from a git commit object.
107#[derive(Debug)]
108pub struct ExtractedSignature {
109    /// The SSH signature PEM block.
110    pub signature_pem: String,
111    /// The commit content with the gpgsig header removed (the signed payload).
112    pub signed_payload: String,
113}
114
115/// Extract the SSH signature PEM and signed payload from a raw git commit object.
116///
117/// The signed payload is the commit object with the `gpgsig` header block removed,
118/// preserving exact byte content including trailing newlines.
119///
120/// Args:
121/// * `commit_content`: The raw commit object as a string.
122///
123/// Usage:
124/// ```ignore
125/// let extracted = extract_ssh_signature(content)?;
126/// ```
127pub fn extract_ssh_signature(
128    commit_content: &str,
129) -> Result<ExtractedSignature, CommitVerificationError> {
130    if !commit_content.contains("-----BEGIN SSH SIGNATURE-----") {
131        return Err(CommitVerificationError::UnsignedCommit);
132    }
133
134    let mut sig_lines: Vec<&str> = Vec::new();
135    let mut payload = String::with_capacity(commit_content.len());
136    let mut in_sig = false;
137
138    let mut remaining = commit_content;
139    while !remaining.is_empty() {
140        let (line_with_nl, rest) = match remaining.find('\n') {
141            Some(i) => (&remaining[..=i], &remaining[i + 1..]),
142            None => (remaining, ""),
143        };
144        remaining = rest;
145
146        let line = line_with_nl.strip_suffix('\n').unwrap_or(line_with_nl);
147
148        if line.starts_with("gpgsig ") {
149            in_sig = true;
150            sig_lines.push(line.strip_prefix("gpgsig ").unwrap_or(line));
151        } else if in_sig && line.starts_with(' ') {
152            sig_lines.push(line.strip_prefix(' ').unwrap_or(line));
153        } else {
154            in_sig = false;
155            payload.push_str(line_with_nl);
156        }
157    }
158
159    if sig_lines.is_empty() {
160        return Err(CommitVerificationError::UnsignedCommit);
161    }
162
163    let signature_pem = sig_lines.join("\n");
164
165    Ok(ExtractedSignature {
166        signature_pem,
167        signed_payload: payload,
168    })
169}
170
171/// Whether a raw git commit object carries an SSH signature at all.
172///
173/// This is the same `gpgsig` SSH-block detection [`extract_ssh_signature`] uses to
174/// decide a commit is unsigned, exposed as a cheap predicate so callers that only
175/// need "did a signature land?" (e.g. confirming an amend actually signed) share
176/// the verifier's single source of truth instead of re-grepping the object.
177pub fn commit_object_is_signed(commit_content: &str) -> bool {
178    extract_ssh_signature(commit_content).is_ok()
179}
180
181/// Construct the SSHSIG "signed data" blob.
182///
183/// This is the data that the Ed25519 signature actually covers:
184/// ```text
185/// "SSHSIG" (6 raw bytes)
186/// string  namespace
187/// string  reserved (empty)
188/// string  hash_algorithm
189/// string  H(message)
190/// ```
191fn compute_sshsig_signed_data(
192    namespace: &str,
193    hash_algorithm: &str,
194    message: &[u8],
195) -> Result<Vec<u8>, CommitVerificationError> {
196    let hash = match hash_algorithm {
197        "sha512" => {
198            let mut hasher = Sha512::new();
199            hasher.update(message);
200            hasher.finalize().to_vec()
201        }
202        "sha256" => {
203            let mut hasher = Sha256::new();
204            hasher.update(message);
205            hasher.finalize().to_vec()
206        }
207        other => {
208            return Err(CommitVerificationError::HashAlgorithmUnsupported(
209                other.into(),
210            ));
211        }
212    };
213
214    let mut blob = Vec::new();
215
216    // Magic preamble (raw bytes, NOT length-prefixed)
217    blob.extend_from_slice(b"SSHSIG");
218
219    // Namespace
220    blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
221    blob.extend_from_slice(namespace.as_bytes());
222
223    // Reserved (empty)
224    blob.extend_from_slice(&0u32.to_be_bytes());
225
226    // Hash algorithm
227    blob.extend_from_slice(&(hash_algorithm.len() as u32).to_be_bytes());
228    blob.extend_from_slice(hash_algorithm.as_bytes());
229
230    // Hash of message
231    blob.extend_from_slice(&(hash.len() as u32).to_be_bytes());
232    blob.extend_from_slice(&hash);
233
234    Ok(blob)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    const SIGNED_COMMIT: &str = "tree abc123\n\
242        parent def456\n\
243        author Test <test@test.com> 1700000000 +0000\n\
244        committer Test <test@test.com> 1700000000 +0000\n\
245        gpgsig -----BEGIN SSH SIGNATURE-----\n \
246        U1NIU0lHAAAAAQ==\n \
247        -----END SSH SIGNATURE-----\n\
248        \n\
249        test commit message\n";
250
251    const UNSIGNED_COMMIT: &str = "tree abc123\n\
252        parent def456\n\
253        author Test <test@test.com> 1700000000 +0000\n\
254        committer Test <test@test.com> 1700000000 +0000\n\
255        \n\
256        test commit message\n";
257
258    const GPG_COMMIT: &str = "tree abc123\n\
259        gpgsig -----BEGIN PGP SIGNATURE-----\n \
260        iQEzBAAB\n \
261        -----END PGP SIGNATURE-----\n\
262        \n\
263        test commit message\n";
264
265    #[test]
266    fn extract_returns_unsigned_for_plain_commit() {
267        let err = extract_ssh_signature(UNSIGNED_COMMIT).unwrap_err();
268        assert!(matches!(err, CommitVerificationError::UnsignedCommit));
269    }
270
271    #[test]
272    fn commit_object_is_signed_matches_extract() {
273        // The predicate agrees with extract_ssh_signature on every fixture: an SSH
274        // gpgsig block is "signed"; a plain or GPG-only commit is not.
275        assert!(commit_object_is_signed(SIGNED_COMMIT));
276        assert!(!commit_object_is_signed(UNSIGNED_COMMIT));
277        assert!(!commit_object_is_signed(GPG_COMMIT));
278    }
279
280    #[test]
281    fn extract_signature_present() {
282        let result = extract_ssh_signature(SIGNED_COMMIT).unwrap();
283        assert!(result.signature_pem.contains("BEGIN SSH SIGNATURE"));
284        assert!(!result.signed_payload.contains("gpgsig"));
285        assert!(result.signed_payload.contains("tree abc123"));
286        assert!(result.signed_payload.contains("test commit message"));
287    }
288
289    #[test]
290    fn extract_preserves_trailing_newline() {
291        let result = extract_ssh_signature(SIGNED_COMMIT).unwrap();
292        assert!(result.signed_payload.ends_with('\n'));
293    }
294
295    #[test]
296    fn gpg_commit_detected_by_verify() {
297        let content = GPG_COMMIT.as_bytes();
298        let rt = tokio::runtime::Builder::new_current_thread()
299            .build()
300            .unwrap();
301        let provider = auths_crypto::RingCryptoProvider;
302        let result = rt.block_on(verify_commit_signature(content, &[], &provider, None));
303        assert!(matches!(
304            result,
305            Err(CommitVerificationError::GpgNotSupported)
306        ));
307    }
308}