1use std::io::Write as _;
7use std::process::{Command, Stdio};
8
9use anyhow::{Context, Result, bail, ensure};
10use mailparse::{ParsedMail, parse_mail};
11
12use crate::config::Pgp;
13use crate::message;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Sig {
18 Good(String),
20 Bad(String),
22 Unknown(String),
24}
25
26struct Gpg {
29 stdout: Vec<u8>,
30 status: Vec<String>,
32 diag: String,
34 success: bool,
35}
36
37impl Gpg {
38 fn error(&self, what: &str) -> anyhow::Error {
39 let first = self.diag.lines().next().unwrap_or("").trim();
40 if first.is_empty() {
41 anyhow::anyhow!("{what}")
42 } else {
43 anyhow::anyhow!("{what}: {}", first.trim_start_matches("gpg: "))
44 }
45 }
46}
47
48fn run(cfg: &Pgp, args: &[&str], input: &[u8]) -> Result<Gpg> {
53 let spawn = || {
54 Command::new(&cfg.command)
55 .args(["--batch", "--no-tty", "--status-fd", "2"])
56 .args(args)
57 .stdin(Stdio::piped())
58 .stdout(Stdio::piped())
59 .stderr(Stdio::piped())
60 .spawn()
61 };
62 let mut child = spawn()
63 .or_else(|err| {
64 if err.raw_os_error() == Some(26) {
67 std::thread::sleep(std::time::Duration::from_millis(20));
68 spawn()
69 } else {
70 Err(err)
71 }
72 })
73 .with_context(|| format!("running {}", cfg.command))?;
74 let mut stdin = child.stdin.take().context("no stdin on gpg child")?;
75 let input = input.to_vec();
76 let writer = std::thread::spawn(move || {
79 let _ = stdin.write_all(&input);
80 });
81 let out = child.wait_with_output()?;
82 let _ = writer.join();
83 let mut status = Vec::new();
84 let mut diag = Vec::new();
85 for line in String::from_utf8_lossy(&out.stderr).lines() {
86 match line.strip_prefix("[GNUPG:] ") {
87 Some(s) => status.push(s.to_string()),
88 None => diag.push(line.to_string()),
89 }
90 }
91 Ok(Gpg {
92 stdout: out.stdout,
93 status,
94 diag: diag.join("\n"),
95 success: out.status.success(),
96 })
97}
98
99fn sig_from_status(status: &[String]) -> Option<Sig> {
100 for line in status {
101 let (kw, rest) = line.split_once(' ').unwrap_or((line.as_str(), ""));
102 let uid = || rest.split_once(' ').map_or(rest, |(_, u)| u).to_string();
104 match kw {
105 "GOODSIG" => return Some(Sig::Good(uid())),
106 "EXPKEYSIG" => return Some(Sig::Good(format!("{} (expired key)", uid()))),
107 "REVKEYSIG" => return Some(Sig::Good(format!("{} (revoked key)", uid()))),
108 "BADSIG" => return Some(Sig::Bad(uid())),
109 "ERRSIG" => {
110 let keyid = rest.split(' ').next().unwrap_or("?").to_string();
111 let missing = status.iter().any(|l| l.starts_with("NO_PUBKEY"));
112 return Some(Sig::Unknown(if missing {
113 format!("no public key {keyid}")
114 } else {
115 format!("key {keyid}")
116 }));
117 }
118 _ => {}
119 }
120 }
121 None
122}
123
124#[derive(Debug)]
127pub struct Opened {
128 pub plaintext: Vec<u8>,
129 pub sig: Option<Sig>,
130}
131
132pub fn decrypt(cfg: &Pgp, data: &[u8]) -> Result<Opened> {
136 let out = run(cfg, &["--decrypt"], data)?;
137 let sig = sig_from_status(&out.status);
138 let was_encrypted = out.status.iter().any(|s| s.starts_with("BEGIN_DECRYPTION"));
139 let decrypted = out.status.iter().any(|s| s.starts_with("DECRYPTION_OKAY"));
140 if was_encrypted && !decrypted {
141 return Err(out.error("decryption failed"));
142 }
143 if !was_encrypted && sig.is_none() && !out.success {
144 return Err(out.error("gpg failed"));
145 }
146 Ok(Opened {
147 plaintext: out.stdout,
148 sig,
149 })
150}
151
152pub fn verify_detached(cfg: &Pgp, signature: &[u8], data: &[u8]) -> Result<Sig> {
155 let sigfile = crate::scratch::write("sig", "", signature)?;
156 let result = run(
157 cfg,
158 &["--verify", &sigfile.display().to_string(), "-"],
159 data,
160 );
161 let _ = std::fs::remove_file(&sigfile);
162 let out = result?;
163 sig_from_status(&out.status).ok_or_else(|| out.error("no verdict from gpg"))
164}
165
166pub fn sign_detached(cfg: &Pgp, data: &[u8]) -> Result<(String, String)> {
169 let mut args = vec!["--armor", "--detach-sign"];
170 if let Some(key) = &cfg.sign_key {
171 args.extend(["--local-user", key.as_str()]);
172 }
173 let out = run(cfg, &args, data)?;
174 ensure!(
175 out.success && !out.stdout.is_empty(),
176 out.error("signing failed")
177 );
178 let sig = String::from_utf8(out.stdout).context("gpg produced non-UTF-8 armor")?;
179 Ok((sig, micalg(&out.status)))
180}
181
182fn micalg(status: &[String]) -> String {
185 let hash = status
186 .iter()
187 .find_map(|l| l.strip_prefix("SIG_CREATED "))
188 .and_then(|rest| rest.split(' ').nth(2))
189 .and_then(|n| n.parse::<u32>().ok());
190 let name = match hash {
191 Some(1) => "md5",
192 Some(2) => "sha1",
193 Some(3) => "ripemd160",
194 Some(9) => "sha384",
195 Some(10) => "sha512",
196 Some(11) => "sha224",
197 _ => "sha256",
198 };
199 format!("pgp-{name}")
200}
201
202pub fn encrypt(cfg: &Pgp, recipients: &[String], sign: bool, data: &[u8]) -> Result<String> {
206 let mut args = vec!["--armor", "--encrypt", "--trust-model", "always"];
207 for r in recipients {
208 args.extend(["--recipient", r.as_str()]);
209 }
210 if sign {
211 args.push("--sign");
212 if let Some(key) = &cfg.sign_key {
213 args.extend(["--local-user", key.as_str()]);
214 }
215 }
216 let out = run(cfg, &args, data)?;
217 if !out.success || out.stdout.is_empty() {
218 let missing: Vec<&str> = out
220 .status
221 .iter()
222 .filter_map(|l| l.strip_prefix("INV_RECP "))
223 .filter_map(|rest| rest.split(' ').nth(1))
224 .collect();
225 if !missing.is_empty() {
226 bail!("no key for {}", missing.join(", "));
227 }
228 return Err(out.error("encryption failed"));
229 }
230 String::from_utf8(out.stdout).context("gpg produced non-UTF-8 armor")
231}
232
233pub struct View {
239 pub body: Option<Body>,
240 pub note: String,
241}
242
243pub enum Body {
245 Text(String),
247 Entity(Vec<u8>),
250}
251
252fn note(text: &str) -> String {
253 format!("[-- PGP: {text} --]")
254}
255
256fn sig_phrase(sig: &Sig) -> String {
257 match sig {
258 Sig::Good(uid) => format!("good signature from {uid}"),
259 Sig::Bad(uid) => format!("BAD signature from {uid}"),
260 Sig::Unknown(what) => format!("signature not verified ({what})"),
261 }
262}
263
264#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
271pub struct Crypto {
272 pub signed: bool,
273 pub encrypted: bool,
274}
275
276pub fn classify(raw: &[u8]) -> Crypto {
277 let Ok(mail) = parse_mail(raw) else {
278 return Crypto::default();
279 };
280 if mail.ctype.mimetype == "multipart/encrypted" {
281 return Crypto {
282 encrypted: true,
283 signed: false,
284 };
285 }
286 if mail.ctype.mimetype == "multipart/signed"
287 && mail.ctype.params.get("protocol").map(String::as_str)
288 == Some("application/pgp-signature")
289 {
290 return Crypto {
291 signed: true,
292 encrypted: false,
293 };
294 }
295 if let Some(text) = message::extract_text(&mail) {
297 let t = text.trim_start();
298 if t.starts_with("-----BEGIN PGP MESSAGE-----") {
299 return Crypto {
300 encrypted: true,
301 signed: false,
302 };
303 }
304 if t.starts_with("-----BEGIN PGP SIGNED MESSAGE-----") {
305 return Crypto {
306 signed: true,
307 encrypted: false,
308 };
309 }
310 }
311 Crypto::default()
312}
313
314pub fn view(cfg: &Pgp, raw: &[u8]) -> Option<View> {
315 let mail = parse_mail(raw).ok()?;
316 if mail.ctype.mimetype == "multipart/encrypted" && mail.subparts.len() >= 2 {
317 return Some(view_mime_encrypted(cfg, &mail));
318 }
319 if mail.ctype.mimetype == "multipart/signed"
320 && mail.ctype.params.get("protocol").map(String::as_str)
321 == Some("application/pgp-signature")
322 && mail.subparts.len() >= 2
323 {
324 return Some(view_mime_signed(cfg, &mail));
325 }
326 let text = message::extract_text(&mail)?;
327 let trimmed = text.trim_start();
328 if trimmed.starts_with("-----BEGIN PGP MESSAGE-----") {
329 return Some(view_inline(cfg, trimmed, true));
330 }
331 if trimmed.starts_with("-----BEGIN PGP SIGNED MESSAGE-----") {
332 return Some(view_inline(cfg, trimmed, false));
333 }
334 None
335}
336
337fn view_mime_encrypted(cfg: &Pgp, mail: &ParsedMail) -> View {
338 let cipher = match mail.subparts[1].get_body_raw() {
339 Ok(c) => c,
340 Err(err) => {
341 return View {
342 body: None,
343 note: note(&format!("cannot read the encrypted part: {err}")),
344 };
345 }
346 };
347 match decrypt(cfg, &cipher) {
348 Ok(opened) => {
349 let text = match &opened.sig {
350 Some(sig) => format!("decrypted; {}", sig_phrase(sig)),
351 None => "decrypted".into(),
352 };
353 View {
354 body: Some(Body::Entity(opened.plaintext)),
356 note: note(&text),
357 }
358 }
359 Err(err) => View {
360 body: None,
361 note: note(&format!("decryption failed: {err:#}")),
362 },
363 }
364}
365
366fn view_mime_signed(cfg: &Pgp, mail: &ParsedMail) -> View {
367 let signed = crlf(mail.subparts[0].raw_bytes);
368 let sig_armor = match mail.subparts[1].get_body_raw() {
369 Ok(s) => s,
370 Err(err) => {
371 return View {
372 body: None,
373 note: note(&format!("cannot read the signature part: {err}")),
374 };
375 }
376 };
377 let verdict = verify_detached(cfg, &sig_armor, &signed).and_then(|sig| {
381 if !matches!(sig, Sig::Bad(_)) {
382 return Ok(sig);
383 }
384 let mut with_crlf = signed.clone();
385 with_crlf.extend_from_slice(b"\r\n");
386 match verify_detached(cfg, &sig_armor, &with_crlf)? {
387 good @ Sig::Good(_) => Ok(good),
388 _ => Ok(sig),
389 }
390 });
391 View {
392 body: None,
393 note: match verdict {
394 Ok(sig) => note(&sig_phrase(&sig)),
395 Err(err) => note(&format!("cannot verify signature: {err:#}")),
396 },
397 }
398}
399
400fn view_inline(cfg: &Pgp, text: &str, encrypted: bool) -> View {
401 match decrypt(cfg, text.as_bytes()) {
402 Ok(opened) => {
403 let phrase = match (&opened.sig, encrypted) {
404 (Some(sig), true) => format!("decrypted; {}", sig_phrase(sig)),
405 (Some(sig), false) => sig_phrase(sig),
406 (None, true) => "decrypted".into(),
407 (None, false) => "signed, no verdict from gpg".into(),
408 };
409 View {
410 body: Some(Body::Text(
411 String::from_utf8_lossy(&opened.plaintext).into_owned(),
412 )),
413 note: note(&phrase),
414 }
415 }
416 Err(err) => View {
417 body: None,
418 note: note(&format!(
419 "{} failed: {err:#}",
420 if encrypted {
421 "decryption"
422 } else {
423 "verification"
424 }
425 )),
426 },
427 }
428}
429
430pub fn sign_message(cfg: &Pgp, text: &str, flowed: bool) -> Result<String> {
435 let (head, body) = split_head_body(text);
436 sign_entity(cfg, head, &inner_entity(body, flowed))
437}
438
439pub fn sign_entity(cfg: &Pgp, head: &str, entity: &[u8]) -> Result<String> {
443 let (sig, micalg) = sign_detached(cfg, entity)?;
444 let b = boundary(&[entity, sig.as_bytes()]);
445 let mut out = head.trim_end().to_string();
446 out += &format!(
447 "\nMIME-Version: 1.0\nContent-Type: multipart/signed; boundary=\"{b}\";\n\tmicalg={micalg}; protocol=\"application/pgp-signature\"\n\n"
448 );
449 out += &format!("--{b}\r\n");
450 out += std::str::from_utf8(entity).expect("entity is built from str");
452 out += &format!(
453 "\r\n--{b}\r\nContent-Type: application/pgp-signature\r\n\r\n{}\r\n--{b}--\r\n",
454 sig.trim_end()
455 );
456 Ok(out)
457}
458
459pub fn encrypt_message(
464 cfg: &Pgp,
465 recipients: &[String],
466 sign: bool,
467 text: &str,
468 flowed: bool,
469) -> Result<String> {
470 let (head, body) = split_head_body(text);
471 encrypt_entity(cfg, recipients, sign, head, &inner_entity(body, flowed))
472}
473
474pub fn encrypt_entity(
476 cfg: &Pgp,
477 recipients: &[String],
478 sign: bool,
479 head: &str,
480 entity: &[u8],
481) -> Result<String> {
482 let armor = encrypt(cfg, recipients, sign, entity)?;
483 let b = boundary(&[armor.as_bytes()]);
484 let mut out = head.trim_end().to_string();
485 out += &format!(
486 "\nMIME-Version: 1.0\nContent-Type: multipart/encrypted; boundary=\"{b}\";\n\tprotocol=\"application/pgp-encrypted\"\n\n"
487 );
488 out += &format!("--{b}\r\nContent-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n");
489 out += &format!(
490 "--{b}\r\nContent-Type: application/octet-stream\r\n\r\n{}\r\n--{b}--\r\n",
491 armor.trim_end()
492 );
493 Ok(out)
494}
495
496fn split_head_body(text: &str) -> (&str, &str) {
497 text.split_once("\n\n").unwrap_or((text.trim_end(), ""))
498}
499
500fn inner_entity(body: &str, flowed: bool) -> Vec<u8> {
503 crate::compose::text_entity(body, flowed).into_bytes()
504}
505
506pub(crate) fn crlf(data: &[u8]) -> Vec<u8> {
508 let mut out = Vec::with_capacity(data.len() + 16);
509 for (i, line) in data.split(|&b| b == b'\n').enumerate() {
510 if i > 0 {
511 out.extend_from_slice(b"\r\n");
512 }
513 out.extend_from_slice(line.strip_suffix(b"\r").unwrap_or(line));
514 }
515 out
516}
517
518fn boundary(content: &[&[u8]]) -> String {
520 for n in 0.. {
521 let b = format!("=-rmut-{}-{n}", std::process::id());
522 let bb = b.as_bytes();
523 if content
524 .iter()
525 .all(|c| !c.windows(bb.len()).any(|w| w == bb))
526 {
527 return b;
528 }
529 }
530 unreachable!()
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use std::os::unix::fs::PermissionsExt;
537 use std::path::Path;
538
539 fn body_text(v: &View) -> String {
542 match v.body.as_ref().expect("a decrypted body") {
543 Body::Text(t) => t.clone(),
544 Body::Entity(raw) => String::from_utf8_lossy(raw).into_owned(),
545 }
546 }
547
548 fn stub(script: &str) -> (tempfile::TempDir, Pgp) {
552 let dir = tempfile::tempdir().unwrap();
553 let path = dir.path().join("gpg");
554 std::fs::write(&path, format!("#!/bin/sh\nD=$(dirname \"$0\")\n{script}\n")).unwrap();
555 let mut perm = std::fs::metadata(&path).unwrap().permissions();
556 perm.set_mode(0o755);
557 std::fs::set_permissions(&path, perm).unwrap();
558 let cfg = Pgp {
559 command: path.display().to_string(),
560 ..Default::default()
561 };
562 (dir, cfg)
563 }
564
565 fn scratch(dir: &Path, name: &str) -> Vec<u8> {
566 std::fs::read(dir.join(name)).unwrap()
567 }
568
569 #[test]
570 fn sig_from_status_maps_keywords() {
571 let s = |l: &str| vec![l.to_string()];
572 assert_eq!(
573 sig_from_status(&s("GOODSIG AAA Jane <j@x>")),
574 Some(Sig::Good("Jane <j@x>".into()))
575 );
576 assert_eq!(
577 sig_from_status(&s("BADSIG AAA Mallory")),
578 Some(Sig::Bad("Mallory".into()))
579 );
580 assert_eq!(
581 sig_from_status(&["ERRSIG KEY1 1 8 00 12 9".into(), "NO_PUBKEY KEY1".into()]),
582 Some(Sig::Unknown("no public key KEY1".into()))
583 );
584 assert_eq!(
585 sig_from_status(&s("EXPKEYSIG AAA Old <o@x>")),
586 Some(Sig::Good("Old <o@x> (expired key)".into()))
587 );
588 assert_eq!(sig_from_status(&s("PLAINTEXT 74 123 x")), None);
589 }
590
591 #[test]
592 fn micalg_maps_hash_numbers() {
593 assert_eq!(
594 micalg(&["SIG_CREATED D 1 8 00 12 FPR".into()]),
595 "pgp-sha256"
596 );
597 assert_eq!(
598 micalg(&["SIG_CREATED D 1 10 00 12 FPR".into()]),
599 "pgp-sha512"
600 );
601 assert_eq!(micalg(&["SIG_CREATED D 1 2 00 12 FPR".into()]), "pgp-sha1");
602 assert_eq!(micalg(&[]), "pgp-sha256");
603 }
604
605 #[test]
606 fn crlf_canonicalizes_mixed_endings() {
607 assert_eq!(crlf(b"a\nb\r\nc"), b"a\r\nb\r\nc");
608 assert_eq!(crlf(b"a\n"), b"a\r\n");
609 assert_eq!(crlf(b""), b"");
610 }
611
612 #[test]
613 fn decrypt_returns_plaintext_and_sig() {
614 let (_dir, cfg) = stub(
615 r#"cat >/dev/null
616echo "[GNUPG:] BEGIN_DECRYPTION" >&2
617echo "[GNUPG:] DECRYPTION_OKAY" >&2
618echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2
619printf 'secret'"#,
620 );
621 let opened = decrypt(&cfg, b"armor").unwrap();
622 assert_eq!(opened.plaintext, b"secret");
623 assert_eq!(opened.sig, Some(Sig::Good("Jane <j@x>".into())));
624 }
625
626 #[test]
627 fn decrypt_failure_carries_gpg_diagnostic() {
628 let (_dir, cfg) = stub(
629 r#"cat >/dev/null
630echo "[GNUPG:] BEGIN_DECRYPTION" >&2
631echo "[GNUPG:] DECRYPTION_FAILED" >&2
632echo "gpg: decryption failed: No secret key" >&2
633exit 2"#,
634 );
635 let err = decrypt(&cfg, b"armor").unwrap_err();
636 assert!(format!("{err:#}").contains("No secret key"), "{err:#}");
637 }
638
639 #[test]
640 fn verify_detached_passes_signature_file() {
641 let (dir, cfg) = stub(
642 r#"cat > "$D/data.in"
643echo "$@" > "$D/args"
644case "$*" in *--verify*) : ;; *) exit 9 ;; esac
645echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2"#,
646 );
647 let sig = verify_detached(&cfg, b"SIGDATA", b"payload").unwrap();
648 assert_eq!(sig, Sig::Good("Jane <j@x>".into()));
649 assert_eq!(scratch(dir.path(), "data.in"), b"payload");
650 let args = String::from_utf8(scratch(dir.path(), "args")).unwrap();
652 let sigfile = args
653 .split_whitespace()
654 .skip_while(|a| *a != "--verify")
655 .nth(1)
656 .unwrap();
657 assert!(!Path::new(sigfile).exists());
658 }
659
660 #[test]
661 fn sign_detached_returns_armor_and_micalg() {
662 let (dir, cfg) = stub(
663 r#"cat > "$D/signed.in"
664echo "$@" > "$D/args"
665echo "[GNUPG:] SIG_CREATED D 1 10 00 12 FPR" >&2
666printf -- '-----BEGIN PGP SIGNATURE-----\nAAA\n-----END PGP SIGNATURE-----\n'"#,
667 );
668 let cfg = Pgp {
669 sign_key: Some("jane@x".into()),
670 ..cfg
671 };
672 let (sig, micalg) = sign_detached(&cfg, b"payload").unwrap();
673 assert!(sig.contains("BEGIN PGP SIGNATURE"));
674 assert_eq!(micalg, "pgp-sha512");
675 assert_eq!(scratch(dir.path(), "signed.in"), b"payload");
676 let args = String::from_utf8(scratch(dir.path(), "args")).unwrap();
677 assert!(args.contains("--local-user jane@x"), "{args}");
678 }
679
680 #[test]
681 fn encrypt_reports_missing_keys() {
682 let (_dir, cfg) = stub(
683 r#"cat >/dev/null
684echo "[GNUPG:] INV_RECP 0 bob@nowhere" >&2
685exit 2"#,
686 );
687 let err = encrypt(&cfg, &["bob@nowhere".into()], false, b"x").unwrap_err();
688 assert!(err.to_string().contains("bob@nowhere"));
689 }
690
691 #[test]
692 fn encrypt_passes_recipients_and_sign() {
693 let (dir, cfg) = stub(
694 r#"cat >/dev/null
695echo "$@" > "$D/args"
696printf -- '-----BEGIN PGP MESSAGE-----\nCCC\n-----END PGP MESSAGE-----\n'"#,
697 );
698 let armor = encrypt(&cfg, &["bob@x".into(), "jane@x".into()], true, b"data").unwrap();
699 assert!(armor.contains("BEGIN PGP MESSAGE"));
700 let args = String::from_utf8(scratch(dir.path(), "args")).unwrap();
701 assert!(args.contains("--recipient bob@x"), "{args}");
702 assert!(args.contains("--recipient jane@x"), "{args}");
703 assert!(args.contains("--sign"), "{args}");
704 assert!(args.contains("--trust-model always"), "{args}");
705 }
706
707 const MIME_ENCRYPTED: &str = concat!(
708 "From: a@x\r\n",
709 "Subject: sealed\r\n",
710 "Content-Type: multipart/encrypted; boundary=\"b\";\r\n",
711 "\tprotocol=\"application/pgp-encrypted\"\r\n",
712 "\r\n",
713 "--b\r\n",
714 "Content-Type: application/pgp-encrypted\r\n",
715 "\r\n",
716 "Version: 1\r\n",
717 "--b\r\n",
718 "Content-Type: application/octet-stream\r\n",
719 "\r\n",
720 "-----BEGIN PGP MESSAGE-----\r\n",
721 "XYZ\r\n",
722 "-----END PGP MESSAGE-----\r\n",
723 "--b--\r\n",
724 );
725
726 #[test]
727 fn view_decrypts_pgp_mime() {
728 let (_dir, cfg) = stub(
729 r#"cat >/dev/null
730echo "[GNUPG:] BEGIN_DECRYPTION" >&2
731echo "[GNUPG:] DECRYPTION_OKAY" >&2
732echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2
733printf 'Content-Type: text/plain\r\n\r\nthe secret plan\r\n'"#,
734 );
735 let v = view(&cfg, MIME_ENCRYPTED.as_bytes()).unwrap();
736 assert!(body_text(&v).contains("the secret plan"));
737 assert!(v.note.contains("decrypted"), "{}", v.note);
738 assert!(
739 v.note.contains("good signature from Jane <j@x>"),
740 "{}",
741 v.note
742 );
743 }
744
745 #[test]
746 fn view_reports_decryption_failure_keeping_body() {
747 let (_dir, cfg) = stub(
748 r#"cat >/dev/null
749echo "[GNUPG:] BEGIN_DECRYPTION" >&2
750echo "[GNUPG:] DECRYPTION_FAILED" >&2
751exit 2"#,
752 );
753 let v = view(&cfg, MIME_ENCRYPTED.as_bytes()).unwrap();
754 assert!(v.body.is_none());
755 assert!(v.note.contains("decryption failed"), "{}", v.note);
756 }
757
758 const MIME_SIGNED: &str = concat!(
759 "From: a@x\r\n",
760 "Content-Type: multipart/signed; boundary=\"b\";\r\n",
761 "\tmicalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n",
762 "\r\n",
763 "--b\r\n",
764 "Content-Type: text/plain\r\n",
765 "\r\n",
766 "hello\r\n",
767 "signed text\r\n",
768 "--b\r\n",
769 "Content-Type: application/pgp-signature\r\n",
770 "\r\n",
771 "-----BEGIN PGP SIGNATURE-----\r\n",
772 "SSS\r\n",
773 "-----END PGP SIGNATURE-----\r\n",
774 "--b--\r\n",
775 );
776
777 #[test]
778 fn view_verifies_mime_signed_over_exact_part_bytes() {
779 let (dir, cfg) = stub(
780 r#"cat > "$D/data.in"
781echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2"#,
782 );
783 let v = view(&cfg, MIME_SIGNED.as_bytes()).unwrap();
784 assert!(v.body.is_none());
785 assert!(v.note.contains("good signature"), "{}", v.note);
786 assert_eq!(
789 scratch(dir.path(), "data.in"),
790 b"Content-Type: text/plain\r\n\r\nhello\r\nsigned text"
791 );
792 }
793
794 #[test]
795 fn view_retries_bad_signature_with_trailing_crlf() {
796 let (_dir, cfg) = stub(
797 r#"cat >/dev/null
798if [ -f "$D/second" ]; then
799 echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2
800else
801 touch "$D/second"
802 echo "[GNUPG:] BADSIG AAA Jane <j@x>" >&2
803fi"#,
804 );
805 let v = view(&cfg, MIME_SIGNED.as_bytes()).unwrap();
806 assert!(v.note.contains("good signature"), "{}", v.note);
807 }
808
809 #[test]
810 fn view_handles_inline_and_clearsigned() {
811 let (_dir, cfg) = stub(
812 r#"cat >/dev/null
813echo "[GNUPG:] BEGIN_DECRYPTION" >&2
814echo "[GNUPG:] DECRYPTION_OKAY" >&2
815printf 'inline secret'"#,
816 );
817 let msg =
818 "From: a@x\r\n\r\n-----BEGIN PGP MESSAGE-----\r\nXYZ\r\n-----END PGP MESSAGE-----\r\n";
819 let v = view(&cfg, msg.as_bytes()).unwrap();
820 assert_eq!(body_text(&v), "inline secret");
821 assert!(v.note.contains("decrypted"), "{}", v.note);
822
823 let (_dir, cfg) = stub(
824 r#"cat >/dev/null
825echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2
826printf 'stripped text'"#,
827 );
828 let msg = "From: a@x\r\n\r\n-----BEGIN PGP SIGNED MESSAGE-----\r\nHash: SHA256\r\n\r\nstripped text\r\n-----BEGIN PGP SIGNATURE-----\r\nSSS\r\n-----END PGP SIGNATURE-----\r\n";
829 let v = view(&cfg, msg.as_bytes()).unwrap();
830 assert_eq!(body_text(&v), "stripped text");
831 assert!(v.note.contains("good signature"), "{}", v.note);
832 }
833
834 #[test]
835 fn view_ignores_ordinary_mail() {
836 let cfg = Pgp {
837 command: "/nonexistent-gpg".into(),
838 ..Default::default()
839 };
840 assert!(view(&cfg, b"From: a@x\r\n\r\nplain old mail\r\n").is_none());
841 assert!(view(&cfg, MULTIPART_PLAIN.as_bytes()).is_none());
842 }
843
844 const MULTIPART_PLAIN: &str = concat!(
845 "Content-Type: multipart/mixed; boundary=\"m\"\r\n",
846 "\r\n",
847 "--m\r\n",
848 "Content-Type: text/plain\r\n",
849 "\r\n",
850 "nothing pgp here\r\n",
851 "--m--\r\n",
852 );
853
854 #[test]
857 fn sign_message_roundtrips_through_view() {
858 let (dir, cfg) = stub(
859 r#"case "$*" in
860*--detach-sign*)
861 cat > "$D/signed.in"
862 echo "[GNUPG:] SIG_CREATED D 1 8 00 12 FPR" >&2
863 printf -- '-----BEGIN PGP SIGNATURE-----\nAAA\n-----END PGP SIGNATURE-----\n' ;;
864*--verify*)
865 cat > "$D/verify.in"
866 echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2 ;;
867esac"#,
868 );
869 let draft = "To: bob@x\nFrom: jane@x\nSubject: s\n\nline one\nline two\n";
870 let msg = sign_message(&cfg, draft, false).unwrap();
871 assert!(msg.contains("Content-Type: multipart/signed"));
872 assert!(msg.contains("micalg=pgp-sha256"));
873 let mail = parse_mail(msg.as_bytes()).unwrap();
874 assert_eq!(mail.subparts.len(), 2);
875 assert_eq!(
876 mail.subparts[0].get_body().unwrap(),
877 "line one\r\nline two\r\n"
878 );
879 let v = view(&cfg, msg.as_bytes()).unwrap();
880 assert!(v.note.contains("good signature"), "{}", v.note);
881 assert_eq!(
882 scratch(dir.path(), "signed.in"),
883 scratch(dir.path(), "verify.in")
884 );
885 }
886
887 #[test]
890 fn sign_entity_roundtrips_a_multipart() {
891 let (dir, cfg) = stub(
892 r#"case "$*" in
893*--detach-sign*)
894 cat > "$D/signed.in"
895 echo "[GNUPG:] SIG_CREATED D 1 8 00 12 FPR" >&2
896 printf -- '-----BEGIN PGP SIGNATURE-----\nAAA\n-----END PGP SIGNATURE-----\n' ;;
897*--verify*)
898 cat > "$D/verify.in"
899 echo "[GNUPG:] GOODSIG AAA Jane <j@x>" >&2 ;;
900esac"#,
901 );
902 let entity = "Content-Type: multipart/mixed; boundary=\"mm\"\r\n\r\n\
903 --mm\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhi\r\n\
904 --mm\r\nContent-Type: application/pdf\r\n\r\ndata\r\n--mm--\r\n";
905 let msg = sign_entity(
906 &cfg,
907 "To: bob@x\nFrom: jane@x\nSubject: s",
908 entity.as_bytes(),
909 )
910 .unwrap();
911 let mail = parse_mail(msg.as_bytes()).unwrap();
912 assert_eq!(mail.ctype.mimetype, "multipart/signed");
913 assert_eq!(mail.subparts[0].ctype.mimetype, "multipart/mixed");
914 assert_eq!(mail.subparts[0].subparts.len(), 2);
915 let v = view(&cfg, msg.as_bytes()).unwrap();
916 assert!(v.note.contains("good signature"), "{}", v.note);
917 assert_eq!(
918 scratch(dir.path(), "signed.in"),
919 scratch(dir.path(), "verify.in")
920 );
921 }
922
923 #[test]
924 fn encrypt_message_builds_rfc3156_shape() {
925 let (_dir, cfg) = stub(
926 r#"cat >/dev/null
927printf -- '-----BEGIN PGP MESSAGE-----\nCCC\n-----END PGP MESSAGE-----\n'"#,
928 );
929 let draft = "To: bob@x\nFrom: jane@x\nSubject: s\n\ntop secret\n";
930 let msg = encrypt_message(
931 &cfg,
932 &["bob@x".into(), "jane@x".into()],
933 false,
934 draft,
935 false,
936 )
937 .unwrap();
938 assert!(msg.contains("Content-Type: multipart/encrypted"));
939 assert!(msg.contains("Subject: s"), "headers stay in clear");
940 assert!(!msg.contains("top secret"), "body must not leak");
941 let mail = parse_mail(msg.as_bytes()).unwrap();
942 assert_eq!(mail.subparts.len(), 2);
943 assert_eq!(mail.subparts[0].get_body().unwrap().trim(), "Version: 1");
944 assert!(
945 mail.subparts[1]
946 .get_body()
947 .unwrap()
948 .contains("BEGIN PGP MESSAGE")
949 );
950 }
951}
952
953#[cfg(test)]
954mod classify_tests {
955 use super::classify;
956
957 #[test]
958 fn classify_reads_the_mime_type_only() {
959 let enc = b"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=b\r\n\r\n--b\r\nContent-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n--b--\r\n";
960 let c = classify(enc);
961 assert!(c.encrypted && !c.signed);
962 let sig = b"Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=b\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nhi\r\n--b--\r\n";
963 let c = classify(sig);
964 assert!(c.signed && !c.encrypted);
965 let inline = b"Content-Type: text/plain\r\n\r\n-----BEGIN PGP MESSAGE-----\r\nxx\r\n-----END PGP MESSAGE-----\r\n";
966 assert!(classify(inline).encrypted);
967 let plain = b"Content-Type: text/plain\r\n\r\nnothing here\r\n";
968 let c = classify(plain);
969 assert!(!c.signed && !c.encrypted);
970 }
971}