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 #[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#[derive(Debug)]
112pub struct ExtractedSignature {
113 pub signature_pem: String,
115 pub signed_payload: String,
117}
118
119pub 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
175fn 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 blob.extend_from_slice(b"SSHSIG");
212
213 blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
215 blob.extend_from_slice(namespace.as_bytes());
216
217 blob.extend_from_slice(&0u32.to_be_bytes());
219
220 blob.extend_from_slice(&(hash_algorithm.len() as u32).to_be_bytes());
222 blob.extend_from_slice(hash_algorithm.as_bytes());
223
224 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}