Skip to main content

vault_core/
pgp.rs

1//! What an OpenPGP key says about itself: fingerprint, key id, user ids and, the
2//! reason this exists, **when it expires** (Phase 24.5, `gpg_key`).
3//!
4//! The expiry of a key is not in the key packet. It is a subpacket (type 9, "key
5//! expiration time", seconds after the key's creation) of the *self-signature*
6//! that binds a user id or the key itself, so reading it means walking the
7//! signature packets. This reads v4 public and secret key blocks, ASCII-armoured
8//! or binary, and refuses what it does not understand (v5/v6 keys, partial-length
9//! packets) rather than guessing: an expiry that is wrong is worse than none.
10//!
11//! Nothing here verifies a signature. The file is the user's own key and the
12//! answer is metadata for a reminder; a forged self-signature could only make the
13//! reminder wrong, never grant anything. Secret key material is never read: a
14//! secret-key packet's public portion is parsed and the rest is skipped.
15
16use base64::Engine;
17use sha1::{Digest, Sha1};
18
19/// One key or subkey.
20#[derive(Debug, Clone, PartialEq)]
21pub struct KeyInfo {
22    /// Upper-case hex, 40 characters.
23    pub fingerprint: String,
24    /// The last 16 hex characters of the fingerprint.
25    pub key_id: String,
26    /// Seconds since the epoch.
27    pub created: i64,
28    /// Seconds since the epoch; `None` for a key that does not expire.
29    pub expires: Option<i64>,
30    /// OpenPGP public-key algorithm number (1 RSA, 17 DSA, 18 ECDH, 19 ECDSA, 22 EdDSA).
31    pub algorithm: u8,
32    pub revoked: bool,
33}
34
35/// A primary key with its user ids and subkeys.
36#[derive(Debug, Clone, PartialEq)]
37pub struct PgpInfo {
38    pub primary: KeyInfo,
39    pub user_ids: Vec<String>,
40    pub subkeys: Vec<KeyInfo>,
41}
42
43impl PgpInfo {
44    /// The earliest expiry among the primary key and its subkeys that are not
45    /// revoked: what a reminder should count down to.
46    pub fn soonest_expiry(&self) -> Option<i64> {
47        std::iter::once(&self.primary)
48            .chain(self.subkeys.iter())
49            .filter(|k| !k.revoked)
50            .filter_map(|k| k.expires)
51            .min()
52    }
53}
54
55/// `YYYY-MM-DDTHH:MM:SSZ` for an epoch second, the form `expires_at` holds.
56pub fn iso(epoch: i64) -> String {
57    let t = time::OffsetDateTime::from_unix_timestamp(epoch)
58        .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
59    format!(
60        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
61        t.year(),
62        t.month() as u8,
63        t.day(),
64        t.hour(),
65        t.minute(),
66        t.second()
67    )
68}
69
70const MAX_PACKETS: usize = 4096;
71
72/// Armoured text or raw bytes to the packet stream.
73fn dearmor(input: &[u8]) -> Result<Vec<u8>, String> {
74    let Ok(text) = std::str::from_utf8(input) else {
75        return Ok(input.to_vec()); // binary keyring
76    };
77    if !text.contains("-----BEGIN PGP") {
78        return Ok(input.to_vec());
79    }
80    let mut body = String::new();
81    let mut in_block = false;
82    let mut in_headers = false;
83    for line in text.lines() {
84        let l = line.trim();
85        if l.starts_with("-----BEGIN PGP") {
86            in_block = true;
87            in_headers = true;
88            continue;
89        }
90        if l.starts_with("-----END PGP") {
91            break;
92        }
93        if !in_block {
94            continue;
95        }
96        if in_headers {
97            if l.is_empty() {
98                in_headers = false;
99            }
100            continue;
101        }
102        if l.starts_with('=') {
103            continue; // the CRC-24 line
104        }
105        body.push_str(l);
106    }
107    base64::engine::general_purpose::STANDARD
108        .decode(body.as_bytes())
109        .map_err(|e| format!("The armour is not valid base64: {e}"))
110}
111
112struct Packet<'a> {
113    tag: u8,
114    body: &'a [u8],
115}
116
117fn packets(data: &[u8]) -> Result<Vec<Packet<'_>>, String> {
118    let mut out = Vec::new();
119    let mut i = 0;
120    while i < data.len() {
121        if out.len() >= MAX_PACKETS {
122            return Err("The key has too many packets to read".into());
123        }
124        let b = data[i];
125        if b & 0x80 == 0 {
126            return Err("Not an OpenPGP packet stream".into());
127        }
128        i += 1;
129        let (tag, len) = if b & 0x40 != 0 {
130            let tag = b & 0x3f;
131            let first = *data.get(i).ok_or("Truncated packet header")?;
132            i += 1;
133            let len = match first {
134                0..=191 => usize::from(first),
135                192..=223 => {
136                    let second = *data.get(i).ok_or("Truncated packet header")?;
137                    i += 1;
138                    ((usize::from(first) - 192) << 8) + usize::from(second) + 192
139                }
140                255 => {
141                    let n = data.get(i..i + 4).ok_or("Truncated packet header")?;
142                    i += 4;
143                    u32::from_be_bytes([n[0], n[1], n[2], n[3]]) as usize
144                }
145                _ => return Err("Partial-length packets are not supported".into()),
146            };
147            (tag, len)
148        } else {
149            let tag = (b >> 2) & 0x0f;
150            let len = match b & 3 {
151                0 => {
152                    let n = *data.get(i).ok_or("Truncated packet header")?;
153                    i += 1;
154                    usize::from(n)
155                }
156                1 => {
157                    let n = data.get(i..i + 2).ok_or("Truncated packet header")?;
158                    i += 2;
159                    usize::from(u16::from_be_bytes([n[0], n[1]]))
160                }
161                2 => {
162                    let n = data.get(i..i + 4).ok_or("Truncated packet header")?;
163                    i += 4;
164                    u32::from_be_bytes([n[0], n[1], n[2], n[3]]) as usize
165                }
166                _ => return Err("Indeterminate-length packets are not supported".into()),
167            };
168            (tag, len)
169        };
170        let body = data
171            .get(i..i.checked_add(len).ok_or("Packet length overflows")?)
172            .ok_or("A packet runs past the end of the data")?;
173        i += len;
174        out.push(Packet { tag, body });
175    }
176    Ok(out)
177}
178
179/// Length in bytes of the public-key material after the algorithm byte, which is
180/// what a fingerprint covers and a secret key's private part follows.
181fn public_material_len(algo: u8, m: &[u8]) -> Result<usize, String> {
182    let mpi = |at: usize| -> Result<usize, String> {
183        let h = m.get(at..at + 2).ok_or("Truncated key material")?;
184        let bits = usize::from(u16::from_be_bytes([h[0], h[1]]));
185        Ok(2 + bits.div_ceil(8))
186    };
187    let mut at = 0;
188    match algo {
189        1..=3 => {
190            for _ in 0..2 {
191                at += mpi(at)?; // n, e
192            }
193        }
194        16 | 20 => {
195            for _ in 0..3 {
196                at += mpi(at)?; // p, g, y
197            }
198        }
199        17 => {
200            for _ in 0..4 {
201                at += mpi(at)?; // p, q, g, y
202            }
203        }
204        18 | 19 | 22 => {
205            let oid_len = usize::from(*m.first().ok_or("Truncated key material")?);
206            at += 1 + oid_len;
207            at += mpi(at)?;
208            if algo == 18 {
209                // KDF parameters: a length byte, then that many bytes.
210                let kl = usize::from(*m.get(at).ok_or("Truncated key material")?);
211                at += 1 + kl;
212            }
213        }
214        25 | 27 | 28 => {
215            // X25519, Ed25519, X448 etc. in the native (RFC 9580) forms: fixed size.
216            at += match algo {
217                25 | 27 => 32,
218                _ => 56,
219            };
220        }
221        a => return Err(format!("Public-key algorithm {a} is not supported")),
222    }
223    if at > m.len() {
224        return Err("Truncated key material".into());
225    }
226    Ok(at)
227}
228
229fn key_from_packet(body: &[u8]) -> Result<KeyInfo, String> {
230    let version = *body.first().ok_or("Empty key packet")?;
231    if version != 4 {
232        return Err(format!(
233            "Version {version} keys are not supported; only v4 (what gpg has made by default for years)"
234        ));
235    }
236    if body.len() < 6 {
237        return Err("Truncated key packet".into());
238    }
239    let created = i64::from(u32::from_be_bytes([body[1], body[2], body[3], body[4]]));
240    let algorithm = body[5];
241    let n = public_material_len(algorithm, &body[6..])?;
242    let public = &body[..6 + n];
243    let mut h = Sha1::new();
244    h.update([0x99]);
245    h.update((public.len() as u16).to_be_bytes());
246    h.update(public);
247    let fingerprint = hex::encode_upper(h.finalize());
248    Ok(KeyInfo {
249        key_id: fingerprint[24..].to_string(),
250        fingerprint,
251        created,
252        expires: None,
253        algorithm,
254        revoked: false,
255    })
256}
257
258/// The hashed subpackets of a v4 signature that matter here.
259struct SigFacts {
260    sig_type: u8,
261    created: i64,
262    key_expiry: Option<u32>,
263}
264
265fn signature_facts(body: &[u8]) -> Option<SigFacts> {
266    if body.first() != Some(&4) || body.len() < 6 {
267        return None;
268    }
269    let sig_type = body[1];
270    let hashed_len = usize::from(u16::from_be_bytes([body[4], body[5]]));
271    let hashed = body.get(6..6 + hashed_len)?;
272    let (mut created, mut key_expiry) = (0i64, None);
273    let mut i = 0;
274    while i < hashed.len() {
275        let first = usize::from(hashed[i]);
276        i += 1;
277        let len = match first {
278            0..=191 => first,
279            192..=254 => {
280                let second = usize::from(*hashed.get(i)?);
281                i += 1;
282                ((first - 192) << 8) + second + 192
283            }
284            _ => {
285                let n = hashed.get(i..i + 4)?;
286                i += 4;
287                u32::from_be_bytes([n[0], n[1], n[2], n[3]]) as usize
288            }
289        };
290        let sp = hashed.get(i..i.checked_add(len)?)?;
291        i += len;
292        let (&ty, data) = sp.split_first()?;
293        match ty & 0x7f {
294            2 if data.len() == 4 => {
295                created = i64::from(u32::from_be_bytes([data[0], data[1], data[2], data[3]]));
296            }
297            9 if data.len() == 4 => {
298                key_expiry = Some(u32::from_be_bytes([data[0], data[1], data[2], data[3]]));
299            }
300            _ => {}
301        }
302    }
303    Some(SigFacts {
304        sig_type,
305        created,
306        key_expiry,
307    })
308}
309
310/// Reads the first primary key in `input` (armoured or binary) with its user ids
311/// and subkeys.
312pub fn inspect(input: &[u8]) -> Result<PgpInfo, String> {
313    let data = dearmor(input)?;
314    let pk = packets(&data)?;
315    let mut primary: Option<KeyInfo> = None;
316    let mut user_ids: Vec<String> = Vec::new();
317    let mut subkeys: Vec<KeyInfo> = Vec::new();
318    // What the next signature packet refers to.
319    enum Target {
320        Primary,
321        Subkey,
322        Other,
323    }
324    let mut target = Target::Other;
325    // Newest self-signature wins for the primary key's expiry.
326    let mut primary_sig_at = i64::MIN;
327    let mut sub_sig_at: Vec<i64> = Vec::new();
328
329    for p in &pk {
330        match p.tag {
331            5 | 6 => {
332                if primary.is_some() {
333                    break; // a second primary key starts a different certificate
334                }
335                primary = Some(key_from_packet(p.body)?);
336                target = Target::Primary;
337            }
338            7 | 14 if primary.is_some() => {
339                subkeys.push(key_from_packet(p.body)?);
340                sub_sig_at.push(i64::MIN);
341                target = Target::Subkey;
342            }
343            13 if primary.is_some() => {
344                user_ids.push(String::from_utf8_lossy(p.body).into_owned());
345                target = Target::Primary;
346            }
347            2 => {
348                let Some(f) = signature_facts(p.body) else {
349                    continue;
350                };
351                match (&target, f.sig_type) {
352                    // Certification of a user id, or a direct-key signature.
353                    (Target::Primary, 0x10..=0x13 | 0x1f) => {
354                        if let Some(k) = primary.as_mut() {
355                            if f.created >= primary_sig_at {
356                                primary_sig_at = f.created;
357                                k.expires = f
358                                    .key_expiry
359                                    .filter(|s| *s > 0)
360                                    .map(|s| k.created + i64::from(s));
361                            }
362                        }
363                    }
364                    (Target::Primary, 0x20) => {
365                        if let Some(k) = primary.as_mut() {
366                            k.revoked = true;
367                        }
368                    }
369                    // Subkey binding.
370                    (Target::Subkey, 0x18) => {
371                        if let (Some(k), Some(at)) = (subkeys.last_mut(), sub_sig_at.last_mut()) {
372                            if f.created >= *at {
373                                *at = f.created;
374                                k.expires = f
375                                    .key_expiry
376                                    .filter(|s| *s > 0)
377                                    .map(|s| k.created + i64::from(s));
378                            }
379                        }
380                    }
381                    (Target::Subkey, 0x28) => {
382                        if let Some(k) = subkeys.last_mut() {
383                            k.revoked = true;
384                        }
385                    }
386                    _ => {}
387                }
388            }
389            _ => {}
390        }
391    }
392    let primary = primary.ok_or("No OpenPGP key found")?;
393    Ok(PgpInfo {
394        primary,
395        user_ids,
396        subkeys,
397    })
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    // Made by `gpg --quick-generate-key "Test User <test@example.com>" ed25519 sign 2y`
405    // then `--quick-add-key … cv25519 encr 1y`, exported with `--export --armor`; the
406    // expected values are what `gpg --list-keys --with-colons` printed for it.
407    const KEY: &str = include_str!("../tests/fixtures/pgp-ed25519.asc");
408
409    #[test]
410    fn reads_the_fingerprint_user_ids_and_both_expiries_exactly_as_gpg_does() {
411        let k = inspect(KEY.as_bytes()).unwrap();
412        assert_eq!(
413            k.primary.fingerprint,
414            "899144971A1F3431B729FE0AFF605C48E0B8A0D4"
415        );
416        assert_eq!(k.primary.key_id, "FF605C48E0B8A0D4");
417        assert_eq!(k.primary.created, 1_791_539_014);
418        assert_eq!(k.primary.expires, Some(1_854_611_014));
419        assert_eq!(k.primary.algorithm, 22);
420        assert_eq!(k.user_ids, ["Test User <test@example.com>"]);
421        assert_eq!(k.subkeys.len(), 1);
422        assert_eq!(
423            k.subkeys[0].fingerprint,
424            "829CE5E113EE851675808E8E231B4410DF551005"
425        );
426        assert_eq!(k.subkeys[0].expires, Some(1_823_075_014));
427        assert_eq!(k.subkeys[0].algorithm, 18);
428        // The reminder counts down to the sooner of the two.
429        assert_eq!(k.soonest_expiry(), Some(1_823_075_014));
430        assert_eq!(iso(1_823_075_014), "2027-10-09T09:43:34Z");
431    }
432
433    #[test]
434    fn a_key_that_never_expires_has_no_expiry_and_binary_input_reads_the_same() {
435        let k = inspect(include_bytes!("../tests/fixtures/pgp-rsa-never.asc")).unwrap();
436        assert_eq!(k.primary.expires, None);
437        assert_eq!(k.soonest_expiry(), None);
438        assert_eq!(k.primary.algorithm, 1);
439        assert_eq!(
440            k.primary.fingerprint,
441            "6719ED3DEC650A98E29B79B3C3AEA629EABC5467"
442        );
443        assert_eq!(k.primary.key_id, "C3AEA629EABC5467");
444        // The binary form of the same key.
445        let armoured =
446            std::str::from_utf8(include_bytes!("../tests/fixtures/pgp-rsa-never.asc")).unwrap();
447        let body: String = armoured
448            .lines()
449            .skip_while(|l| !l.trim().is_empty())
450            .skip(1)
451            .take_while(|l| !l.starts_with('=') && !l.starts_with("-----"))
452            .collect();
453        let raw = base64::engine::general_purpose::STANDARD
454            .decode(body)
455            .unwrap();
456        assert_eq!(inspect(&raw).unwrap().primary, k.primary);
457    }
458
459    #[test]
460    fn refuses_what_it_cannot_read_instead_of_guessing() {
461        assert!(inspect(b"hello").is_err());
462        assert!(inspect(b"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n!!!notbase64\n-----END PGP PUBLIC KEY BLOCK-----").is_err());
463        // A v5 key packet.
464        let v5 = [0xc6u8, 0x04, 5, 0, 0, 0];
465        assert!(inspect(&v5).unwrap_err().contains("Version 5"));
466        // A truncated stream and a partial-length header.
467        assert!(inspect(&[0xc6, 0x05, 4]).is_err());
468        assert!(inspect(&[0xc6, 0xe0, 4]).is_err());
469        // Garbage that begins like a packet must not panic.
470        for n in 0..64u8 {
471            let junk: Vec<u8> = (0..32u8).map(|i| 0x80 | i.wrapping_mul(n)).collect();
472            let _ = inspect(&junk);
473        }
474    }
475}