1use 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#[derive(Debug)]
22pub struct VerifiedCommit {
23 pub signer_key: Ed25519PublicKey,
25}
26
27pub 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#[derive(Debug)]
90pub struct ExtractedSignature {
91 pub signature_pem: String,
93 pub signed_payload: String,
95}
96
97pub 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
153fn 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 blob.extend_from_slice(b"SSHSIG");
190
191 blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
193 blob.extend_from_slice(namespace.as_bytes());
194
195 blob.extend_from_slice(&0u32.to_be_bytes());
197
198 blob.extend_from_slice(&(hash_algorithm.len() as u32).to_be_bytes());
200 blob.extend_from_slice(hash_algorithm.as_bytes());
201
202 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}