Skip to main content

kernoid_e2e/
mime.rs

1// SPDX-License-Identifier: MIT
2//! Mail as PGP/MIME (RFC 3156) with header protection (RFC 9788), and the summary the apps show in lists (RFC 0006,
3//! 5.2). The whole original message becomes the cryptographic payload; its `Content-Type` gets
4//! `protected-headers="v1"`, and the outer message keeps the header fields delivery, threads and sorting need – with
5//! the subject replaced by `[...]` and recorded in `HP-Outer`.
6
7use mail_parser::{MessageParser, MimeHeaders};
8use rand::{Rng, rngs::OsRng};
9
10use crate::{
11    Error, PublicKey, Result, SecretKey,
12    message::{self, Decrypted},
13};
14
15/// The subject outside an encrypted message (RFC 9788, 2.2).
16pub const OBSCURED_SUBJECT: &str = "[...]";
17
18/// Encrypts a mail message (RFC 5322, as received or as built by an app) to `recipients`: the result is a PGP/MIME
19/// message to store or send.
20pub fn encrypt_message(raw: &[u8], recipients: &[&PublicKey], signer: Option<&SecretKey>) -> Result<Vec<u8>> {
21    let (header, body) = split(raw).ok_or(Error::NotMail)?;
22    let fields = fields(header);
23    let mut inner = Vec::with_capacity(raw.len() + 256);
24    let mut outer = Vec::with_capacity(header.len() + 256);
25    let mut content_type = false;
26    for field in &fields {
27        let name = field_name(field);
28        if name.eq_ignore_ascii_case("content-type") {
29            content_type = true;
30            inner.extend_from_slice(trim_newline(field));
31            inner.extend_from_slice(b"; protected-headers=\"v1\"\r\n");
32            continue;
33        }
34        inner.extend_from_slice(field);
35        if name.to_ascii_lowercase().starts_with("content-") || name.eq_ignore_ascii_case("mime-version") {
36            continue;
37        }
38        if name.eq_ignore_ascii_case("subject") {
39            outer.extend_from_slice(format!("Subject: {OBSCURED_SUBJECT}\r\n").as_bytes());
40        } else {
41            outer.extend_from_slice(field);
42        }
43    }
44    if !content_type {
45        inner.extend_from_slice(b"Content-Type: text/plain; charset=us-ascii; protected-headers=\"v1\"\r\n");
46    }
47    if fields.iter().any(|f| field_name(f).eq_ignore_ascii_case("subject")) {
48        inner.extend_from_slice(format!("HP-Outer: Subject: {OBSCURED_SUBJECT}\r\n").as_bytes());
49    }
50    inner.extend_from_slice(b"\r\n");
51    inner.extend_from_slice(body);
52
53    let armored = crlf(&message::encrypt(&inner, recipients, signer, true)?);
54    let boundary = boundary();
55    outer.extend_from_slice(
56        format!(
57            "MIME-Version: 1.0\r\nContent-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; \
58             boundary=\"{boundary}\"\r\n\r\nThis is an OpenPGP/MIME encrypted message (RFC 3156 and 9580).\r\n\
59             --{boundary}\r\nContent-Type: application/pgp-encrypted\r\nContent-Description: PGP/MIME version \
60             identification\r\n\r\nVersion: 1\r\n\r\n--{boundary}\r\nContent-Type: application/octet-stream; \
61             name=\"encrypted.asc\"\r\nContent-Description: OpenPGP encrypted message\r\nContent-Disposition: \
62             inline; filename=\"encrypted.asc\"\r\n\r\n"
63        )
64        .as_bytes(),
65    );
66    outer.extend_from_slice(&armored);
67    if !armored.ends_with(b"\r\n") {
68        outer.extend_from_slice(b"\r\n");
69    }
70    outer.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
71    Ok(outer)
72}
73
74/// Whether a mail message is PGP/MIME encrypted.
75pub fn is_encrypted(raw: &[u8]) -> bool {
76    encrypted_payload(raw).is_some()
77}
78
79/// Decrypts a PGP/MIME message: `data` is the inner message, with its protected header fields.
80pub fn decrypt_message(raw: &[u8], keys: &[&SecretKey], senders: &[&PublicKey]) -> Result<Decrypted> {
81    let payload = encrypted_payload(raw).ok_or(Error::NotEncrypted)?;
82    message::decrypt(&payload, keys, senders)
83}
84
85/// What an app shows of a message in a list (RFC 0006, 5.2).
86#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct Summary {
89    pub subject: Option<String>,
90    pub preview: String,
91    pub has_attachment: bool,
92}
93
94/// The summary of a mail message in plain text.
95pub fn summary(raw: &[u8]) -> Result<Summary> {
96    let message = MessageParser::default().parse(raw).ok_or(Error::NotMail)?;
97    let preview = message.body_preview(256).map(|p| p.into_owned()).unwrap_or_default();
98    Ok(Summary {
99        subject: message.subject().map(str::to_owned),
100        preview: preview.split_whitespace().collect::<Vec<_>>().join(" "),
101        has_attachment: message.attachment_count() > 0,
102    })
103}
104
105/// The summary of a message, encrypted to `recipients` (binary) – the property `kernoid:encryptedSummary`.
106pub fn encrypted_summary(raw: &[u8], recipients: &[&PublicKey]) -> Result<Vec<u8>> {
107    let json = serde_json::to_vec(&summary(raw)?).map_err(|e| Error::Pgp(e.to_string()))?;
108    message::encrypt(&json, recipients, None, false)
109}
110
111/// Decrypts an encrypted summary.
112pub fn decrypt_summary(data: &[u8], keys: &[&SecretKey]) -> Result<Summary> {
113    let decrypted = message::decrypt(data, keys, &[])?;
114    serde_json::from_slice(&decrypted.data).map_err(|_| Error::Decrypt)
115}
116
117/// The armored OpenPGP message of a PGP/MIME message: the second part of a `multipart/encrypted` with the protocol
118/// `application/pgp-encrypted`.
119fn encrypted_payload(raw: &[u8]) -> Option<Vec<u8>> {
120    let message = MessageParser::default().parse(raw)?;
121    let root = message.content_type()?;
122    let encrypted = root.ctype().eq_ignore_ascii_case("multipart")
123        && root.subtype().is_some_and(|s| s.eq_ignore_ascii_case("encrypted"))
124        && root.attribute("protocol").is_some_and(|p| p.eq_ignore_ascii_case("application/pgp-encrypted"));
125    if !encrypted {
126        return None;
127    }
128    let mail_parser::PartType::Multipart(children) = &message.root_part().body else { return None };
129    let part = message.part(*children.get(1)?)?;
130    let contents = part.contents();
131    message::is_armored(contents).then(|| contents.to_vec())
132}
133
134/// The header section (with its last line break) and the body.
135fn split(raw: &[u8]) -> Option<(&[u8], &[u8])> {
136    if let Some(i) = find(raw, b"\r\n\r\n") {
137        return Some((&raw[..i + 2], &raw[i + 4..]));
138    }
139    find(raw, b"\n\n").map(|i| (&raw[..i + 1], &raw[i + 2..]))
140}
141
142/// The header fields, each with its continuation lines and line break; bare `\n` become `\r\n`.
143fn fields(header: &[u8]) -> Vec<Vec<u8>> {
144    let mut fields: Vec<Vec<u8>> = Vec::new();
145    for line in header.split_inclusive(|b| *b == b'\n') {
146        let mut line = trim_newline(line).to_vec();
147        line.extend_from_slice(b"\r\n");
148        match fields.last_mut() {
149            Some(field) if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') => field.extend_from_slice(&line),
150            _ => fields.push(line),
151        }
152    }
153    fields
154}
155
156fn field_name(field: &[u8]) -> String {
157    let end = field.iter().position(|b| *b == b':').unwrap_or(field.len());
158    String::from_utf8_lossy(&field[..end]).trim().to_owned()
159}
160
161fn trim_newline(line: &[u8]) -> &[u8] {
162    let line = line.strip_suffix(b"\n").unwrap_or(line);
163    line.strip_suffix(b"\r").unwrap_or(line)
164}
165
166fn crlf(data: &[u8]) -> Vec<u8> {
167    let mut out = Vec::with_capacity(data.len() + data.len() / 32);
168    for line in data.split_inclusive(|b| *b == b'\n') {
169        out.extend_from_slice(trim_newline(line));
170        if line.ends_with(b"\n") {
171            out.extend_from_slice(b"\r\n");
172        }
173    }
174    out
175}
176
177fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
178    haystack.windows(needle.len()).position(|w| w == needle)
179}
180
181fn boundary() -> String {
182    let bytes: [u8; 16] = OsRng.r#gen();
183    format!("kernoid-{}", crate::keys::hex(&bytes))
184}