1use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey};
16use thiserror::Error;
17
18const SSH_NAMESPACE: &str = "git";
20
21#[derive(Debug, Error)]
23pub enum TrustedKeyError {
24 #[error("invalid PGP public key: {0}")]
25 Pgp(String),
26 #[error("invalid SSH public key: {0}")]
27 Ssh(String),
28 #[error("unrecognized key format (expected an armored PGP key or an OpenSSH public key)")]
29 Unrecognized,
30}
31
32#[derive(Default)]
37pub struct TrustedKeys {
38 pgp: Vec<SignedPublicKey>,
39 ssh: Vec<ssh_key::PublicKey>,
40}
41
42impl TrustedKeys {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn is_empty(&self) -> bool {
50 self.pgp.is_empty() && self.ssh.is_empty()
51 }
52
53 pub fn add_key_text(&mut self, text: &str) -> Result<(), TrustedKeyError> {
58 let trimmed = text.trim_start();
59 if trimmed.starts_with("-----BEGIN PGP") {
60 self.add_pgp_armored(text)
61 } else if is_openssh_public_key(trimmed) {
62 self.add_ssh_openssh(text)
63 } else {
64 Err(TrustedKeyError::Unrecognized)
65 }
66 }
67
68 pub fn add_pgp_armored(&mut self, armored: &str) -> Result<(), TrustedKeyError> {
70 let (key, _headers) = SignedPublicKey::from_armor_single(armored.as_bytes())
71 .map_err(|e| TrustedKeyError::Pgp(e.to_string()))?;
72 self.pgp.push(key);
73 Ok(())
74 }
75
76 pub fn add_ssh_openssh(&mut self, openssh: &str) -> Result<(), TrustedKeyError> {
78 let key = ssh_key::PublicKey::from_openssh(openssh.trim())
79 .map_err(|e| TrustedKeyError::Ssh(e.to_string()))?;
80 self.ssh.push(key);
81 Ok(())
82 }
83}
84
85fn is_openssh_public_key(line: &str) -> bool {
87 matches!(
88 line.split_whitespace().next(),
89 Some(
90 "ssh-ed25519"
91 | "ssh-rsa"
92 | "ssh-dss"
93 | "ecdsa-sha2-nistp256"
94 | "ecdsa-sha2-nistp384"
95 | "ecdsa-sha2-nistp521"
96 | "sk-ssh-ed25519@openssh.com"
97 | "sk-ecdsa-sha2-nistp256@openssh.com"
98 )
99 )
100}
101
102pub(crate) fn verify_commit_object(raw: &[u8], trusted: &TrustedKeys) -> Result<(), String> {
108 let (payload, sig) =
109 split_commit_signature(raw).ok_or_else(|| "commit is not signed".to_string())?;
110 let sig_str =
111 std::str::from_utf8(&sig).map_err(|_| "signature is not valid UTF-8".to_string())?;
112 let banner = sig_str.trim_start();
113
114 if banner.starts_with("-----BEGIN PGP SIGNATURE-----") {
115 verify_pgp(&payload, sig_str, trusted)
116 } else if banner.starts_with("-----BEGIN SSH SIGNATURE-----") {
117 verify_ssh(&payload, sig_str, trusted)
118 } else {
119 Err("unrecognized signature format".to_string())
120 }
121}
122
123fn verify_pgp(payload: &[u8], armored_sig: &str, trusted: &TrustedKeys) -> Result<(), String> {
125 let (sig, _headers) = DetachedSignature::from_armor_single(armored_sig.as_bytes())
126 .map_err(|e| format!("malformed PGP signature: {e}"))?;
127
128 if trusted.pgp.is_empty() {
129 return Err("no trusted PGP keys configured".to_string());
130 }
131
132 for key in &trusted.pgp {
133 if sig.verify(key, payload).is_ok() {
135 return Ok(());
136 }
137 for subkey in &key.public_subkeys {
138 if sig.verify(subkey, payload).is_ok() {
139 return Ok(());
140 }
141 }
142 }
143 Err("signature is not valid for any trusted PGP key".to_string())
144}
145
146fn verify_ssh(payload: &[u8], pem_sig: &str, trusted: &TrustedKeys) -> Result<(), String> {
148 let sshsig =
149 ssh_key::SshSig::from_pem(pem_sig).map_err(|e| format!("malformed SSH signature: {e}"))?;
150
151 if trusted.ssh.is_empty() {
152 return Err("no trusted SSH keys configured".to_string());
153 }
154
155 for key in &trusted.ssh {
156 if key.verify(SSH_NAMESPACE, payload, &sshsig).is_ok() {
159 return Ok(());
160 }
161 }
162 Err("signature is not valid for any trusted SSH key".to_string())
163}
164
165fn split_commit_signature(raw: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
172 const HEADER: &[u8] = b"gpgsig ";
173 let mut payload = Vec::with_capacity(raw.len());
174 let mut sig = Vec::new();
175 let mut found = false;
176 let mut i = 0;
177
178 while i < raw.len() {
179 let (line, next) = read_line(raw, i);
180
181 if line.is_empty() {
183 payload.extend_from_slice(&raw[i..]);
184 return if found { Some((payload, sig)) } else { None };
185 }
186
187 if !found && line.starts_with(HEADER) {
188 found = true;
189 sig.extend_from_slice(&line[HEADER.len()..]);
190 i = next;
191 while i < raw.len() {
193 let (cont, cnext) = read_line(raw, i);
194 if cont.first() == Some(&b' ') {
195 sig.push(b'\n');
196 sig.extend_from_slice(&cont[1..]);
197 i = cnext;
198 } else {
199 break;
200 }
201 }
202 continue;
203 }
204
205 payload.extend_from_slice(line);
207 payload.push(b'\n');
208 i = next;
209 }
210
211 if found { Some((payload, sig)) } else { None }
212}
213
214fn read_line(raw: &[u8], start: usize) -> (&[u8], usize) {
217 match raw[start..].iter().position(|&b| b == b'\n') {
218 Some(p) => (&raw[start..start + p], start + p + 1),
219 None => (&raw[start..], raw.len()),
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 const TEST_SSH_PRIVATE: &str = "\
231-----BEGIN OPENSSH PRIVATE KEY-----
232b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
233QyNTUxOQAAACBmFUP3SDH5k28ErT2na8g4asrcsI4STLcmDImAF0WjDwAAAIiFW+7uhVvu
2347gAAAAtzc2gtZWQyNTUxOQAAACBmFUP3SDH5k28ErT2na8g4asrcsI4STLcmDImAF0WjDw
235AAAEAgsZE1vrnYoatnjRDx6BGE9PeOViG9mgDVkCbPj8unnmYVQ/dIMfmTbwStPadryDhq
236ytywjhJMtyYMiYAXRaMPAAAAAAECAwQF
237-----END OPENSSH PRIVATE KEY-----
238";
239 const TEST_SSH_PUBLIC: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGYVQ/dIMfmTbwStPadryDhqytywjhJMtyYMiYAXRaMP cljrs-test";
240
241 fn assemble_signed_commit(payload: &[u8], armored_sig: &str) -> Vec<u8> {
244 let split = payload
246 .windows(2)
247 .position(|w| w == b"\n\n")
248 .expect("payload has a header/message separator");
249 let headers = &payload[..=split]; let message = &payload[split + 1..]; let mut out = Vec::new();
253 out.extend_from_slice(headers);
254 out.extend_from_slice(b"gpgsig ");
255 for (i, line) in armored_sig.lines().enumerate() {
256 if i > 0 {
257 out.push(b'\n');
258 out.push(b' ');
259 }
260 out.extend_from_slice(line.as_bytes());
261 }
262 out.push(b'\n');
263 out.extend_from_slice(message);
264 out
265 }
266
267 fn sign_payload_ssh(payload: &[u8]) -> String {
268 let key = ssh_key::PrivateKey::from_openssh(TEST_SSH_PRIVATE).expect("parse private key");
269 let sig = ssh_key::SshSig::sign(&key, SSH_NAMESPACE, ssh_key::HashAlg::Sha512, payload)
270 .expect("sign");
271 sig.to_pem(ssh_key::LineEnding::LF).expect("pem")
272 }
273
274 const PAYLOAD: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
275 author Test <t@example.com> 0 +0000\n\
276 committer Test <t@example.com> 0 +0000\n\
277 \n\
278 signed commit\n";
279
280 #[test]
281 fn ssh_signed_commit_verifies_with_trusted_key() {
282 let pem = sign_payload_ssh(PAYLOAD);
283 let raw = assemble_signed_commit(PAYLOAD, &pem);
284
285 let mut trusted = TrustedKeys::new();
286 trusted.add_ssh_openssh(TEST_SSH_PUBLIC).unwrap();
287 assert!(verify_commit_object(&raw, &trusted).is_ok());
288 }
289
290 #[test]
291 fn ssh_signed_commit_fails_with_empty_trust() {
292 let pem = sign_payload_ssh(PAYLOAD);
293 let raw = assemble_signed_commit(PAYLOAD, &pem);
294 let err = verify_commit_object(&raw, &TrustedKeys::new()).unwrap_err();
295 assert!(err.contains("no trusted SSH keys"), "got: {err}");
296 }
297
298 #[test]
299 fn ssh_signed_commit_fails_with_untrusted_key() {
300 let pem = sign_payload_ssh(PAYLOAD);
301 let mut tampered = PAYLOAD.to_vec();
303 tampered.extend_from_slice(b"extra\n");
304 let raw = assemble_signed_commit(&tampered, &pem);
305
306 let mut trusted = TrustedKeys::new();
307 trusted.add_ssh_openssh(TEST_SSH_PUBLIC).unwrap();
308 assert!(verify_commit_object(&raw, &trusted).is_err());
309 }
310
311 #[test]
312 fn add_key_text_autodetects_ssh() {
313 let mut trusted = TrustedKeys::new();
314 trusted.add_key_text(TEST_SSH_PUBLIC).expect("ssh key");
315 assert!(!trusted.is_empty());
316 }
317
318 #[test]
319 fn unsigned_commit_has_no_signature() {
320 let raw = b"tree 0000000000000000000000000000000000000000\n\
321 author A <a@example.com> 0 +0000\n\
322 committer A <a@example.com> 0 +0000\n\
323 \n\
324 hello\n";
325 assert!(split_commit_signature(raw).is_none());
326 }
327
328 #[test]
329 fn splits_payload_and_signature() {
330 let raw = b"tree 0000000000000000000000000000000000000000\n\
331 author A <a@example.com> 0 +0000\n\
332 committer A <a@example.com> 0 +0000\n\
333 gpgsig -----BEGIN SSH SIGNATURE-----\n\
334 \x20line1\n\
335 \x20line2\n\
336 \x20-----END SSH SIGNATURE-----\n\
337 \n\
338 subject\n";
339 let (payload, sig) = split_commit_signature(raw).expect("signed");
340 assert!(!payload.windows(6).any(|w| w == b"gpgsig"));
342 assert!(payload.ends_with(b"\nsubject\n"));
344 assert!(payload.starts_with(b"tree "));
345 let sig = String::from_utf8(sig).unwrap();
347 assert_eq!(
348 sig,
349 "-----BEGIN SSH SIGNATURE-----\nline1\nline2\n-----END SSH SIGNATURE-----"
350 );
351 }
352}