1use base64::Engine;
17use sha1::{Digest, Sha1};
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct KeyInfo {
22 pub fingerprint: String,
24 pub key_id: String,
26 pub created: i64,
28 pub expires: Option<i64>,
30 pub algorithm: u8,
32 pub revoked: bool,
33}
34
35#[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 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
55pub 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
72fn dearmor(input: &[u8]) -> Result<Vec<u8>, String> {
74 let Ok(text) = std::str::from_utf8(input) else {
75 return Ok(input.to_vec()); };
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; }
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
179fn 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)?; }
193 }
194 16 | 20 => {
195 for _ in 0..3 {
196 at += mpi(at)?; }
198 }
199 17 => {
200 for _ in 0..4 {
201 at += mpi(at)?; }
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 let kl = usize::from(*m.get(at).ok_or("Truncated key material")?);
211 at += 1 + kl;
212 }
213 }
214 25 | 27 | 28 => {
215 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
258struct 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
310pub 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 enum Target {
320 Primary,
321 Subkey,
322 Other,
323 }
324 let mut target = Target::Other;
325 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; }
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 (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 (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 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 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 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 let v5 = [0xc6u8, 0x04, 5, 0, 0, 0];
465 assert!(inspect(&v5).unwrap_err().contains("Version 5"));
466 assert!(inspect(&[0xc6, 0x05, 4]).is_err());
468 assert!(inspect(&[0xc6, 0xe0, 4]).is_err());
469 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}