Skip to main content

ljos_cli/mailbox/
export.rs

1//! A draft as an approval file.
2//!
3//! ```text
4//! ljos-outbox v1
5//! draft <outbox draft id>
6//! reachbook-hash <the outbox's sha256 of the message, hex>
7//! expires <RFC 3339, at most 24 h out>
8//! nonce <16 random bytes, hex>
9//!
10//! <the message, LF line endings>
11//! ```
12//!
13//! The message is stored in one canonical form: LF line endings, written
14//! as is to `sendmail -i -t`, with the MTA turning LF into CRLF. `H` is
15//! sha256 of exactly those bytes, so mailgate can hash what it is about
16//! to write and refuse on a mismatch. `H` is what the ballot and the
17//! owner approve; the expiry and nonce are checked and spent by mailgate
18//! on their own. The person
19//! approving reads this file in a terminal or a chat card, so any byte
20//! that could hide or reorder text there is refused, not escaped: what
21//! they read must be exactly what is sent.
22
23use std::io::Read as _;
24
25use anyhow::{bail, Context as _, Result};
26use chrono::{DateTime, Duration, SecondsFormat, Utc};
27use sha2::{Digest as _, Sha256};
28
29use super::config::MailConfig;
30
31/// The longest an approval file stays valid.
32pub const MAX_LIFETIME: Duration = Duration::hours(24);
33
34/// What the outbox holds for one draft.
35pub struct Draft<'a> {
36    pub id: &'a str,
37    /// The outbox's sha256 of `message`, lowercase hex. It must match.
38    pub reachbook_hash: &'a str,
39    /// The message in its canonical form, `Message-ID` and `Date` set.
40    pub message: &'a [u8],
41}
42
43/// An exported approval file and its hash.
44#[derive(Debug, Clone)]
45pub struct Approval {
46    pub file: Vec<u8>,
47    /// `H`: sha256 of the message bytes, the ones `sendmail` is given, hex.
48    pub hash: String,
49    pub nonce: String,
50    pub expires: DateTime<Utc>,
51}
52
53/// The approval file for `draft`, refused when the content checks under
54/// `config` refuse the message: no file is minted for a draft that could
55/// not be sent.
56pub fn export(
57    draft: &Draft<'_>,
58    config: &MailConfig,
59    expires: DateTime<Utc>,
60    now: DateTime<Utc>,
61) -> Result<Approval> {
62    for (name, value) in [
63        ("draft", draft.id),
64        ("reachbook-hash", draft.reachbook_hash),
65    ] {
66        if value.is_empty() || value.len() > 200 || !value.bytes().all(|b| b.is_ascii_graphic()) {
67            bail!("export: {name} must be 1 to 200 printable characters with no space");
68        }
69    }
70    if expires <= now || expires - now > MAX_LIFETIME {
71        bail!("export: expires must be after now and at most 24 h out");
72    }
73    let message = std::str::from_utf8(draft.message).context("export: the message is not UTF-8")?;
74    refuse_hiding(message)?;
75    let Some((headers, _)) = message.split_once("\n\n") else {
76        bail!("export: the message has no blank line between headers and body");
77    };
78    let fields = super::checks::unfold(headers)
79        .context("export: a header line is neither a field nor a continuation")?;
80    let one = |name: &str| -> Result<&str> {
81        let mut values = fields.iter().filter(|(n, _)| n == name);
82        match (values.next(), values.next()) {
83            (Some((_, value)), None) => Ok(value),
84            _ => bail!(
85                "export: the message needs exactly one {name} header; the send path would add one"
86            ),
87        }
88    };
89    if DateTime::parse_from_rfc2822(one("date")?).is_err() {
90        bail!("export: Date is not an RFC 5322 date-time");
91    }
92    if !is_msg_id(one("message-id")?) {
93        bail!("export: Message-ID is not an RFC 5322 msg-id such as <id@example.test>");
94    }
95    if fields.iter().any(|(n, _)| n == "bcc") {
96        bail!("export: sendmail -t strips Bcc, so the sent bytes would not be H; use To or Cc");
97    }
98    if let Some((name, _)) = fields.iter().find(|(n, _)| n.starts_with("resent-")) {
99        bail!(
100            "export: {name} is refused: with a Resent- header sendmail -t sends to the Resent- recipients, not To and Cc"
101        );
102    }
103    let refused = super::checks::refusals(draft.message, config);
104    if !refused.is_empty() {
105        bail!(
106            "export: the checks refuse this draft: {}",
107            refused.join("; ")
108        );
109    }
110    let hash = hex::encode(Sha256::digest(draft.message));
111    if draft.reachbook_hash != hash {
112        bail!("export: reachbook-hash is not the sha256 of the message, so the outbox holds other bytes");
113    }
114    let mut seed = [0u8; 16];
115    std::fs::File::open("/dev/urandom")
116        .and_then(|mut f| f.read_exact(&mut seed))
117        .context("export: no randomness for the nonce")?;
118    let nonce = hex::encode(seed);
119    let mut file = format!(
120        "ljos-outbox v1\ndraft {}\nreachbook-hash {}\nexpires {}\nnonce {nonce}\n\n",
121        draft.id,
122        draft.reachbook_hash,
123        expires.to_rfc3339_opts(SecondsFormat::Secs, true),
124    )
125    .into_bytes();
126    file.extend_from_slice(draft.message);
127    Ok(Approval {
128        file,
129        hash,
130        nonce,
131        expires,
132    })
133}
134
135/// `<id-left@id-right>`: dot-atom text on the left, dot-atom text or a
136/// `[...]` literal without folding on the right.
137fn is_msg_id(value: &str) -> bool {
138    let dot_atom = |s: &str| {
139        s.split('.').all(|atom| {
140            !atom.is_empty()
141                && atom
142                    .bytes()
143                    .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-/=?^_`{|}~".contains(&b))
144        })
145    };
146    let Some((left, right)) = value
147        .strip_prefix('<')
148        .and_then(|v| v.strip_suffix('>'))
149        .and_then(|v| v.split_once('@'))
150    else {
151        return false;
152    };
153    let literal = right
154        .strip_prefix('[')
155        .and_then(|r| r.strip_suffix(']'))
156        .is_some_and(|d| {
157            d.bytes()
158                .all(|b| b.is_ascii_graphic() && !b"[]\\".contains(&b))
159        });
160    dot_atom(left) && (dot_atom(right) || literal)
161}
162
163fn refuse_hiding(message: &str) -> Result<()> {
164    for (n, line) in message.split('\n').enumerate() {
165        if let Some((col, c)) = line
166            .chars()
167            .enumerate()
168            .find(|(_, c)| super::notmuch::hiding(*c))
169        {
170            bail!(
171                "export: line {} column {} carries U+{:04X}; only tab and newline may appear",
172                n + 1,
173                col + 1,
174                c as u32
175            );
176        }
177    }
178    Ok(())
179}