Skip to main content

sioul_app/
crypto.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! OpenPGP behind the window: protected messages opened for the reader, the
5//! keys Autocrypt brings, what a draft can be (signed, encrypted), and your
6//! keys: made, imported, exported, removed. Passphrases stay in the keyring;
7//! decrypted messages stay in memory.
8
9use crate::backend::{json, load_config, say, tr};
10use sioul_core::compose::{Draft, DraftKind};
11use sioul_core::draftcopies;
12use sioul_core::pgp::{self, Keys, PgpView};
13use sioul_core::securitykey;
14use sioul_core::porch::Triaged;
15use sioul_core::trust::Trust;
16use sioul_sync::{SyncError, keys, secret};
17use std::path::{Path, PathBuf};
18
19/// Whether a message might be protected: cheap, before any key is read.
20fn looks_protected(raw: &[u8]) -> bool {
21    let text = String::from_utf8_lossy(&raw[..raw.len().min(512 * 1024)]);
22    text.contains("application/pgp-encrypted") || text.contains("application/pgp-signature") || text.contains("-----BEGIN PGP MESSAGE-----")
23}
24
25/// A protected message opened with your keys: what it reads once decrypted,
26/// and what was found; None when it is not protected. Your security key is
27/// never asked here: a message encrypted to it opens from its session key,
28/// kept since you opened it with the key, or waits for "Open with your
29/// security key".
30pub(crate) fn open(raw: &[u8]) -> Option<(Option<Vec<u8>>, PgpView)> {
31    if !looks_protected(raw) {
32        return None;
33    }
34    let mut opened = pgp::open(raw, &Keys::load(), &pgp::Unlock::new(&|fingerprint| secret::pgp_passphrase(&fingerprint.to_hex()), pgp::SessionKeys::global()));
35    // A phone, until it can use the key: what waits for it is said, with no button.
36    if let Some((_, view)) = opened.as_mut() {
37        view.key_elsewhere = !sioul_sync::securitykey::usable_here();
38    }
39    opened
40}
41
42/// The message as it reads: decrypted when it was encrypted to you.
43pub(crate) fn readable(path: &Path) -> Option<Vec<u8>> {
44    let raw = std::fs::read(path).ok()?;
45    Some(open(&raw).and_then(|(opened, _)| opened).unwrap_or(raw))
46}
47
48/// Keeps the key a sender's Autocrypt header carries, only when the sender's
49/// domain vouches for the message (the Porch's judgement): an unproven one
50/// could plant a key for someone you write to, and read your answers.
51pub(crate) fn learn_autocrypt(raw: &[u8], triaged: &Triaged) {
52    if triaged.trust != Trust::Verified {
53        return;
54    }
55    let Some(from) = triaged.card.from_address.as_deref() else { return };
56    if let Some(header) = triaged.card.headers.first("Autocrypt") {
57        let _ = raw;
58        pgp::learn_autocrypt(header, from);
59    }
60}
61
62/// What a draft can be, for the writing window's switches.
63#[derive(serde::Serialize, Default)]
64pub(crate) struct DraftProtection {
65    pub can_sign: bool,
66    pub can_encrypt: bool,
67    /// Recipients without a known key: "Encrypt" waits for them.
68    pub missing: Vec<String>,
69    /// The security key that signs for the address has an expired
70    /// certificate: when it expired, and how to renew it; else "".
71    pub expired: String,
72    /// This device cannot use the security key that signs for the address
73    /// (a phone, until it can): why "Sign" is off; else "".
74    pub sign_not_here: String,
75    /// Encrypted, your own certificate cannot take it (expired): you would
76    /// not read your sent copy; said before sending; else "".
77    pub self_copy: String,
78    /// The copy of the message answered or forwarded is encrypted (it came
79    /// so, or the look sealed it): what becomes of it
80    /// when "Encrypt" is unticked (a reply goes without its quote, a forward
81    /// not at all); said only then; else "".
82    pub sealed_quote: String,
83    /// Bcc recipients, the message encrypted: every recipient can see which
84    /// keys it was encrypted to, theirs too (review of 19 October 2026, F13:
85    /// said before Send; a message of their own for each, later); else "".
86    pub bcc_seen: String,
87}
88
89/// The message a draft answers or forwards, as this device can read it. Its
90/// copy beside the draft stays as it came: encrypted when it came so.
91pub(crate) enum Answered {
92    /// None, or not encrypted.
93    Clear,
94    /// Encrypted, and it opens here now (a key here, or the session key kept
95    /// since you opened it): what it reads, in memory only.
96    Opens(Vec<u8>),
97    /// Encrypted to your security key, which this device can use: opened at Send.
98    AtSend,
99    /// Encrypted to your security key, which this device cannot use yet (a
100    /// phone): it opens on a device that can.
101    Elsewhere,
102    /// Encrypted, and no key of yours known here opens it (to a key you no
103    /// longer have, to one kept on another device alone, or to others
104    /// alone): never quoted nor forwarded from here (F16).
105    NoKey,
106}
107
108/// The copy beside `draft`, read whole (`filelock::read_whole`: never the
109/// zeros written over a copy replaced meanwhile); none when there is none.
110pub(crate) fn copy_of(draft: &Draft) -> Option<Vec<u8>> {
111    draft.original_here().and_then(|path| sioul_core::filelock::read_whole(&path).ok())
112}
113
114/// How the copy beside a draft reads here (`raw`: as [`copy_of`] read it).
115pub(crate) fn answered(raw: Option<&[u8]>) -> Answered {
116    let Some(raw) = raw.filter(|raw| pgp::is_encrypted(raw)) else { return Answered::Clear };
117    match open(raw) {
118        Some((Some(opened), _)) => Answered::Opens(opened),
119        Some((None, view)) if view.security_key.is_some() && !view.key_elsewhere => Answered::AtSend,
120        Some((None, view)) if view.security_key.is_some() => Answered::Elsewhere,
121        _ => Answered::NoKey,
122    }
123}
124
125pub(crate) fn draft_protection(draft: &Draft) -> DraftProtection {
126    let config = load_config();
127    let Some(address) = config.account(&draft.account).and_then(|a| a.address.clone()) else { return DraftProtection::default() };
128    protection_with(&Keys::load(), &address, draft, sioul_sync::securitykey::usable_here())
129}
130
131/// [`draft_protection`] with `keys`, for `address`; `card_here`: whether this
132/// device can use a security key (a phone cannot yet).
133fn protection_with(keys: &Keys, address: &str, draft: &Draft, card_here: bool) -> DraftProtection {
134    let address = address.to_string();
135    let missing: Vec<String> = draft.recipients().into_iter().filter(|r| keys.for_address(r).is_none()).collect();
136    let signs = keys.own_for(&address);
137    let can_sign = signs.is_some();
138    let expired = if can_sign { String::new() } else { crate::securitykey::expired_line(keys, &address).unwrap_or_default() };
139    // Your security key signs for the address, and this device cannot use it
140    // yet (a phone); or it was read on another computer of yours, and a
141    // computer signs with one it read itself (review of 19 October 2026, F18).
142    let card_signs = signs.as_ref().is_some_and(|own| matches!(own.place, pgp::Place::Card { .. }));
143    let shared_serves = !can_sign && keys.card_for(&address).is_none() && keys.serving_card(&address).is_some();
144    let sign_not_here = match (card_signs || shared_serves, card_here) {
145        (false, _) => String::new(),
146        (true, false) => tr().text("seckey-sign-not-here", None),
147        (true, true) if shared_serves => tr().text(crate::securitykey::read_here_words("seckey-sign-read-here"), None),
148        (true, true) => String::new(),
149    };
150    // Encrypted without a copy for you: why, said whatever it is (F14).
151    let self_copy = if keys.self_cert_for(&address).is_none() { self_copy_line(keys, &address, card_here) } else { String::new() };
152    // An encrypted message is never quoted nor forwarded in clear.
153    let sealed = copy_of(draft).is_some_and(|raw| pgp::is_encrypted(&raw));
154    let sealed_quote = match (sealed, draft.kind) {
155        (false, _) | (_, DraftKind::New) => String::new(),
156        (true, DraftKind::Forward) => tr().text("pgp-forward-needs-encryption", None),
157        (true, _) => tr().text("pgp-quote-left-out", None),
158    };
159    let bcc_seen = if draft.bcc.iter().any(|b| !b.trim().is_empty()) { tr().text("pgp-bcc-seen", None) } else { String::new() };
160    DraftProtection { can_sign: can_sign && (card_here || !card_signs), can_encrypt: !draft.recipients().is_empty() && missing.is_empty(), missing, expired, sign_not_here, self_copy, sealed_quote, bcc_seen }
161}
162
163/// Why an encrypted message would go without a copy for you, for
164/// `address`: the part of your certificate that expired or was revoked
165/// (its encryption subkey, its primary key), how to renew it (on a phone:
166/// on a computer); or that no certificate of yours for it is known here
167/// (a phone whose settings do not come through the sharing). Review of 19
168/// October 2026, F14: it said the whole certificate had expired whatever
169/// part had, and said nothing when none was known.
170fn self_copy_line(keys: &Keys, address: &str, computer: bool) -> String {
171    let now = jiff::Timestamp::now().as_second();
172    let card = keys.serving_card(address).map(|(_, cert)| cert);
173    let cert = card.or_else(|| keys.own.iter().find(|c| pgp::names_address(c, address)));
174    let Some(cert) = cert else { return tr().text("pgp-self-copy-none", None) };
175    // Revoked as a whole: nothing renews it (review of 19 October 2026, N3).
176    if pgp::revoked_whole(cert) {
177        return tr().text("pgp-self-copy-revoked", None);
178    }
179    let mut why = securitykey::expired_parts(tr(), &securitykey::parts(cert), now);
180    if why.is_empty() {
181        why.extend(securitykey::revoked_encryption(cert).map(|fingerprint| say("seckey-part-revoked-encrypt", &[("fingerprint", securitykey::short_fingerprint(&fingerprint))])));
182    }
183    if why.is_empty() {
184        return tr().text("pgp-self-copy-none", None);
185    }
186    // What can renew it (N8): your security key's certificate, GnuPG through
187    // Accounts on a computer; a key kept here, only the program that made it,
188    // or a new key.
189    let renew = match (card.is_some(), computer) {
190        // Known here from another computer only: Accounts renews a key read here (review of 19 October 2026, Info).
191        (true, true) if keys.card_for(address).is_none() => tr().text("pgp-self-copy-renew-read-here", None),
192        (true, true) => tr().text("pgp-self-copy-renew", None),
193        (true, false) => tr().text("pgp-self-copy-renew-phone", None),
194        (false, _) => tr().text("pgp-self-copy-new-key", None),
195    };
196    say("pgp-self-copy-why", &[("why", why.join(" ")), ("renew", renew)])
197}
198
199/// Answering an encrypted message: the answer is encrypted when every key is
200/// known. The copy kept with the draft stays as it came, encrypted: it opens
201/// only at Send, in memory (`sioul_sync::send::build_draft`), never in clear
202/// on a disk, nor in the sharing, which carries drafts to every device. Until
203/// 19 October 2026 its decrypted text was written there (`seal_clear_drafts`
204/// seals those copies again).
205pub(crate) fn protect_answer(draft: &mut Draft) {
206    if copy_of(draft).is_some_and(|raw| pgp::is_encrypted(&raw)) {
207        draft.encrypt = draft_protection(draft).can_encrypt;
208    }
209}
210
211/// The copies beside drafts looked at (`draftcopies`), their verdicts read
212/// from the disk once: one look at a time in this process.
213static JUDGED: std::sync::Mutex<Option<draftcopies::Judged>> = std::sync::Mutex::new(None);
214
215/// Where the look's verdicts are kept between processes, never shared.
216fn verdicts_path() -> PathBuf {
217    sioul_core::config::state_dir().join("draftcopies.json")
218}
219
220/// The copies beside drafts that hold an encrypted message's text in clear,
221/// their earlier versions kept here and the copies kept before sharing
222/// began (`share/before-sharing-<day>/`), encrypted again to your own
223/// certificates (`sioul_core::draftcopies`): at the window's start, before
224/// each of its exchanges (never holding the exchanges' `BUSY`: the look takes
225/// the exchange lock in short holds of its own), and at a phone's background
226/// step; never on a dose's alarm, nor at "Taken" (review of 19 October
227/// 2026, F9). Cheap once done: its verdicts are kept on the disk, a file is
228/// read again only once it changed, and your keys are read only when a copy
229/// is to be sealed. Said in counts only, never what a copy says.
230pub(crate) fn seal_clear_drafts() {
231    // Others' keys kept before say once what they serve (review of 19 October 2026, N2).
232    match pgp::keep_others_for_their_addresses() {
233        Ok(0) => {}
234        Ok(n) => eprintln!("sioul: keys: {n} kept before now say which address they serve"),
235        Err(e) => eprintln!("sioul: keys: what others' keys serve not written: {e}"),
236    }
237    let mut judged = JUDGED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
238    let judged = judged.get_or_insert_with(|| draftcopies::Judged::load(&verdicts_path()));
239    seal_with(judged);
240    if let Err(e) = judged.save(&verdicts_path()) {
241        eprintln!("sioul: drafts: the look's verdicts not kept: {}", e.kind());
242    }
243}
244
245/// The copies' earlier versions kept here (`history`), and those kept before
246/// sharing began (`share/before-sharing-<day>/`), beside the sharing's `memory`.
247fn kept_versions(memory: &Path) -> Vec<PathBuf> {
248    let mut kept = sioul_sync::history::draft_copies(&sioul_sync::history::root(memory));
249    kept.extend(sioul_sync::share::draft_copies_kept_before_sharing(memory));
250    kept
251}
252
253fn seal_with(judged: &mut draftcopies::Judged) {
254    let config = load_config();
255    let roots: Vec<PathBuf> = config.accounts.iter().filter(|a| a.kind == sioul_core::config::AccountKind::Imap).map(|a| a.maildir_path()).collect();
256    let cache = sioul_core::config::cache_dir().join("mail-index.tsv");
257    let done = seal_in(&Draft::folder(), &crate::share::memory_path(), &draftcopies::Certs::lazy(Keys::load), &roots, &cache, judged);
258    if done.any() {
259        eprintln!("{}", said(&done));
260    }
261}
262
263/// [`seal_clear_drafts`] in these folders: the drafts', the sharing's memory
264/// (its history and what it kept before sharing began beside it), your mail
265/// (`roots`, its index of Message-IDs kept in `cache`). Each copy takes its
266/// place under the sharing's exchange lock, a short hold at a time
267/// (`share::replacements_hold`): no exchange reads one while it is replaced.
268fn seal_in(drafts: &Path, memory: &Path, certs: &draftcopies::Certs, roots: &[PathBuf], cache: &Path, judged: &mut draftcopies::Judged) -> draftcopies::Sealed {
269    // Your mail by Message-ID, read only when a copy in clear asks it.
270    let mut index: Option<sioul_core::mailindex::MailIndex> = None;
271    // Every file of a Message-ID: one message in two folders, or another given its ID (F6).
272    let mut find = |id: &str| index.get_or_insert_with(|| sioul_core::mailindex::MailIndex::build(roots, cache)).paths(id);
273    let hold = sioul_sync::share::replacements_hold(memory);
274    let beside = draftcopies::seal_beside(drafts, certs, &mut find, judged, &hold);
275    // Their earlier versions kept here, and what was kept before sharing began.
276    let done = beside.and(draftcopies::seal_versions(&kept_versions(memory), certs, &mut find, judged, &hold));
277    // What waits for a certificate of yours, said in Accounts ▸ Encryption (F19).
278    judged.set_waiting(done.waiting);
279    done
280}
281
282/// What a look did, for the log: counts only, never what a copy says.
283fn said(done: &draftcopies::Sealed) -> String {
284    format!(
285        "sioul: drafts: {} copies encrypted again, {} earlier versions, {} not written, {} waiting for a key, {} without their draft taken out",
286        done.copies, done.versions, done.failed, done.waiting, done.orphans
287    )
288}
289
290#[derive(serde::Serialize)]
291struct KeysView {
292    keys: Vec<pgp::KeySummary>,
293    /// Your mail addresses without a key of yours: "Make a key" is offered for each.
294    without_key: Vec<String>,
295    /// Copies kept beside drafts (and their earlier versions) waiting for a
296    /// certificate of yours to be encrypted again: one quiet line, nothing to
297    /// press (F19); "" when none waits.
298    copies_waiting: String,
299    /// On a computer, your addresses a security key of yours serves that
300    /// another computer read: a line each, and "Use a security key", rather
301    /// than "Make a key for" (review of 19 October 2026, N4).
302    read_here: Vec<String>,
303}
304
305/// Your addresses Accounts offers "Make a key for", and those it offers to
306/// read your security key for here instead: an address a security key of
307/// yours serves has its key, read here or on another device (N4: since a
308/// device signs only with the security key it read, `card_for` alone left
309/// the phone, and a computer that did not read it, offering a second key).
310/// Read here is offered on a computer only (a phone cannot read one yet),
311/// also where a key kept here signs for the address meanwhile: read here,
312/// the security key signs, and Autocrypt hands out the certificate that
313/// signs.
314fn offers(keys: &Keys, addresses: &[String], card_here: bool) -> (Vec<String>, Vec<String>) {
315    let (mut make, mut read) = (Vec::new(), Vec::new());
316    for address in addresses {
317        if keys.card_for(address).is_some() {
318            continue;
319        }
320        if keys.serving_card(address).is_some() {
321            if card_here {
322                read.push(say(crate::securitykey::read_here_words("pgp-read-card-here"), &[("address", address.clone())]));
323            }
324            continue;
325        }
326        if keys.own_for(address).is_none() {
327            make.push(address.clone());
328        }
329    }
330    (make, read)
331}
332
333/// Your keys and others', and the addresses that could have one.
334pub(crate) fn keys_view() -> String {
335    let keys = Keys::load();
336    let config = load_config();
337    // An address your security key serves has its key, even expired, wherever it was read.
338    let addresses: Vec<String> = config.accounts.iter().filter(|a| a.syncs()).filter_map(|a| a.address.clone()).collect();
339    let (without_key, read_here) = offers(&keys, &addresses, sioul_sync::securitykey::usable_here());
340    // A look running holds its verdicts: what the last one kept on the disk, rather than wait for it.
341    let waiting = JUDGED.try_lock().ok().and_then(|j| j.as_ref().map(draftcopies::Judged::waiting)).unwrap_or_else(|| draftcopies::Judged::load(&verdicts_path()).waiting());
342    let copies_waiting = if waiting == 0 { String::new() } else { tr().text("pgp-copies-waiting", Some(&tr().counted(waiting))) };
343    json(&KeysView { keys: pgp::summaries(), without_key, copies_waiting, read_here })
344}
345
346/// Makes a key for one of your addresses; its passphrase, random, goes to the keyring.
347pub(crate) fn generate(address: &str) -> Result<String, String> {
348    let config = load_config();
349    let name = config.accounts.iter().find(|a| a.address.as_deref() == Some(address)).and_then(|a| a.name.clone()).unwrap_or_default();
350    let passphrase = match secret::test_passphrase() {
351        Some(fixed) => fixed,
352        None => pgp::new_passphrase()?,
353    };
354    let made = pgp::generate(&name, address, &passphrase)?;
355    secret::save_pgp_passphrase(&made.fingerprint, &passphrase).map_err(|e| e.sentence(tr(), address))?;
356    Ok(say("pgp-made", &[("address", address.to_string())]))
357}
358
359/// Imports the keys of a file (a `file://` address); a secret key's passphrase
360/// is checked, then kept in the keyring.
361pub(crate) fn import(url: &str, passphrase: &str) -> Result<String, String> {
362    let path = crate::backend::local_path(url);
363    let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
364    // Your keys before: one imported again with a mistyped passphrase stays yours.
365    let had: Vec<String> = pgp::summaries().into_iter().filter(|k| k.own).map(|k| k.fingerprint).collect();
366    let imported = pgp::import(&bytes)?;
367    for key in imported.iter().filter(|k| k.own) {
368        if !pgp::opens(&key.fingerprint, passphrase) {
369            if !had.contains(&key.fingerprint) {
370                let _ = pgp::remove(&key.fingerprint);
371            }
372            return Err(tr().text("pgp-wrong-passphrase", None));
373        }
374        secret::save_pgp_passphrase(&key.fingerprint, passphrase).map_err(|e| e.sentence(tr(), &key.fingerprint))?;
375    }
376    let mut args = tr().counted(imported.len());
377    args.set("n", imported.len());
378    Ok(tr().text("pgp-imported", Some(&args)))
379}
380
381/// Your public key into the downloads folder, to give to others.
382pub(crate) fn export(fingerprint: &str, folder: &Path) -> Result<PathBuf, String> {
383    let armored = pgp::export_public(fingerprint).ok_or_else(|| tr().text("pgp-no-key", None))?;
384    std::fs::create_dir_all(folder).map_err(|e| e.to_string())?;
385    let short: String = fingerprint.chars().rev().take(16).collect::<Vec<_>>().into_iter().rev().collect();
386    let path = folder.join(format!("{short}.asc"));
387    std::fs::write(&path, armored).map_err(|e| e.to_string())?;
388    Ok(path)
389}
390
391/// Looks up the keys a draft's recipients miss; returns what the status line says.
392pub(crate) fn lookup(draft: &Draft) -> String {
393    let missing = draft_protection(draft).missing;
394    let mut found = 0;
395    for address in &missing {
396        match keys::lookup(address) {
397            Ok(keys) if !keys.is_empty() => found += 1,
398            Ok(_) => {}
399            Err(SyncError::Network(e)) => return say("pgp-lookup-failed", &[("detail", e)]),
400            Err(e) => return e.sentence(tr(), address),
401        }
402    }
403    let still = missing.len() - found;
404    if still == 0 { tr().text("pgp-lookup-all", None) } else { say("pgp-lookup-some", &[("missing", draft_protection(draft).missing.join(", "))]) }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use sioul_core::securitykey::{KnownCard, SoftCard};
411
412    /// Your security key's certificate, as a phone or a computer knows it.
413    fn yours(dated: Option<(std::time::SystemTime, std::time::Duration)>) -> Keys {
414        let (card, public) = SoftCard::generate_dated("0006:12345678", &["Card Holder <me@example.org>"], "123456", dated).unwrap();
415        let mine = pgp::certificates_in(&public);
416        let known = KnownCard {
417            ident: "0006:12345678".into(),
418            sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
419            decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
420            cert: mine[0].fingerprint().to_hex(),
421            addresses: vec!["me@example.org".into()],
422            ..KnownCard::default()
423        };
424        Keys { own: mine, cards: vec![known], ..Keys::default() }
425    }
426
427    /// Batch B (18 October 2026): answering an encrypted message keeps its
428    /// copy beside the draft as it came, encrypted: never its text in clear,
429    /// which the sharing would carry to every device.
430    #[test]
431    fn an_answered_encrypted_message_stays_encrypted_beside_the_draft() {
432        let tsk = pgp::stand_in_key("Me <me@example.org>").unwrap();
433        let keys = Keys { own: pgp::certificates_in(&tsk), ..Keys::default() };
434        let clear = b"From: Jane <jane@example.org>\r\nTo: Me <me@example.org>\r\nSubject: The lease\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\nMessage-ID: <lease-b@example.org>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is here.\r\n";
435        let sealed = pgp::seal_message(clear, &[&keys.own[0]]).unwrap();
436        let dir = std::env::temp_dir().join(format!("sioul-answer-{}", std::process::id()));
437        std::fs::create_dir_all(&dir).unwrap();
438        let original = dir.join("lease.eml");
439        std::fs::write(&original, &sealed).unwrap();
440        let mut draft = sioul_core::compose::answer(&original, sioul_core::compose::DraftKind::Reply, "me", &["me@example.org".to_string()]).unwrap();
441        draft.keep_original().unwrap();
442        // The key here opens it (as a session key kept since you opened it would).
443        let passphrase = |_: &pgp::Fingerprint| None;
444        assert!(pgp::open(&sealed, &keys, &pgp::Unlock::new(&passphrase, &pgp::SessionKeys::new())).is_some_and(|(opened, _)| opened.is_some()));
445        protect_answer(&mut draft);
446        let kept = std::fs::read(draft.original.as_ref().unwrap()).unwrap();
447        draft.discard();
448        let _ = std::fs::remove_dir_all(&dir);
449        assert!(!String::from_utf8_lossy(&kept).contains("secret clause"), "never in clear beside the draft");
450        assert_eq!(kept, sealed, "kept as it came");
451    }
452
453    /// Batch A (18 October 2026): a phone knows your key and cannot sign with
454    /// it yet: "Sign" is off, said; a computer signs. An expired certificate
455    /// of yours: encrypting is said to leave you no copy to read, before Send.
456    #[test]
457    fn the_writing_window_says_what_your_key_can_do_here() {
458        let mut draft = Draft::new("me");
459        draft.to = vec!["me@example.org".into()];
460        let keys = yours(None);
461        let phone = protection_with(&keys, "me@example.org", &draft, false);
462        assert!(!phone.can_sign && phone.sign_not_here == tr().text("seckey-sign-not-here", None) && phone.self_copy.is_empty() && phone.can_encrypt);
463        let computer = protection_with(&keys, "me@example.org", &draft, true);
464        assert!(computer.can_sign && computer.sign_not_here.is_empty() && computer.self_copy.is_empty());
465        let year = std::time::Duration::from_secs(365 * 24 * 3600);
466        let expired = yours(Some((std::time::SystemTime::now() - 2 * year, year)));
467        let late = protection_with(&expired, "me@example.org", &draft, true);
468        // Which part expired, and how to renew it there (F14): a computer's way, a phone's.
469        let why = securitykey::expired_parts(tr(), &securitykey::parts(&expired.own[0]), jiff::Timestamp::now().as_second()).join(" ");
470        assert!(!why.is_empty());
471        assert_eq!(late.self_copy, say("pgp-self-copy-why", &[("why", why.clone()), ("renew", tr().text("pgp-self-copy-renew", None))]));
472        assert_eq!(protection_with(&expired, "me@example.org", &draft, false).self_copy, say("pgp-self-copy-why", &[("why", why), ("renew", tr().text("pgp-self-copy-renew-phone", None))]));
473        assert!(!late.can_sign && !late.can_encrypt, "to yourself, with no key that takes it: {:?}", late.missing);
474        // No certificate of yours for the address known here (a phone whose settings do not come): said too.
475        let mut to_jane = Draft::new("me");
476        to_jane.to = vec!["jane@example.org".into()];
477        assert_eq!(protection_with(&Keys::default(), "me@example.org", &to_jane, false).self_copy, tr().text("pgp-self-copy-none", None));
478        // Your security key read on another computer of yours: a computer says
479        // to read it there to sign (F18), a phone that it cannot yet.
480        let shared = Keys { shared: keys.cards.clone(), cards: Vec::new(), ..keys.clone() };
481        let elsewhere = protection_with(&shared, "me@example.org", &draft, true);
482        assert!(!elsewhere.can_sign && elsewhere.sign_not_here == tr().text("seckey-sign-read-here", None) && elsewhere.self_copy.is_empty());
483        assert_eq!(protection_with(&shared, "me@example.org", &draft, false).sign_not_here, tr().text("seckey-sign-not-here", None));
484    }
485
486    /// Review of 19 October 2026, N8: the self-copy line says what can renew
487    /// the certificate: your security key's, GnuPG through Accounts (on a
488    /// computer) or one of your computers (on a phone); a key kept here, only
489    /// the program that made it, or a new key: never a button Accounts lacks.
490    #[test]
491    fn the_self_copy_line_says_what_can_renew_the_key() {
492        let mut draft = Draft::new("me");
493        draft.to = vec!["me@example.org".into()];
494        let year = std::time::Duration::from_secs(365 * 24 * 3600);
495        let card = yours(Some((std::time::SystemTime::now() - 2 * year, year)));
496        let here = Keys { cards: Vec::new(), ..card.clone() };
497        let why = securitykey::expired_parts(tr(), &securitykey::parts(&here.own[0]), jiff::Timestamp::now().as_second()).join(" ");
498        for computer in [true, false] {
499            assert_eq!(protection_with(&here, "me@example.org", &draft, computer).self_copy, say("pgp-self-copy-why", &[("why", why.clone()), ("renew", tr().text("pgp-self-copy-new-key", None))]));
500        }
501        assert!(protection_with(&card, "me@example.org", &draft, true).self_copy.ends_with(&tr().text("pgp-self-copy-renew", None)));
502        // Known on this computer from another one only: read it here first.
503        let shared = Keys { shared: card.cards.clone(), cards: Vec::new(), ..card.clone() };
504        assert!(protection_with(&shared, "me@example.org", &draft, true).self_copy.ends_with(&tr().text("pgp-self-copy-renew-read-here", None)));
505        assert!(protection_with(&shared, "me@example.org", &draft, false).self_copy.ends_with(&tr().text("pgp-self-copy-renew-phone", None)));
506    }
507
508    /// Review of 19 October 2026, N4: an address your security key serves,
509    /// read on another device, is never offered "Make a key for": a computer
510    /// offers to read the key there, a phone nothing; an address without a
511    /// key of yours is offered one.
512    #[test]
513    fn a_shared_security_key_is_read_here_never_doubled_by_a_new_key() {
514        let keys = yours(None);
515        let shared = Keys { shared: keys.cards.clone(), cards: Vec::new(), ..keys.clone() };
516        let addresses = vec!["me@example.org".to_string(), "other@example.org".to_string()];
517        let other = vec!["other@example.org".to_string()];
518        assert_eq!(offers(&shared, &addresses, true), (other.clone(), vec![say("pgp-read-card-here", &[("address", "me@example.org".to_string())])]));
519        assert_eq!(offers(&shared, &addresses, false), (other.clone(), Vec::new()), "a phone cannot read it yet");
520        assert_eq!(offers(&keys, &addresses, true), (other.clone(), Vec::new()), "read here: it serves");
521        // A key of yours kept here for that address meanwhile: reading the security key here is still offered.
522        let mut both = shared.clone();
523        both.own.extend(pgp::certificates_in(&pgp::stand_in_key("Me <me@example.org>").unwrap()));
524        assert_eq!(offers(&both, &addresses, true).1.len(), 1);
525    }
526
527    const LEASE: &[u8] = b"From: Jane <jane@example.org>\r\nTo: Card Holder <me@example.org>\r\nSubject: The lease\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\nMessage-ID: <lease-view@example.org>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is here.\r\n";
528
529    /// Batch B (19 October 2026): unticking "Encrypt" says what becomes of
530    /// an encrypted message answered (its quote left out) or forwarded (not
531    /// sent); of one that came in clear, nothing. At Send, the copy encrypted
532    /// to your security key waits for it, when the answer is encrypted.
533    #[test]
534    fn unticking_encrypt_says_what_becomes_of_an_encrypted_quote() {
535        let keys = yours(None);
536        let dir = std::env::temp_dir().join(format!("sioul-sealed-quote-{}", std::process::id()));
537        std::fs::create_dir_all(&dir).unwrap();
538        let (sealed, clear) = (dir.join("sealed.eml"), dir.join("clear.eml"));
539        std::fs::write(&sealed, pgp::seal_message(LEASE, &[&keys.own[0]]).unwrap()).unwrap();
540        std::fs::write(&clear, LEASE).unwrap();
541        let mut draft = Draft::new("me");
542        draft.to = vec!["me@example.org".into()];
543        draft.kind = DraftKind::Reply;
544        draft.original = Some(sealed.clone());
545        assert_eq!(protection_with(&keys, "me@example.org", &draft, true).sealed_quote, tr().text("pgp-quote-left-out", None));
546        draft.kind = DraftKind::Forward;
547        assert_eq!(protection_with(&keys, "me@example.org", &draft, true).sealed_quote, tr().text("pgp-forward-needs-encryption", None));
548        draft.original = Some(clear.clone());
549        assert!(protection_with(&keys, "me@example.org", &draft, true).sealed_quote.is_empty());
550        draft.kind = DraftKind::Reply;
551        draft.original = Some(sealed);
552        draft.encrypt = true;
553        assert_eq!(crate::securitykey::quote_card(&draft, &keys).as_deref(), Some("0006:12345678"));
554        draft.encrypt = false;
555        assert_eq!(crate::securitykey::quote_card(&draft, &keys), None, "not encrypted: not quoted, no key asked");
556        draft.encrypt = true;
557        draft.original = Some(clear);
558        assert_eq!(crate::securitykey::quote_card(&draft, &keys), None, "came in clear");
559        let _ = std::fs::remove_dir_all(&dir);
560    }
561
562    /// Batch B: the look at the start and before each exchange encrypts the
563    /// clear copies beside drafts, their earlier versions and what was kept
564    /// before sharing began; nothing of their text is left in clear in those
565    /// folders nor in the cache, and the log says counts only.
566    #[test]
567    fn the_look_leaves_no_clear_copy_and_says_counts_only() {
568        let base = std::env::temp_dir().join(format!("sioul-seal-in-{}", std::process::id()));
569        let _ = std::fs::remove_dir_all(&base);
570        let (drafts, state, mail, cache) = (base.join("data/drafts"), base.join("state/share"), base.join("mail"), base.join("cache"));
571        let memory = state.join("memory.json");
572        let put = |path: PathBuf, bytes: &[u8]| {
573            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
574            std::fs::write(path, bytes).unwrap();
575        };
576        let keys = Keys { own: pgp::certificates_in(&pgp::stand_in_key("Card Holder <me@example.org>").unwrap()), ..Keys::default() };
577        // The message came encrypted, in your mail (a Maildir); its copy, decrypted as Sioul wrote it before.
578        let sealed = pgp::seal_message(LEASE, &[&keys.own[0]]).unwrap();
579        put(mail.join("INBOX/cur/1790000000.U1-1.sioul:2,S"), &sealed);
580        std::fs::create_dir_all(mail.join("INBOX/new")).unwrap();
581        let none = |_: &pgp::Fingerprint| None;
582        let decrypted = pgp::open(&sealed, &keys, &pgp::Unlock::new(&none, &pgp::SessionKeys::new())).unwrap().0.unwrap();
583        assert!(pgp::looks_opened(&decrypted));
584        put(drafts.join("b1.toml"), toml::to_string(&Draft { id: "b1".into(), ..Draft::default() }).unwrap().as_bytes());
585        put(drafts.join("b1.eml"), &decrypted);
586        put(state.join("history/drafts/data/drafts/b1.eml/2026-10-18T10-00-00.000Z"), &decrypted);
587        put(state.join("before-sharing-2026-09-21/data/drafts/b1.eml"), &decrypted);
588        let done = seal_in(&drafts, &memory, &draftcopies::Certs::of(keys.clone()), &[mail.clone()], &cache.join("mail-index.tsv"), &mut draftcopies::Judged::default());
589        assert_eq!(done, draftcopies::Sealed { copies: 1, versions: 2, ..draftcopies::Sealed::default() });
590        for folder in [&drafts, &state, &cache] {
591            for path in sioul_core::mailindex::message_files(folder).into_iter().chain(walk(folder)) {
592                assert!(!String::from_utf8_lossy(&std::fs::read(&path).unwrap_or_default()).contains("secret clause"), "{}", path.display());
593            }
594        }
595        assert!(Draft::load(&drafts.join("b1.toml")).is_some(), "the draft itself untouched");
596        let line = said(&done);
597        assert!(!line.contains("lease") && !line.contains("secret") && !line.contains("b1"), "{line}");
598        let _ = std::fs::remove_dir_all(&base);
599    }
600
601    /// Review of 19 October 2026, F9: the look never runs on a dose's path
602    /// (`share::exchange_here`: a dose's alarm, "Taken", a waking), where
603    /// Android gives seconds, nor reads your keys there; the window's exchange
604    /// runs it before it takes `BUSY`, which an alarm waits for; the phone's
605    /// background step runs it after its exchange. Read from the source.
606    #[test]
607    fn the_look_is_off_the_doses_path() {
608        let source = |file: &str| std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join(file)).unwrap();
609        let body = |text: &str, start: &str| text.split(start).nth(1).and_then(|rest| rest.split("\n}\n").next()).unwrap_or_else(|| panic!("{start}")).to_string();
610        let share = source("src/share.rs");
611        let alarm = body(&share, "\npub(crate) fn exchange_here(");
612        assert!(!alarm.contains("seal_clear_drafts(") && !alarm.contains("seal_clear_drafts_if_idle") && !alarm.contains("Keys::load"), "the alarm's path looks at the copies");
613        let window = body(&share, "\nfn exchange_now(");
614        let (look, busy) = (window.find("crate::crypto::seal_clear_drafts();").expect("the window's exchange looks"), window.find("BUSY.lock()").unwrap());
615        assert!(look < busy, "the look before BUSY");
616        let steps = source("src/steps.rs");
617        let step = body(&steps, "\nfn step(reason: &str)");
618        let (pull, look) = (step.find("exchange_here(fetch_first)").unwrap(), step.find("crate::crypto::seal_clear_drafts").expect("the background step looks"));
619        assert!(pull < look, "after the step's exchange");
620        for file in ["src/health.rs", "src/wake.rs", "src/timenote.rs", "src/alarms.rs"] {
621            assert!(!source(file).contains("seal_clear_drafts("), "{file}");
622        }
623        // At the window's start, its thread spawned once the environment is set, which it reads (F10).
624        let start = source("src/lib.rs");
625        let (set, spawn) = (start.rfind("std::env::set_var(").unwrap(), start.find("std::thread::spawn(crypto::seal_clear_drafts)").expect("the start looks"));
626        assert!(set < spawn, "the look's thread after the environment is set");
627    }
628
629    /// Every file below `dir`.
630    fn walk(dir: &Path) -> Vec<PathBuf> {
631        let mut out = Vec::new();
632        for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
633            if entry.path().is_dir() {
634                out.extend(walk(&entry.path()));
635            } else {
636                out.push(entry.path());
637            }
638        }
639        out
640    }
641
642    /// Batch B: a draft whose message came encrypted, opened on a device
643    /// where no key opens it: the window says so, lists none of what it
644    /// forwards, and the copy stays as it came.
645    #[test]
646    fn a_draft_opened_without_the_key_says_so_and_stays_encrypted() {
647        let elsewhere = pgp::certificates_in(&pgp::stand_in_key("Card Holder <me@example.org>").unwrap());
648        let sealed = pgp::seal_message(LEASE, &[&elsewhere[0]]).unwrap();
649        let mut draft = Draft::new("me");
650        draft.kind = DraftKind::Forward;
651        draft.to = vec!["bob@example.org".into()];
652        std::fs::create_dir_all(Draft::folder()).unwrap();
653        let copy = Draft::folder().join(format!("{}.eml", draft.id));
654        std::fs::write(&copy, &sealed).unwrap();
655        draft.original = Some(copy.clone());
656        draft.save().unwrap();
657        let view = |id: &str| serde_json::from_str::<serde_json::Value>(&crate::mail::draft(id).unwrap()).unwrap();
658        let shown = view(&draft.id);
659        let below = shown["below"].as_str().unwrap_or_default().to_string();
660        assert!(below.ends_with(&tr().text("compose-forward-sealed-no-key", None)), "no key of yours opens it: {below}");
661        assert!(shown["forwarded"].as_array().is_some_and(Vec::is_empty), "{shown}");
662        draft.kind = DraftKind::Reply;
663        draft.save().unwrap();
664        let below = view(&draft.id)["below"].as_str().unwrap_or_default().to_string();
665        assert!(below.ends_with(&tr().text("compose-below-sealed-no-key", None)), "no key of yours opens it: {below}");
666        assert_eq!(std::fs::read(&copy).unwrap(), sealed, "kept as it came");
667        draft.discard();
668    }
669}