Skip to main content

cljrs_vcs/
signature.rs

1//! Native commit-signature verification.
2//!
3//! Git commits can be signed in two formats, both stored in the commit
4//! object's `gpgsig` header:
5//!
6//! * **PGP** — `-----BEGIN PGP SIGNATURE-----`, verified here with rPGP.
7//! * **SSH** — `-----BEGIN SSH SIGNATURE-----` (the `SSHSIG` format produced by
8//!   `ssh-keygen -Y sign -n git`), verified with `ssh-key`.
9//!
10//! Trust is *cljrs-managed*: a signature is accepted only when it is
11//! cryptographically valid **and** made by a key present in the caller-supplied
12//! [`TrustedKeys`]. There is no implicit fallback to the user's GPG keyring or
13//! SSH `allowed_signers` file.
14
15use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey};
16use thiserror::Error;
17
18/// The SSHSIG namespace git uses for commit/tag signatures.
19const SSH_NAMESPACE: &str = "git";
20
21/// Errors raised while loading a trusted public key.
22#[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/// A cljrs-managed set of public keys trusted to sign commits.
33///
34/// Populate it from `cljrs.edn`'s `:trusted-signers` entries (see
35/// `cljrs-deps`), then pass it to [`crate::verify_commit_signature`].
36#[derive(Default)]
37pub struct TrustedKeys {
38    pgp: Vec<SignedPublicKey>,
39    ssh: Vec<ssh_key::PublicKey>,
40}
41
42impl TrustedKeys {
43    /// An empty trust set. Any signature check against it fails.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Returns `true` when no keys are configured.
49    pub fn is_empty(&self) -> bool {
50        self.pgp.is_empty() && self.ssh.is_empty()
51    }
52
53    /// Add a trusted key from text, auto-detecting PGP vs OpenSSH format.
54    ///
55    /// * Armored PGP public-key blocks (`-----BEGIN PGP PUBLIC KEY BLOCK-----`).
56    /// * OpenSSH public keys (`ssh-ed25519 AAAA… comment`, `ecdsa-…`, `rsa-…`).
57    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    /// Add a trusted PGP public key from an armored block.
69    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    /// Add a trusted SSH public key in OpenSSH `authorized_keys` format.
77    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
85/// Returns `true` when `line` looks like an OpenSSH public key.
86fn 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
102/// Verify the signature embedded in a raw commit object (`object.data`, i.e. the
103/// decoded commit text starting with `tree …`, without the `commit <size>\0`
104/// git object header) against `trusted`.
105///
106/// Returns `Ok(())` on a valid, trusted signature; `Err(reason)` otherwise.
107pub(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
123/// Verify a PGP-signed commit payload against the trusted PGP keys.
124fn 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        // Try the primary key, then any signing subkeys.
134        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
146/// Verify an SSH-signed commit payload against the trusted SSH keys.
147fn 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        // `verify` enforces that the signing key equals this trusted key, the
157        // namespace matches, and the signature is cryptographically valid.
158        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
165/// Split a raw commit object into `(signed_payload, armored_signature)`.
166///
167/// The signed payload is the commit object with its `gpgsig` header removed —
168/// exactly the bytes git signs. The signature is reconstructed from the
169/// `gpgsig` header line and its space-prefixed continuation lines. Returns
170/// `None` when there is no `gpgsig` header.
171fn 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        // A blank line terminates the header section; copy the message verbatim.
182        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            // Consume space-prefixed continuation lines, stripping the leading space.
192            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        // Ordinary header line: keep it in the signed payload.
206        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
214/// Return the line starting at `start` (without its trailing `\n`) and the index
215/// just past the line terminator.
216fn 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    // A static Ed25519 keypair used to exercise the SSH verification path
228    // without generating keys (which would need an RNG feature). Generated once
229    // with `ssh-key`; used only in tests.
230    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    /// Build a raw commit object (`object.data` form) carrying `armored_sig` in
242    /// its `gpgsig` header. `payload` must be the no-signature commit text.
243    fn assemble_signed_commit(payload: &[u8], armored_sig: &str) -> Vec<u8> {
244        // Headers end at the first blank line; insert gpgsig as the last header.
245        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]; // includes the trailing header newline
250        let message = &payload[split + 1..]; // includes the leading blank line
251
252        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        // Tamper with the payload so the signature no longer matches.
302        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        // Payload must not contain the gpgsig header.
341        assert!(!payload.windows(6).any(|w| w == b"gpgsig"));
342        // Payload must retain the message and the other headers.
343        assert!(payload.ends_with(b"\nsubject\n"));
344        assert!(payload.starts_with(b"tree "));
345        // Signature is reassembled with continuation lines de-indented.
346        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}