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;
11use sioul_core::pgp::{self, Keys, PgpView};
12use sioul_core::porch::Triaged;
13use sioul_core::trust::Trust;
14use sioul_sync::{SyncError, keys, secret};
15use std::path::{Path, PathBuf};
16
17/// Whether a message might be protected: cheap, before any key is read.
18fn looks_protected(raw: &[u8]) -> bool {
19    let text = String::from_utf8_lossy(&raw[..raw.len().min(512 * 1024)]);
20    text.contains("application/pgp-encrypted") || text.contains("application/pgp-signature") || text.contains("-----BEGIN PGP MESSAGE-----")
21}
22
23/// A protected message opened with your keys: what it reads once decrypted,
24/// and what was found; None when it is not protected. Your security key is
25/// never asked here: a message encrypted to it opens from its session key,
26/// kept since you opened it with the key, or waits for "Open with your
27/// security key".
28pub(crate) fn open(raw: &[u8]) -> Option<(Option<Vec<u8>>, PgpView)> {
29    if !looks_protected(raw) {
30        return None;
31    }
32    pgp::open(raw, &Keys::load(), &pgp::Unlock::new(&|fingerprint| secret::pgp_passphrase(&fingerprint.to_hex()), pgp::SessionKeys::global()))
33}
34
35/// The message as it reads: decrypted when it was encrypted to you.
36pub(crate) fn readable(path: &Path) -> Option<Vec<u8>> {
37    let raw = std::fs::read(path).ok()?;
38    Some(open(&raw).and_then(|(opened, _)| opened).unwrap_or(raw))
39}
40
41/// Keeps the key a sender's Autocrypt header carries, only when the sender's
42/// domain vouches for the message (the Porch's judgement): an unproven one
43/// could plant a key for someone you write to, and read your answers.
44pub(crate) fn learn_autocrypt(raw: &[u8], triaged: &Triaged) {
45    if triaged.trust != Trust::Verified {
46        return;
47    }
48    let Some(from) = triaged.card.from_address.as_deref() else { return };
49    if let Some(header) = triaged.card.headers.first("Autocrypt") {
50        let _ = raw;
51        pgp::learn_autocrypt(header, from);
52    }
53}
54
55/// What a draft can be, for the writing window's switches.
56#[derive(serde::Serialize, Default)]
57pub(crate) struct DraftProtection {
58    pub can_sign: bool,
59    pub can_encrypt: bool,
60    /// Recipients without a known key: "Encrypt" waits for them.
61    pub missing: Vec<String>,
62    /// The security key that signs for the address has an expired
63    /// certificate: when it expired, and how to renew it; else "".
64    pub expired: String,
65}
66
67pub(crate) fn draft_protection(draft: &Draft) -> DraftProtection {
68    let config = load_config();
69    let Some(address) = config.account(&draft.account).and_then(|a| a.address.clone()) else { return DraftProtection::default() };
70    let keys = Keys::load();
71    let missing: Vec<String> = draft.recipients().into_iter().filter(|r| keys.for_address(r).is_none()).collect();
72    let can_sign = keys.own_for(&address).is_some();
73    let expired = if can_sign { String::new() } else { crate::securitykey::expired_line(&keys, &address).unwrap_or_default() };
74    DraftProtection { can_sign, can_encrypt: !draft.recipients().is_empty() && missing.is_empty(), missing, expired }
75}
76
77/// Answering an encrypted message: the copy kept with the draft is the
78/// decrypted one (in your drafts folder, as your own text is), and the answer
79/// is encrypted when every key is known.
80pub(crate) fn protect_answer(draft: &mut Draft) {
81    let Some(original) = draft.original.clone() else { return };
82    let Ok(raw) = std::fs::read(&original) else { return };
83    let Some((opened, view)) = open(&raw) else { return };
84    if let Some(bytes) = opened {
85        let _ = std::fs::write(&original, bytes);
86    }
87    if view.encrypted {
88        draft.encrypt = draft_protection(draft).can_encrypt;
89    }
90}
91
92#[derive(serde::Serialize)]
93struct KeysView {
94    keys: Vec<pgp::KeySummary>,
95    /// Your mail addresses without a key of yours: "Make a key" is offered for each.
96    without_key: Vec<String>,
97}
98
99/// Your keys and others', and the addresses that could have one.
100pub(crate) fn keys_view() -> String {
101    let keys = Keys::load();
102    let config = load_config();
103    // An address your security key serves has its key, even expired: it alone signs for it.
104    let without_key: Vec<String> = config.accounts.iter().filter(|a| a.syncs()).filter_map(|a| a.address.clone()).filter(|a| keys.own_for(a).is_none() && keys.card_for(a).is_none()).collect();
105    json(&KeysView { keys: pgp::summaries(), without_key })
106}
107
108/// Makes a key for one of your addresses; its passphrase, random, goes to the keyring.
109pub(crate) fn generate(address: &str) -> Result<String, String> {
110    let config = load_config();
111    let name = config.accounts.iter().find(|a| a.address.as_deref() == Some(address)).and_then(|a| a.name.clone()).unwrap_or_default();
112    let passphrase = match secret::test_passphrase() {
113        Some(fixed) => fixed,
114        None => pgp::new_passphrase()?,
115    };
116    let made = pgp::generate(&name, address, &passphrase)?;
117    secret::save_pgp_passphrase(&made.fingerprint, &passphrase).map_err(|e| e.sentence(tr(), address))?;
118    Ok(say("pgp-made", &[("address", address.to_string())]))
119}
120
121/// Imports the keys of a file (a `file://` address); a secret key's passphrase
122/// is checked, then kept in the keyring.
123pub(crate) fn import(url: &str, passphrase: &str) -> Result<String, String> {
124    let path = crate::backend::local_path(url);
125    let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
126    // Your keys before: one imported again with a mistyped passphrase stays yours.
127    let had: Vec<String> = pgp::summaries().into_iter().filter(|k| k.own).map(|k| k.fingerprint).collect();
128    let imported = pgp::import(&bytes)?;
129    for key in imported.iter().filter(|k| k.own) {
130        if !pgp::opens(&key.fingerprint, passphrase) {
131            if !had.contains(&key.fingerprint) {
132                let _ = pgp::remove(&key.fingerprint);
133            }
134            return Err(tr().text("pgp-wrong-passphrase", None));
135        }
136        secret::save_pgp_passphrase(&key.fingerprint, passphrase).map_err(|e| e.sentence(tr(), &key.fingerprint))?;
137    }
138    let mut args = tr().counted(imported.len());
139    args.set("n", imported.len());
140    Ok(tr().text("pgp-imported", Some(&args)))
141}
142
143/// Your public key into the downloads folder, to give to others.
144pub(crate) fn export(fingerprint: &str, folder: &Path) -> Result<PathBuf, String> {
145    let armored = pgp::export_public(fingerprint).ok_or_else(|| tr().text("pgp-no-key", None))?;
146    std::fs::create_dir_all(folder).map_err(|e| e.to_string())?;
147    let short: String = fingerprint.chars().rev().take(16).collect::<Vec<_>>().into_iter().rev().collect();
148    let path = folder.join(format!("{short}.asc"));
149    std::fs::write(&path, armored).map_err(|e| e.to_string())?;
150    Ok(path)
151}
152
153/// Looks up the keys a draft's recipients miss; returns what the status line says.
154pub(crate) fn lookup(draft: &Draft) -> String {
155    let missing = draft_protection(draft).missing;
156    let mut found = 0;
157    for address in &missing {
158        match keys::lookup(address) {
159            Ok(keys) if !keys.is_empty() => found += 1,
160            Ok(_) => {}
161            Err(SyncError::Network(e)) => return say("pgp-lookup-failed", &[("detail", e)]),
162            Err(e) => return e.sentence(tr(), address),
163        }
164    }
165    let still = missing.len() - found;
166    if still == 0 { tr().text("pgp-lookup-all", None) } else { say("pgp-lookup-some", &[("missing", draft_protection(draft).missing.join(", "))]) }
167}