Skip to content

g1t/services/repos/src/signatures.rs

275 lines12,194 bytesCodeBlame
1//! Commit signatures, for the "Require signed commits" rule.
2//!
3//! A commit signed with an SSH key carries an `SSHSIG` signature in its
4//! `gpgsig` header (git's `gpg.format ssh`). It is verified here when the
5//! key is ed25519: the signature must be valid over the commit without
6//! that header, in the `git` namespace, and the key must be registered on
7//! the g1t account that owns the committer's verified email address.
8//! Signatures with other key types and GPG signatures are reported as not
9//! verified, with why.
10
11use std::collections::HashMap;
12
13use base64::Engine;
14use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
15use ed25519_dalek::{Signature as Ed25519Signature, Verifier, VerifyingKey};
16use g1t_contracts::rules::Signature;
17use sha2::{Digest, Sha256, Sha512};
18
19/// A commit's signature as found in its object: the armored text, and the
20/// bytes it signs (the commit without its `gpgsig` header).
21#[derive(Debug, PartialEq, Eq)]
22pub struct Signed {
23 pub armored: String,
24 pub payload: Vec<u8>,
25}
26
27/// The signature in a raw commit object, if it has one.
28pub fn signed(commit: &[u8]) -> Option<Signed> {
29 let text = std::str::from_utf8(commit).ok()?;
30 let (headers, message) = text.split_once("\n\n").unwrap_or((text, ""));
31 let mut payload = String::with_capacity(text.len());
32 let mut armored = String::new();
33 let mut in_signature = false;
34 for line in headers.split('\n') {
35 if let Some(first) = line.strip_prefix("gpgsig ").or_else(|| line.strip_prefix("gpgsig-sha256 ")) {
36 in_signature = true;
37 armored.push_str(first);
38 armored.push('\n');
39 continue;
40 }
41 if in_signature && let Some(more) = line.strip_prefix(' ') {
42 armored.push_str(more);
43 armored.push('\n');
44 continue;
45 }
46 in_signature = false;
47 payload.push_str(line);
48 payload.push('\n');
49 }
50 if armored.is_empty() {
51 return None;
52 }
53 payload.push('\n');
54 payload.push_str(message);
55 Some(Signed { armored, payload: payload.into_bytes() })
56}
57
58/// What reading an SSH signature found: the key's fingerprint, as
59/// `ssh-keygen -lf` prints it, once the signature checks out.
60#[derive(Debug, PartialEq, Eq)]
61pub enum Checked {
62 /// Valid; owned by whoever registered this key.
63 Valid { fingerprint: String },
64 Invalid(String),
65}
66
67fn take<'a>(bytes: &mut &'a [u8], count: usize) -> Option<&'a [u8]> {
68 if bytes.len() < count {
69 return None;
70 }
71 let (head, rest) = bytes.split_at(count);
72 *bytes = rest;
73 Some(head)
74}
75
76fn string<'a>(bytes: &mut &'a [u8]) -> Option<&'a [u8]> {
77 let length = u32::from_be_bytes(take(bytes, 4)?.try_into().ok()?) as usize;
78 take(bytes, length)
79}
80
81fn put_string(out: &mut Vec<u8>, value: &[u8]) {
82 out.extend_from_slice(&(value.len() as u32).to_be_bytes());
83 out.extend_from_slice(value);
84}
85
86/// Checks an armored signature over `payload`.
87pub fn check(armored: &str, payload: &[u8]) -> Checked {
88 let armored = armored.trim();
89 if armored.starts_with("-----BEGIN PGP SIGNATURE-----") {
90 return Checked::Invalid("GPG signatures are not verified yet; sign with an SSH key (git config gpg.format ssh).".to_owned());
91 }
92 if armored.starts_with("-----BEGIN SIGNED MESSAGE-----") {
93 return Checked::Invalid("S/MIME signatures are not verified; sign with an SSH key (git config gpg.format ssh).".to_owned());
94 }
95 let Some(body) = armored
96 .strip_prefix("-----BEGIN SSH SIGNATURE-----")
97 .and_then(|rest| rest.trim().strip_suffix("-----END SSH SIGNATURE-----"))
98 else {
99 return Checked::Invalid("the signature is in a format g1t does not read.".to_owned());
100 };
101 let encoded: String = body.chars().filter(|c| !c.is_whitespace()).collect();
102 let Ok(blob) = STANDARD.decode(encoded) else {
103 return Checked::Invalid("the signature is not valid base64.".to_owned());
104 };
105 let invalid = |why: &str| Checked::Invalid(why.to_owned());
106 let mut rest = blob.as_slice();
107 if take(&mut rest, 6) != Some(b"SSHSIG".as_slice()) {
108 return invalid("the signature is not an SSH signature.");
109 }
110 if take(&mut rest, 4).map(|version| u32::from_be_bytes(version.try_into().unwrap_or_default())) != Some(1) {
111 return invalid("the signature's version is not one g1t reads.");
112 }
113 let (Some(public_key), Some(namespace), Some(reserved), Some(hash), Some(signature)) =
114 (string(&mut rest), string(&mut rest), string(&mut rest), string(&mut rest), string(&mut rest))
115 else {
116 return invalid("the signature is cut short.");
117 };
118 if namespace != b"git" {
119 return invalid("the signature is not for git commits (its namespace is not git).");
120 }
121 let mut key = public_key;
122 let (Some(key_type), Some(key_bytes)) = (string(&mut key), string(&mut key)) else {
123 return invalid("the signing key could not be read.");
124 };
125 if key_type != b"ssh-ed25519" {
126 return Checked::Invalid(format!(
127 "{} keys are not verified yet; sign with an ed25519 key.",
128 String::from_utf8_lossy(key_type)
129 ));
130 }
131 let mut sig = signature;
132 let (Some(sig_type), Some(sig_bytes)) = (string(&mut sig), string(&mut sig)) else {
133 return invalid("the signature could not be read.");
134 };
135 if sig_type != b"ssh-ed25519" {
136 return invalid("the signature does not match its key's type.");
137 }
138 let digest: Vec<u8> = match hash {
139 b"sha512" => Sha512::digest(payload).to_vec(),
140 b"sha256" => Sha256::digest(payload).to_vec(),
141 _ => return invalid("the signature uses a hash g1t does not read."),
142 };
143 let mut signed_data = b"SSHSIG".to_vec();
144 put_string(&mut signed_data, namespace);
145 put_string(&mut signed_data, reserved);
146 put_string(&mut signed_data, hash);
147 put_string(&mut signed_data, &digest);
148 let (Ok(key_bytes), Ok(sig_bytes)) = (<[u8; 32]>::try_from(key_bytes), <[u8; 64]>::try_from(sig_bytes)) else {
149 return invalid("the key or signature has the wrong length.");
150 };
151 let Ok(verifying) = VerifyingKey::from_bytes(&key_bytes) else {
152 return invalid("the signing key is not a valid ed25519 key.");
153 };
154 if verifying.verify(&signed_data, &Ed25519Signature::from_bytes(&sig_bytes)).is_err() {
155 return invalid("the signature does not match the commit.");
156 }
157 Checked::Valid { fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(public_key))) }
158}
159
160/// A commit's signature, decided: `key_owners` maps fingerprints to the
161/// account (user id) that registered the key, `email_owners` the
162/// committer's address to the account (id, username) that verified it.
163pub fn decide(
164 commit: &[u8],
165 committer_email: Option<&str>,
166 key_owners: &HashMap<String, String>,
167 email_owners: &HashMap<String, (String, String)>,
168) -> Signature {
169 let Some(signed) = signed(commit) else {
170 return Signature::Unsigned;
171 };
172 match check(&signed.armored, &signed.payload) {
173 Checked::Invalid(reason) => Signature::Unverified { reason },
174 Checked::Valid { fingerprint } => {
175 let Some(key_owner) = key_owners.get(&fingerprint) else {
176 return Signature::Unverified {
177 reason: format!("the key {fingerprint} is not registered on any g1t account."),
178 };
179 };
180 let email = committer_email.unwrap_or_default().to_lowercase();
181 match email_owners.get(&email) {
182 Some((id, username)) if id == key_owner => Signature::Verified { signer: username.clone() },
183 Some(_) => Signature::Unverified {
184 reason: format!("the key is not registered on the account that owns {email}."),
185 },
186 None => Signature::Unverified { reason: format!("{email} is not a verified email address on g1t.") },
187 }
188 }
189 }
190}
191
192/// The fingerprint a valid SSH signature was made with, to look up its
193/// owner; `None` for anything else.
194pub fn fingerprint(commit: &[u8]) -> Option<String> {
195 let signed = signed(commit)?;
196 match check(&signed.armored, &signed.payload) {
197 Checked::Valid { fingerprint } => Some(fingerprint),
198 Checked::Invalid(_) => None,
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 /// Made with `ssh-keygen -Y sign -n git` and a throwaway ed25519 key.
207 const PAYLOAD: &str = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Ada <ada@acme.com> 1759800000 +0000\ncommitter Ada <ada@acme.com> 1759800000 +0000\n\nAdd rules\n";
208 const SIGNATURE: &str = "-----BEGIN SSH SIGNATURE-----
209U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgtVGsjkmUevm9NOrwhStbNZ7Njn
210RXkKOw20fds1RA0lwAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
211AAAAQHcrDRd94FoOk8mWAMZL9v2urJlYdG5OVKiwlbVcgFuc9qmOeP+8ivMA4duwWx0N8P
212NP89FV6u51GZJ+01SZ5Ag=
213-----END SSH SIGNATURE-----";
214 const PUBLIC_KEY: &str = "AAAAC3NzaC1lZDI1NTE5AAAAILVRrI5JlHr5vTTq8IUrWzWezY50V5CjsNtH3bNUQNJc";
215
216 /// The commit as git writes it: the signature in a `gpgsig` header,
217 /// each line after its first indented by a space.
218 fn commit(message: &str) -> Vec<u8> {
219 let (headers, _) = PAYLOAD.split_once("\n\n").unwrap();
220 let sig = SIGNATURE.lines().collect::<Vec<_>>().join("\n ");
221 format!("{headers}\ngpgsig {sig}\n\n{message}").into_bytes()
222 }
223
224 fn fingerprint_of_key() -> String {
225 format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(STANDARD.decode(PUBLIC_KEY).unwrap())))
226 }
227
228 #[test]
229 fn the_payload_is_the_commit_without_its_signature() {
230 let found = signed(&commit("Add rules\n")).unwrap();
231 assert_eq!(String::from_utf8(found.payload).unwrap(), PAYLOAD);
232 assert!(found.armored.starts_with("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH"));
233 assert_eq!(signed(PAYLOAD.as_bytes()), None);
234 }
235
236 #[test]
237 fn a_good_ed25519_signature_checks_out() {
238 assert_eq!(
239 check(SIGNATURE, PAYLOAD.as_bytes()),
240 Checked::Valid { fingerprint: fingerprint_of_key() }
241 );
242 assert_eq!(fingerprint(&commit("Add rules\n")), Some(fingerprint_of_key()));
243 }
244
245 #[test]
246 fn a_changed_commit_does_not() {
247 assert_eq!(
248 check(SIGNATURE, b"tree 0000\n\nSomething else\n"),
249 Checked::Invalid("the signature does not match the commit.".into())
250 );
251 assert_eq!(fingerprint(&commit("Add rules, sneakily\n")), None);
252 }
253
254 #[test]
255 fn gpg_and_unknown_formats_are_not_verified() {
256 assert!(matches!(check("-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----", b"x"), Checked::Invalid(why) if why.starts_with("GPG signatures")));
257 assert!(matches!(check("junk", b"x"), Checked::Invalid(_)));
258 assert!(matches!(check("-----BEGIN SSH SIGNATURE-----\n!!!\n-----END SSH SIGNATURE-----", b"x"), Checked::Invalid(_)));
259 }
260
261 #[test]
262 fn verified_means_the_key_belongs_to_whoever_owns_the_committer_address() {
263 let commit = commit("Add rules\n");
264 let mut keys = HashMap::new();
265 let mut emails = HashMap::new();
266 assert!(matches!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not registered")));
267 keys.insert(fingerprint_of_key(), "usr_ada".to_owned());
268 assert!(matches!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not a verified email")));
269 emails.insert("ada@acme.com".to_owned(), ("usr_eve".to_owned(), "eve".to_owned()));
270 assert!(matches!(decide(&commit, Some("Ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not registered on the account")));
271 emails.insert("ada@acme.com".to_owned(), ("usr_ada".to_owned(), "ada".to_owned()));
272 assert_eq!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Verified { signer: "ada".into() });
273 assert_eq!(decide(PAYLOAD.as_bytes(), Some("ada@acme.com"), &keys, &emails), Signature::Unsigned);
274 }
275}