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