Skip to main content

rmut_core/
pgp.rs

1//! PGP support by shelling out to gpg(1): decrypt and verify messages
2//! for the pager, sign and encrypt outgoing drafts. Handles PGP/MIME
3//! (RFC 3156) and inline ("armor in the body") messages. Passphrases
4//! are between gpg and its agent; rmut never sees or stores them.
5
6use 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/// Signature verdict from gpg's --status-fd lines.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Sig {
18    /// Valid signature by this user id.
19    Good(String),
20    /// The signature does not match: the content was altered.
21    Bad(String),
22    /// Cannot check (missing public key, unsupported algorithm, ...).
23    Unknown(String),
24}
25
26// ---- running gpg ----
27
28struct Gpg {
29    stdout: Vec<u8>,
30    /// "[GNUPG:] " lines from stderr, prefix stripped.
31    status: Vec<String>,
32    /// The remaining (human-readable) stderr, for error messages.
33    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
48/// Run gpg with `input` on stdin. --batch/--no-tty keep gpg off our
49/// terminal (pinentry still works through the agent); --status-fd 2
50/// adds machine-readable lines to stderr, where the [GNUPG:] prefix
51/// keeps them separable from diagnostics.
52fn 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            // ETXTBSY: a freshly written executable can be transiently
65            // busy (fd still open across a fork elsewhere), so retry.
66            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    // Feed stdin from a thread while wait_with_output drains stdout and
77    // stderr, so a large message cannot deadlock on full pipes.
78    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        // GOODSIG/BADSIG carry "<keyid> <user id>".
103        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// ---- primitives ----
125
126#[derive(Debug)]
127pub struct Opened {
128    pub plaintext: Vec<u8>,
129    pub sig: Option<Sig>,
130}
131
132/// Decrypt an armored PGP message. Also accepts clearsigned input,
133/// where gpg strips the armor and verifies instead. A bad signature is
134/// reported in `sig`, not as an error; the plaintext is still wanted.
135pub 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
152/// Verify a detached signature over `data` (already in the CRLF form
153/// it was signed in). gpg wants the signature as a file argument.
154pub 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
166/// Detached armored signature over `data`, plus the micalg parameter
167/// for the multipart/signed header.
168pub 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
182/// "SIG_CREATED <type> <pk algo> <hash algo> ...": map the hash
183/// number to the RFC 3156 micalg value, assuming SHA-256 when absent.
184fn 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
202/// Armored encryption of `data` to every recipient address (gpg finds
203/// the keys), optionally signed in the same pass. --trust-model always
204/// mirrors mutt: keys are picked by address, not by web-of-trust.
205pub 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        // INV_RECP names each address gpg has no key for.
219        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
233// ---- viewing ----
234
235/// What the pager should show for a PGP message: a replacement body
236/// (when decryption produced one) and a one-line status note. gpg
237/// trouble goes in the note; the original body stays available.
238pub struct View {
239    pub body: Option<Body>,
240    pub note: String,
241}
242
243/// What decryption produced, when it produced anything.
244pub enum Body {
245    /// Plain text, from inline PGP: show it as it stands.
246    Text(String),
247    /// A MIME entity, from PGP/MIME: the caller renders the tree, so
248    /// attachments inside encrypted mail show like any others.
249    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/// Inspect a raw message; Some when it is PGP-encrypted or signed in
265/// any of the four shapes (PGP/MIME encrypted or signed, inline
266/// encrypted, clearsigned).
267/// Whether a message is signed and/or encrypted, without running gpg:
268/// just the MIME type and the inline PGP markers. For the reply-crypto
269/// defaults, which must not decrypt anything.
270#[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    // Inline PGP: look at the text body's opening marker.
296    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                // The plaintext is itself a MIME entity.
355                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    // The CRLF before the closing boundary belongs to the boundary
378    // delimiter, but some senders sign a trailing newline anyway, so
379    // on a mismatch, retry with one appended before calling it bad.
380    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
430// ---- outgoing (RFC 3156) ----
431
432/// Wrap a finalized draft in multipart/signed. `flowed` is mutt's
433/// $text_flowed, passed through to the text part inside.
434pub 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
439/// Wrap an arbitrary MIME entity (its own Content-Type header + body,
440/// CRLF endings, e.g. compose::mixed_entity) in multipart/signed
441/// under `head`.
442pub 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    // Byte-identical to what was signed: entity, then the delimiter.
451    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
459/// Wrap a finalized draft in multipart/encrypted for `recipients`
460/// (which the caller assembles from To/Cc/Bcc plus the sender, so the
461/// author can read their own mail); `sign` adds a signature inside the
462/// encryption layer. Headers, including Subject, stay in clear.
463pub 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
474/// Like `encrypt_message`, but over an arbitrary MIME entity.
475pub 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
500/// The draft body as a text/plain MIME entity with CRLF endings: the
501/// exact bytes that get signed and shipped inside the multiparts.
502fn inner_entity(body: &str, flowed: bool) -> Vec<u8> {
503    crate::compose::text_entity(body, flowed).into_bytes()
504}
505
506/// RFC 3156 canonical form: every line ending is CRLF.
507pub(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
518/// A MIME boundary checked to not occur in any of the wrapped parts.
519fn 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    /// Whatever decryption produced, as text (a PGP/MIME entity comes
540    /// back raw, headers and all).
541    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    /// A fake gpg: a shell script that inspects "$@", reads stdin, and
549    /// prints canned stdout/status lines. $D is its own directory, for
550    /// scratch files the test can assert on.
551    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        // The signature went through a (since removed) temp file.
651        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        // The signed data is the exact first part, CRLF-canonical, with
787        // the boundary's own CRLF excluded.
788        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    /// sign_message output must verify: what gpg signed and what a
855    /// receiver extracts for verification are byte-identical.
856    #[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    /// Signing a multipart entity (draft with attachments) keeps the
888    /// entity byte-identical and verifiable, like the text/plain case.
889    #[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}