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::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/// Signature verdict from gpg's --status-fd lines.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Sig {
19    /// Valid signature by this user id.
20    Good(String),
21    /// The signature does not match: the content was altered.
22    Bad(String),
23    /// Cannot check (missing public key, unsupported algorithm, ...).
24    Unknown(String),
25}
26
27// ---- running gpg ----
28
29struct Gpg {
30    stdout: Vec<u8>,
31    /// "[GNUPG:] " lines from stderr, prefix stripped.
32    status: Vec<String>,
33    /// The remaining (human-readable) stderr, for error messages.
34    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
49/// Run gpg with `input` on stdin. --batch/--no-tty keep gpg off our
50/// terminal (pinentry still works through the agent); --status-fd 2
51/// adds machine-readable lines to stderr, where the [GNUPG:] prefix
52/// keeps them separable from diagnostics.
53fn 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            // ETXTBSY: a freshly written executable can be transiently
66            // busy (fd still open across a fork elsewhere), so retry.
67            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    // Feed stdin from a thread while wait_with_output drains stdout and
78    // stderr, so a large message cannot deadlock on full pipes.
79    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        // GOODSIG/BADSIG carry "<keyid> <user id>".
104        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// ---- primitives ----
126
127#[derive(Debug)]
128pub struct Opened {
129    pub plaintext: Vec<u8>,
130    pub sig: Option<Sig>,
131}
132
133/// Decrypt an armored PGP message. Also accepts clearsigned input,
134/// where gpg strips the armor and verifies instead. A bad signature is
135/// reported in `sig`, not as an error; the plaintext is still wanted.
136pub 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
153/// Verify a detached signature over `data` (already in the CRLF form
154/// it was signed in). gpg wants the signature as a file argument.
155pub 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
169/// Detached armored signature over `data`, plus the micalg parameter
170/// for the multipart/signed header.
171pub 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
185/// "SIG_CREATED <type> <pk algo> <hash algo> ...": map the hash
186/// number to the RFC 3156 micalg value, assuming SHA-256 when absent.
187fn 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
205/// Armored encryption of `data` to every recipient address (gpg finds
206/// the keys), optionally signed in the same pass. --trust-model always
207/// mirrors mutt: keys are picked by address, not by web-of-trust.
208pub 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        // INV_RECP names each address gpg has no key for.
222        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
236// ---- viewing ----
237
238/// What the pager should show for a PGP message: a replacement body
239/// (when decryption produced one) and a one-line status note. gpg
240/// trouble goes in the note; the original body stays available.
241pub struct View {
242    pub body: Option<Body>,
243    pub note: String,
244}
245
246/// What decryption produced, when it produced anything.
247pub enum Body {
248    /// Plain text, from inline PGP: show it as it stands.
249    Text(String),
250    /// A MIME entity, from PGP/MIME: the caller renders the tree, so
251    /// attachments inside encrypted mail show like any others.
252    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/// Inspect a raw message; Some when it is PGP-encrypted or signed in
268/// any of the four shapes (PGP/MIME encrypted or signed, inline
269/// encrypted, clearsigned).
270/// Whether a message is signed and/or encrypted, without running gpg:
271/// just the MIME type and the inline PGP markers. For the reply-crypto
272/// defaults, which must not decrypt anything.
273#[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    // Inline PGP: look at the text body's opening marker.
299    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                // The plaintext is itself a MIME entity.
358                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    // The CRLF before the closing boundary belongs to the boundary
381    // delimiter, but some senders sign a trailing newline anyway, so
382    // on a mismatch, retry with one appended before calling it bad.
383    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
433// ---- outgoing (RFC 3156) ----
434
435/// Wrap a finalized draft in multipart/signed. `flowed` is mutt's
436/// $text_flowed, passed through to the text part inside.
437pub 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
442/// Wrap an arbitrary MIME entity (its own Content-Type header + body,
443/// CRLF endings, e.g. compose::mixed_entity) in multipart/signed
444/// under `head`.
445pub 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    // Byte-identical to what was signed: entity, then the delimiter.
454    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
462/// Wrap a finalized draft in multipart/encrypted for `recipients`
463/// (which the caller assembles from To/Cc/Bcc plus the sender, so the
464/// author can read their own mail); `sign` adds a signature inside the
465/// encryption layer. Headers, including Subject, stay in clear.
466pub 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
477/// Like `encrypt_message`, but over an arbitrary MIME entity.
478pub 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
503/// The draft body as a text/plain MIME entity with CRLF endings: the
504/// exact bytes that get signed and shipped inside the multiparts.
505fn inner_entity(body: &str, flowed: bool) -> Vec<u8> {
506    crate::compose::text_entity(body, flowed).into_bytes()
507}
508
509/// RFC 3156 canonical form: every line ending is CRLF.
510pub(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
521/// A MIME boundary checked to not occur in any of the wrapped parts.
522fn 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    /// Whatever decryption produced, as text (a PGP/MIME entity comes
553    /// back raw, headers and all).
554    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    /// A fake gpg: a shell script that inspects "$@", reads stdin, and
562    /// prints canned stdout/status lines. $D is its own directory, for
563    /// scratch files the test can assert on.
564    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        // The signature went through a (since removed) temp file.
664        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        // The signed data is the exact first part, CRLF-canonical, with
800        // the boundary's own CRLF excluded.
801        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    /// sign_message output must verify: what gpg signed and what a
868    /// receiver extracts for verification are byte-identical.
869    #[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    /// Signing a multipart entity (draft with attachments) keeps the
901    /// entity byte-identical and verifiable, like the text/plain case.
902    #[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}