git-ents.gitmain
⌘K
foforge
signature.rs119 lines · 4.6 KB · rusthistorycomment on this file
1//! Commit signature extraction and offline verification
2//! (`gate.tip-signed`, `gate.signature-artifact`).
3//!
4//! The signature is read from the commit object's `gpgsig` header — a
5//! data artifact that replicates with the repository — and verified in
6//! pure Rust against a member's stored OpenSSH public key. Nothing here
7//! reads a push certificate, the environment, or any transport state:
8//! give this module the same bytes in any clone and it returns the same
9//! answer (`gate.signature-artifact`).
10
11use ssh_key::{PublicKey, SshSig};
12
13/// The SSHSIG namespace git signs commits under.
14const GIT_NAMESPACE: &str = "git";
15
16/// Split a raw commit object into its signed payload and its detached
17/// signature: the payload is the commit serialization with the `gpgsig`
18/// header removed, exactly the bytes git signs.
19///
20/// Returns `None` when the commit carries no `gpgsig` header — an
21/// unsigned commit.
22pub(crate) fn split_signed(raw: &[u8]) -> Option<(Vec<u8>, String)> {
23 // The header section ends at the first blank line; gpgsig is a
24 // header whose continuation lines start with a single space.
25 let header_end = raw
26 .windows(2)
27 .position(|w| w == b"\n\n")
28 .map_or(raw.len(), |i| i.saturating_add(1));
29
30 let mut sig_start = None;
31 let mut sig_end = None;
32 let mut line_start = 0usize;
33 while line_start < header_end {
34 let rest = raw.get(line_start..header_end)?;
35 let line_len = rest
36 .iter()
37 .position(|&b| b == b'\n')
38 .map_or(rest.len(), |i| i.saturating_add(1));
39 let line = rest.get(..line_len)?;
40 if sig_start.is_none() {
41 if line.starts_with(b"gpgsig ") {
42 sig_start = Some(line_start);
43 sig_end = Some(line_start.saturating_add(line_len));
44 }
45 } else if sig_end == Some(line_start) && line.starts_with(b" ") {
46 sig_end = Some(line_start.saturating_add(line_len));
47 }
48 line_start = line_start.saturating_add(line_len);
49 }
50
51 let (start, end) = (sig_start?, sig_end?);
52 let mut payload = Vec::with_capacity(raw.len().saturating_sub(end.saturating_sub(start)));
53 payload.extend_from_slice(raw.get(..start)?);
54 payload.extend_from_slice(raw.get(end..)?);
55
56 let header = raw.get(start..end)?;
57 let text = std::str::from_utf8(header).ok()?;
58 let mut sig = String::new();
59 for (i, line) in text.lines().enumerate() {
60 let value = if i == 0 {
61 line.strip_prefix("gpgsig ")?
62 } else {
63 line.strip_prefix(' ')?
64 };
65 sig.push_str(value);
66 sig.push('\n');
67 }
68 Some((payload, sig))
69}
70
71/// Whether `signature` (an armored SSHSIG) over `payload` verifies
72/// against `key` (an OpenSSH single-line public key, as stored on a
73/// [`ents_model::Member`]).
74///
75/// Any malformed key, malformed signature, wrong namespace, or failed
76/// cryptographic check is `false` — the gate treats them all as "not
77/// signed by this member", and the caller renders which member set was
78/// consulted.
79pub(crate) fn verifies(key: &str, payload: &[u8], signature: &str) -> bool {
80 let Ok(key) = PublicKey::from_openssh(key) else {
81 return false;
82 };
83 let Ok(sig) = SshSig::from_pem(signature) else {
84 return false;
85 };
86 key.verify(GIT_NAMESPACE, payload, &sig).is_ok()
87}
88
89#[cfg(test)]
90mod tests {
91 #![expect(clippy::expect_used, reason = "unit test")]
92
93 use rstest::rstest;
94
95 use super::*;
96
97 /// A hand-written commit shape: gpgsig between committer and an
98 /// extra header, with two continuation lines (each continuation
99 /// line starts with one space, as git writes multi-line headers).
100 const RAW: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n QUJD\n -----END SSH SIGNATURE-----\nother value\n\nmessage body\n";
101
102 #[rstest]
103 // @relation(gate.signature-artifact, scope=function, role=Verifies)
104 fn split_removes_exactly_the_gpgsig_header() {
105 let (payload, sig) = split_signed(RAW).expect("signed");
106 assert_eq!(
107 sig,
108 "-----BEGIN SSH SIGNATURE-----\nQUJD\n-----END SSH SIGNATURE-----\n"
109 );
110 let expected: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\nother value\n\nmessage body\n";
111 assert_eq!(payload, expected);
112 }
113
114 #[rstest]
115 fn unsigned_commit_splits_to_none() {
116 let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\n\ngpgsig in the message is not a header\n";
117 assert!(split_signed(raw).is_none());
118 }
119}