Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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 | ||
| 11 | use std::collections::HashMap; | |
| 12 | ||
| 13 | use base64::Engine; | |
| 14 | use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD}; | |
| 15 | use ed25519_dalek::{Signature as Ed25519Signature, Verifier, VerifyingKey}; | |
| 16 | use g1t_contracts::rules::Signature; | |
| 17 | use 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)] | |
| 22 | pub 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. | |
| 28 | pub 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)] | |
| 61 | pub enum Checked { | |
| 62 | /// Valid; owned by whoever registered this key. | |
| 63 | Valid { fingerprint: String }, | |
| 64 | Invalid(String), | |
| 65 | } | |
| 66 | ||
| 67 | fn 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 | ||
| 76 | fn 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 | ||
| 81 | fn 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`. | |
| 87 | pub 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. | |
| 163 | pub 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. | |
| 194 | pub 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)] | |
| 203 | mod 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----- | |
| 209 | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgtVGsjkmUevm9NOrwhStbNZ7Njn | |
| 210 | RXkKOw20fds1RA0lwAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 | |
| 211 | AAAAQHcrDRd94FoOk8mWAMZL9v2urJlYdG5OVKiwlbVcgFuc9qmOeP+8ivMA4duwWx0N8P | |
| 212 | NP89FV6u51GZJ+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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.