1use 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#[derive(Debug)]
22pub struct VerifiedCommit {
23 pub signer_key: DevicePublicKey,
25}
26
27pub 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 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#[derive(Debug)]
108pub struct ExtractedSignature {
109 pub signature_pem: String,
111 pub signed_payload: String,
113}
114
115pub 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
171pub fn commit_object_is_signed(commit_content: &str) -> bool {
178 extract_ssh_signature(commit_content).is_ok()
179}
180
181fn 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 blob.extend_from_slice(b"SSHSIG");
218
219 blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
221 blob.extend_from_slice(namespace.as_bytes());
222
223 blob.extend_from_slice(&0u32.to_be_bytes());
225
226 blob.extend_from_slice(&(hash_algorithm.len() as u32).to_be_bytes());
228 blob.extend_from_slice(hash_algorithm.as_bytes());
229
230 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 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}