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            return Err(crate::policy_label::labelled(
68                "draft-warning",
69                format!("export: {name} must be 1 to 200 printable characters with no space"),
70            ));
71        }
72    }
73    if expires <= now || expires - now > MAX_LIFETIME {
74        return Err(crate::policy_label::labelled(
75            "draft-warning",
76            "export: expires must be after now and at most 24 h out",
77        ));
78    }
79    let message = std::str::from_utf8(draft.message).context("export: the message is not UTF-8")?;
80    refuse_hiding(message)?;
81    let Some((headers, _)) = message.split_once("\n\n") else {
82        return Err(crate::policy_label::labelled(
83            "draft-warning",
84            "export: the message has no blank line between headers and body",
85        ));
86    };
87    let fields = super::checks::unfold(headers)
88        .context("export: a header line is neither a field nor a continuation")?;
89    let one = |name: &str| -> Result<&str> {
90        let mut values = fields.iter().filter(|(n, _)| n == name);
91        match (values.next(), values.next()) {
92            (Some((_, value)), None) => Ok(value),
93            _ => Err(crate::policy_label::labelled(
94                "draft-warning",
95                format!(
96                    "export: the message needs exactly one {name} header; the send path would add one"
97                ),
98            )),
99        }
100    };
101    if DateTime::parse_from_rfc2822(one("date")?).is_err() {
102        return Err(crate::policy_label::labelled(
103            "draft-warning",
104            "export: Date is not an RFC 5322 date-time",
105        ));
106    }
107    if !is_msg_id(one("message-id")?) {
108        return Err(crate::policy_label::labelled(
109            "draft-warning",
110            "export: Message-ID is not an RFC 5322 msg-id such as <id@example.test>",
111        ));
112    }
113    if fields.iter().any(|(n, _)| n == "bcc") {
114        return Err(crate::policy_label::labelled(
115            "draft-warning",
116            "export: sendmail -t strips Bcc, so the sent bytes would not be H; use To or Cc",
117        ));
118    }
119    if let Some((name, _)) = fields.iter().find(|(n, _)| n.starts_with("resent-")) {
120        return Err(crate::policy_label::labelled(
121            "draft-warning",
122            format!(
123                "export: {name} is refused: with a Resent- header sendmail -t sends to the Resent- recipients, not To and Cc"
124            ),
125        ));
126    }
127    let refused = super::checks::refusals(draft.message, config);
128    if !refused.is_empty() {
129        return Err(crate::policy_label::labelled(
130            "draft-warning",
131            format!(
132                "export: the checks refuse this draft: {}",
133                refused.join("; ")
134            ),
135        ));
136    }
137    let hash = hex::encode(Sha256::digest(draft.message));
138    if draft.reachbook_hash != hash {
139        return Err(crate::policy_label::labelled(
140            "draft-warning",
141            "export: reachbook-hash is not the sha256 of the message, so the outbox holds other bytes",
142        ));
143    }
144    let mut seed = [0u8; 16];
145    std::fs::File::open("/dev/urandom")
146        .and_then(|mut f| f.read_exact(&mut seed))
147        .context("export: no randomness for the nonce")?;
148    let nonce = hex::encode(seed);
149    let mut file = format!(
150        "ljos-outbox v1\ndraft {}\nreachbook-hash {}\nexpires {}\nnonce {nonce}\n\n",
151        draft.id,
152        draft.reachbook_hash,
153        expires.to_rfc3339_opts(SecondsFormat::Secs, true),
154    )
155    .into_bytes();
156    file.extend_from_slice(draft.message);
157    Ok(Approval {
158        file,
159        hash,
160        nonce,
161        expires,
162    })
163}
164
165/// `<id-left@id-right>`: dot-atom text on the left, dot-atom text or a
166/// `[...]` literal without folding on the right.
167fn is_msg_id(value: &str) -> bool {
168    let dot_atom = |s: &str| {
169        s.split('.').all(|atom| {
170            !atom.is_empty()
171                && atom
172                    .bytes()
173                    .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-/=?^_`{|}~".contains(&b))
174        })
175    };
176    let Some((left, right)) = value
177        .strip_prefix('<')
178        .and_then(|v| v.strip_suffix('>'))
179        .and_then(|v| v.split_once('@'))
180    else {
181        return false;
182    };
183    let literal = right
184        .strip_prefix('[')
185        .and_then(|r| r.strip_suffix(']'))
186        .is_some_and(|d| {
187            d.bytes()
188                .all(|b| b.is_ascii_graphic() && !b"[]\\".contains(&b))
189        });
190    dot_atom(left) && (dot_atom(right) || literal)
191}
192
193fn refuse_hiding(message: &str) -> Result<()> {
194    for (n, line) in message.split('\n').enumerate() {
195        if let Some((col, c)) = line
196            .chars()
197            .enumerate()
198            .find(|(_, c)| super::notmuch::hiding(*c))
199        {
200            bail!(
201                "export: line {} column {} carries U+{:04X}; only tab and newline may appear",
202                n + 1,
203                col + 1,
204                c as u32
205            );
206        }
207    }
208    Ok(())
209}