Skip to main content

verify_trust/
pgp_exempt.rs

1//! Exemption keyring for platform-signed commits.
2//!
3//! GitHub signs the commits it creates itself — web-UI merge and squash
4//! commits, Dependabot commits — with **PGP** (its `web-flow` key), not with
5//! a contributor's sshsig. Those commits can never carry a DID signature, so
6//! a repository that wants `verify-trust` as a required check needs a policy
7//! for them.
8//!
9//! The policy here is cryptographic, never name-based: the repository commits
10//! an armored keyring of explicitly trusted platform keys (e.g. the key from
11//! <https://github.com/web-flow.gpg>), and a PGP-signed commit passes only if
12//! its signature verifies against one of those keys. Committer names and
13//! emails are attacker-chosen strings and play no part. With no keyring
14//! configured, every PGP-signed commit fails — absence of configuration is
15//! the most restrictive interpretation.
16//!
17//! Verification is pure Rust (rPGP); no `gpg` binary is involved.
18
19use anyhow::{Context, Result, bail};
20use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey};
21use pgp::types::KeyDetails;
22use std::path::Path;
23
24/// An armored set of platform keys whose signatures exempt a commit from the
25/// DID-signature requirement.
26pub struct ExemptKeyring {
27    keys: Vec<SignedPublicKey>,
28}
29
30impl ExemptKeyring {
31    /// Parse an armored keyring: either one armor block holding several keys
32    /// (`gpg --export --armor keyA keyB`) or several armor blocks
33    /// concatenated (`cat a.asc b.asc`).
34    pub fn from_armored(text: &str) -> Result<Self> {
35        const BLOCK_START: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
36        let mut keys = Vec::new();
37        for block in text.split(BLOCK_START).skip(1) {
38            let block = format!("{BLOCK_START}{block}");
39            let (parsed, _headers) = SignedPublicKey::from_string_many(&block)
40                .context("exempt keyring did not parse")?;
41            keys.extend(
42                parsed
43                    .collect::<std::result::Result<Vec<_>, _>>()
44                    .context("exempt keyring contains an invalid key")?,
45            );
46        }
47        if keys.is_empty() {
48            bail!("exempt keyring contains no keys");
49        }
50        Ok(Self { keys })
51    }
52
53    /// Load a keyring file.
54    pub fn load(path: &Path) -> Result<Self> {
55        let text = std::fs::read_to_string(path)
56            .with_context(|| format!("cannot read exempt keyring {}", path.display()))?;
57        Self::from_armored(&text)
58    }
59
60    /// Verify a detached, armored PGP signature over `payload` against the
61    /// keyring. Returns the hex fingerprint of the matching primary key, or a
62    /// description of why nothing matched.
63    pub fn verify(&self, armored_signature: &str, payload: &[u8]) -> Result<String, String> {
64        let (signature, _headers) = DetachedSignature::from_string(armored_signature)
65            .map_err(|e| format!("PGP signature did not parse: {e}"))?;
66
67        for key in &self.keys {
68            let fingerprint = hex::encode(key.primary_key.fingerprint().as_bytes());
69            if signature.verify(key, payload).is_ok() {
70                return Ok(fingerprint);
71            }
72            for subkey in &key.public_subkeys {
73                if signature.verify(subkey, payload).is_ok() {
74                    return Ok(fingerprint);
75                }
76            }
77        }
78        Err("signature matches no key in the exempt keyring".to_string())
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    #![allow(clippy::unwrap_used, clippy::expect_used)]
85
86    use super::*;
87    use pgp::composed::{ArmorOptions, KeyType, SecretKeyParamsBuilder, SignedSecretKey};
88    use pgp::crypto::hash::HashAlgorithm;
89    use pgp::types::Password;
90    use rand::SeedableRng;
91    use rand::rngs::StdRng;
92
93    fn test_key(seed: u64, user: &str) -> SignedSecretKey {
94        let mut rng = StdRng::seed_from_u64(seed);
95        SecretKeyParamsBuilder::default()
96            .key_type(KeyType::Ed25519)
97            .can_sign(true)
98            .primary_user_id(user.to_string())
99            .build()
100            .unwrap()
101            .generate(&mut rng)
102            .unwrap()
103    }
104
105    fn armored_public(key: &SignedSecretKey) -> String {
106        SignedPublicKey::from(key.clone())
107            .to_armored_string(ArmorOptions::default())
108            .unwrap()
109    }
110
111    fn sign(key: &SignedSecretKey, payload: &[u8]) -> String {
112        let mut rng = StdRng::seed_from_u64(42);
113        DetachedSignature::sign_binary_data(
114            &mut rng,
115            &key.primary_key,
116            &Password::empty(),
117            HashAlgorithm::Sha256,
118            payload,
119        )
120        .unwrap()
121        .to_armored_string(ArmorOptions::default())
122        .unwrap()
123    }
124
125    #[test]
126    fn signature_by_a_keyring_key_is_exempt() {
127        let key = test_key(1, "platform@example.com");
128        let keyring = ExemptKeyring::from_armored(&armored_public(&key)).unwrap();
129        let payload = b"tree abc\n\nmerge commit";
130
131        let fingerprint = keyring.verify(&sign(&key, payload), payload).unwrap();
132        assert_eq!(
133            fingerprint,
134            hex::encode(key.primary_key.fingerprint().as_bytes())
135        );
136    }
137
138    #[test]
139    fn signature_by_an_unknown_key_is_rejected() {
140        let trusted = test_key(1, "platform@example.com");
141        let attacker = test_key(2, "attacker@example.com");
142        let keyring = ExemptKeyring::from_armored(&armored_public(&trusted)).unwrap();
143        let payload = b"tree abc\n\nmerge commit";
144
145        assert!(keyring.verify(&sign(&attacker, payload), payload).is_err());
146    }
147
148    #[test]
149    fn tampered_payload_is_rejected() {
150        let key = test_key(1, "platform@example.com");
151        let keyring = ExemptKeyring::from_armored(&armored_public(&key)).unwrap();
152        let signature = sign(&key, b"original payload");
153
154        assert!(keyring.verify(&signature, b"tampered payload").is_err());
155    }
156
157    #[test]
158    fn multi_key_keyring_matches_any_member() {
159        let a = test_key(1, "a@example.com");
160        let b = test_key(2, "b@example.com");
161        let both = format!("{}{}", armored_public(&a), armored_public(&b));
162        let keyring = ExemptKeyring::from_armored(&both).unwrap();
163        let payload = b"payload";
164
165        assert!(keyring.verify(&sign(&b, payload), payload).is_ok());
166    }
167
168    #[test]
169    fn empty_or_garbage_keyring_is_a_hard_error() {
170        assert!(ExemptKeyring::from_armored("").is_err());
171        assert!(ExemptKeyring::from_armored("not a keyring").is_err());
172    }
173}