Skip to main content

sioul_app/
securitykey.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Your security key behind the window (docs/client.md, "Your keys on a
5//! security key"): the band of the writing window and of the Reader (plug the
6//! key in, its PIN, a touch, what went wrong, in words), signing at Send
7//! before the ten seconds of "Undo", opening a message on "Open with your
8//! security key", the PIN held in memory for fifteen quiet minutes, the key's
9//! setup in Accounts ▸ Encryption, and "Let GnuPG release it"; GnuPG's own
10//! steps as buttons there ("Import from GnuPG", "Renew for two years") and
11//! "Send it to keys.openpgp.org", each run on its press only, its command
12//! kept under "By hand" to copy.
13//!
14//! Nothing here touches the key unless you asked: a message shown is never
15//! opened with it, and no lookup runs by itself. A demo profile never reaches
16//! the system's smart card service: it has a key of its own, made of
17//! software, whose PIN is 123456.
18
19use crate::backend::{QtThread, Shared, json, load_config, local_path, offline, say, tell, tr};
20use cxx_qt_lib::QString;
21use sioul_core::compose::{Draft, DraftKind};
22use sioul_core::i18n::Translator;
23use sioul_core::pgp::{self, Keys, Place};
24use sioul_core::securitykey::{self, CardAccess, CardError, CardInfo, KnownCard, NoReaders, Password, PinMemory, Purpose, Readers, Remedy, SoftCard, SoftReaders, Touch};
25use sioul_sync::securitykey::{Gnupg, GnupgProblem, GnupgStep, Looked, Released};
26use sioul_sync::send::BuildError;
27use std::collections::BTreeSet;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::{Arc, Mutex, OnceLock};
30use std::time::{Duration, Instant};
31
32/// The PIN, held in memory for fifteen quiet minutes after its last use.
33static PIN: Mutex<PinMemory> = Mutex::new(PinMemory::new());
34/// Whether the watch over the held PIN runs.
35static WATCHING: AtomicBool = AtomicBool::new(false);
36/// The bands waiting for the key to be plugged in, by their context, until "Not now".
37static WAITING: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
38/// The bands told that their key has one PIN try left: the PIN typed next there is sent (review of 23 October 2026, F1).
39static LAST_TRY: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
40/// What the setup read from the key, for "Look for it" and "Import a file…".
41static SETUP: Mutex<Option<CardInfo>> = Mutex::new(None);
42/// The window, told when the PIN is held or forgotten: Accounts offers "Forget the PIN now" then.
43static WINDOW: Mutex<Option<QtThread>> = Mutex::new(None);
44
45/// How often Sioul looks whether a key it waits for came, or whether the key whose PIN it holds went.
46const LOOK_EVERY: Duration = Duration::from_millis(1500);
47/// How long a band waits for its key before it stops looking.
48const WAIT_AT_MOST: Duration = Duration::from_secs(5 * 60);
49/// How long a PIN typed waits for its key: then forgotten, asked again (review of 23 October 2026, F2).
50const PIN_WAITS_AT_MOST: Duration = Duration::from_secs(60);
51/// The band of the setup, in Accounts ▸ Encryption.
52const SETUP_BAND: &str = "setup";
53
54/// The demo's security key: software behind the same traits, its PIN 123456,
55/// a touch asked to sign; its certificate names the profile's first address.
56struct Demo {
57    readers: SoftReaders,
58    certificate: Vec<u8>,
59}
60
61fn demo() -> Option<&'static Demo> {
62    static DEMO: OnceLock<Option<Demo>> = OnceLock::new();
63    DEMO.get_or_init(|| {
64        let config = load_config();
65        let accounts: Vec<_> = config.accounts.iter().filter(|a| a.address.is_some()).collect();
66        // One user ID per address, named when an account names it.
67        let mut userids: Vec<String> = Vec::new();
68        for account in &accounts {
69            let address = account.address.clone().unwrap_or_default();
70            if userids.iter().any(|u| u.ends_with(&format!("<{address}>"))) {
71                continue;
72            }
73            let name = accounts.iter().filter(|a| a.address.as_deref() == Some(address.as_str())).find_map(|a| a.name.clone()).unwrap_or_default();
74            userids.push(format!("{name} <{address}>").trim().to_string());
75        }
76        let userids: Vec<&str> = userids.iter().map(String::as_str).collect();
77        let (mut card, certificate) = SoftCard::generate("0006:01234567", &userids, "123456").ok()?;
78        card.info.holder = accounts.first().and_then(|a| a.name.clone()).unwrap_or_default();
79        if let Some(slot) = card.info.sign.as_mut() {
80            slot.touch = Touch::On;
81        }
82        card.touch_delay = Duration::from_secs(4);
83        Some(Demo { readers: SoftReaders::with(vec![card]), certificate })
84    })
85    .as_ref()
86}
87
88/// Where security keys are: the system's smart card service; in a demo, the demo's own key, never a real one.
89fn readers() -> &'static dyn Readers {
90    static NONE: NoReaders = NoReaders;
91    if offline() {
92        return demo().map_or(&NONE as &dyn Readers, |d| &d.readers as &dyn Readers);
93    }
94    sioul_sync::securitykey::readers()
95}
96
97/// What the band of a writing window, of the Reader or of the setup shows, as JSON.
98#[derive(serde::Serialize, Default)]
99struct Band {
100    /// "working", "plug", "pin", "touch", "problem", "found", "missing", "done".
101    state: &'static str,
102    /// What it says.
103    line: String,
104    /// Under the PIN's field: "2 tries left.", when fewer than the most seen.
105    tries: String,
106    /// Said in warm colours: a wrong PIN, a problem; never red.
107    warm: bool,
108    /// The problem's button: "retry" (ask the key again), "release" (Let
109    /// GnuPG release it), "lookup" (look for the certificate again), or none.
110    action: &'static str,
111    /// The key's fingerprint, for `gpg --export` in the setup's hint.
112    fingerprint: String,
113    /// The key it is about ("0006:12345678"): "Send it to keys.openpgp.org" after a renewal.
114    ident: String,
115    /// The commands that do by hand what the buttons do, to copy ("By hand").
116    commands: Vec<String>,
117}
118
119fn tell_band(qt: &QtThread, context: &str, band: Band) {
120    // A phone reads keys (NFC's reader mode, ahead of other apps) only while a band asks for one.
121    if taps() {
122        readers().want(matches!(band.state, "pin" | "plug" | "working" | "touch"));
123    }
124    let (context, state) = (context.to_string(), json(&band));
125    let _ = qt.queue(move |mut sioul| sioul.as_mut().security_key_changed(QString::from(&context), QString::from(&state)));
126}
127
128fn working(qt: &QtThread, context: &str, id: &'static str) {
129    tell_band(qt, context, Band { state: "working", line: tr().text(working_words(&way(), id), None), ..Band::default() });
130}
131
132/// Held to a phone, the key is used while it stays there: "keep holding it".
133/// Plugged in, the computer's words.
134fn working_words(way: &Way, id: &'static str) -> &'static str {
135    if way.held() && matches!(id, "seckey-signing" | "seckey-opening") { "seckey-holding" } else { id }
136}
137
138/// How the key is reached here, for the words: on a computer, plugged in; on
139/// a phone, in one visit (PIN first), held against it or plugged into its
140/// USB-C port, NFC perhaps missing or off.
141#[derive(Debug, Clone, Default, PartialEq)]
142struct Way {
143    /// One visit, PIN first: a phone.
144    taps: bool,
145    /// The key in reach, or the last one used, came by cable.
146    cable: bool,
147    /// The way missing, while a key can still be plugged in.
148    note: Option<CardError>,
149}
150
151impl Way {
152    fn of(readers: &dyn Readers) -> Way {
153        Way { taps: readers.taps(), cable: readers.by_cable(), note: readers.reach_note() }
154    }
155
156    /// Held against a phone: the tap is the touch, the key moves away.
157    fn held(&self) -> bool {
158        self.taps && !self.cable
159    }
160}
161
162fn way() -> Way {
163    Way::of(readers())
164}
165
166/// The words that say how to read your security key on this device: on a
167/// phone, held against it (`<id>-phone`); on a computer, plugged in.
168pub(crate) fn read_here_words(computer: &'static str) -> &'static str {
169    read_here_in(cfg!(target_os = "android"), computer)
170}
171
172fn read_here_in(phone: bool, computer: &'static str) -> &'static str {
173    if !phone {
174        return computer;
175    }
176    match computer {
177        "seckey-sign-read-here" => "seckey-sign-read-here-phone",
178        "seckey-source-shared-computer" => "seckey-source-shared-phone",
179        "pgp-read-card-here" => "pgp-read-card-here-phone",
180        other => other,
181    }
182}
183
184/// Whether the key is used in one tap: a phone, over NFC (its PIN asked
185/// first, never held; the key held while it works; docs/android.md).
186fn taps() -> bool {
187    readers().taps()
188}
189
190/// A failure in words, a phone's own where they differ: a key moved away, or
191/// unplugged, before it finished; a touch the key would not take over NFC; a
192/// key locked (unlocked on a computer).
193fn sentence(error: &CardError, purpose: Purpose) -> String {
194    sentence_in(&way(), error, purpose, tr())
195}
196
197fn sentence_in(way: &Way, error: &CardError, purpose: Purpose, words: &Translator) -> String {
198    let sign = purpose == Purpose::Sign;
199    if way.taps {
200        match error {
201            CardError::Removed if way.cable => return words.text(if sign { "seckey-unplugged-sign" } else { "seckey-unplugged" }, None),
202            CardError::Removed => return words.text(if sign { "seckey-moved-away-sign" } else { "seckey-moved-away" }, None),
203            CardError::TouchMissed if !way.cable => return words.text("seckey-nfc-touch", None),
204            CardError::Blocked | CardError::WrongPin { left: 0 } => return words.text("seckey-blocked-phone", None),
205            CardError::LastTry => return words.text("seckey-last-try-phone", None),
206            _ => {}
207        }
208    }
209    error.sentence(words, purpose)
210}
211
212/// What the band says while it waits for the key: plug it in; on a phone,
213/// hold it or plug it in (plug it in only, without NFC or with NFC off).
214fn waiting_line(purpose: Purpose) -> String {
215    waiting_in(&way(), purpose, tr())
216}
217
218fn waiting_in(way: &Way, purpose: Purpose, words: &Translator) -> String {
219    if !way.taps {
220        return CardError::Absent.sentence(words, purpose);
221    }
222    words.text(
223        match way.note {
224            Some(CardError::NoNfc) => "seckey-plug-phone",
225            Some(CardError::NfcOff) => "seckey-plug-nfc-off",
226            _ => "seckey-hold",
227        },
228        None,
229    )
230}
231
232/// The waiting band's button: "Open NFC settings" while NFC is off.
233fn waiting_action(way: &Way) -> &'static str {
234    if way.taps && way.note == Some(CardError::NfcOff) { "nfc-settings" } else { "" }
235}
236
237/// What came from the PIN's field looks like a one-time password the key
238/// typed when touched (Yubico OTP: 32 to 64 letters of its modhex alphabet,
239/// 44 most often, then Enter), not a PIN: never given to the key, which would
240/// count it as a wrong PIN.
241fn typed_by_the_key(typed: &str) -> bool {
242    // In capitals too: Caps Lock, or a key set to type with Shift (review of 23 October 2026, F7).
243    (32..=64).contains(&typed.len()) && typed.bytes().all(|b| b"cbdefghijklnrtuv".contains(&b.to_ascii_lowercase()))
244}
245
246/// The PIN as typed, taken into Sequoia's protected memory, and the plain
247/// copy wiped (review of 23 October 2026, F3); empty, none. The copies QML and
248/// Qt made before (the field's text, the QString) are theirs: QML's string is
249/// emptied at once, the QString freed by Qt, neither can be overwritten.
250fn take_pin(mut typed: String) -> Option<Password> {
251    let pin = (!typed.is_empty()).then(|| Password::from(typed.as_str()));
252    // SAFETY: zeros keep the string valid UTF-8.
253    let bytes = unsafe { typed.as_mut_vec() };
254    for byte in bytes.iter_mut() {
255        // SAFETY: a byte of the string's own buffer, written through a volatile
256        // pointer so that the write is not dropped as dead.
257        unsafe { std::ptr::write_volatile(byte, 0) };
258    }
259    typed.clear();
260    pin
261}
262
263/// Whether a PIN typed has waited for its key as long as it may.
264fn pin_waited_too_long(typed: bool, waited: Duration) -> bool {
265    typed && waited > PIN_WAITS_AT_MOST
266}
267
268/// The band asks for the PIN again, saying the key typed a code.
269fn not_a_pin(qt: &QtThread, context: &str) {
270    tell_band(qt, context, Band { state: "pin", line: tr().text("seckey-pin-otp", None), warm: true, ..Band::default() });
271}
272
273fn problem_line(qt: &QtThread, context: &str, line: String, action: &'static str) {
274    tell_band(qt, context, Band { state: "problem", line, warm: true, action, ..Band::default() });
275}
276
277/// The security key asked at Send, as JSON {ident, label, signs}: the one
278/// that signs the draft (signs: true), else the one that opens the encrypted
279/// message it answers, for its quote (signs: false); or {expired: …} when the
280/// signing key's certificate expired; or {cannot: …}, nothing sent, when
281/// Send would only be refused later (review of 19 October 2026, F15): the
282/// key cannot sign here (a phone; on a computer, a key read on another
283/// computer, F18), or an encrypted message forwarded unencrypted, or where
284/// nothing opens it. "" when no security key is asked (on a phone, a reply
285/// then goes without its quote, as its window says).
286pub(crate) fn for_draft(id: &str) -> String {
287    let Some(draft) = Draft::by_id(id) else { return String::new() };
288    let Some(address) = load_config().account(&draft.account).and_then(|a| a.address.clone()) else { return String::new() };
289    let keys = Keys::load();
290    let here = sioul_sync::securitykey::usable_here();
291    let cannot = |id: &str| json(&serde_json::json!({ "cannot": tr().text(id, None) }));
292    // A forward carries the message itself: only encrypted, only from where it opens.
293    if draft.kind == DraftKind::Forward
294        && let Some(id) = forward_refused(draft.encrypt, &crate::crypto::answered(crate::crypto::copy_of(&draft).as_deref()))
295    {
296        return cannot(id);
297    }
298    // The message answered, encrypted, opened at Send in the same band that signs.
299    let opens = quote_card(&draft, &keys);
300    let label = |ident: &str| keys.all_cards().find(|c| c.ident == ident).map(|c| c.label(tr())).unwrap_or_default();
301    match draft.sign.then(|| keys.own_for(&address)) {
302        // A phone, until it can use the key: said, nothing sent.
303        Some(Some(pgp::Own { place: Place::Card { .. }, .. })) if !here => cannot("seckey-sign-not-here"),
304        Some(Some(pgp::Own { place: Place::Card { ident }, .. })) => json(&serde_json::json!({ "ident": ident, "label": label(&ident), "signs": true })),
305        // Your security key serves the address, read on another device: it signs only where it was read.
306        Some(None) if keys.card_for(&address).is_none() && keys.serving_card(&address).is_some() => cannot(if here { read_here_words("seckey-sign-read-here") } else { "seckey-sign-not-here" }),
307        // No key signs for this address now: its certificate expired, said.
308        signing => match matches!(signing, Some(None)).then(|| expired_line(&keys, &address)).flatten() {
309            Some(line) => json(&serde_json::json!({ "expired": line })),
310            None => opens.filter(|_| here).map(|ident| json(&serde_json::json!({ "ident": ident, "label": label(&ident), "signs": false }))).unwrap_or_default(),
311        },
312    }
313}
314
315/// Why Send refuses a forward at once (review of 19 October 2026, F15: it
316/// was refused only at the end of the ten seconds): its copy is encrypted
317/// and the forward is not, or nothing here opens it (encrypted to a key
318/// this device cannot use, or to none of yours: the words the window shows
319/// under the text, F16). None: it may go.
320fn forward_refused(encrypt: bool, answered: &crate::crypto::Answered) -> Option<&'static str> {
321    use crate::crypto::Answered;
322    match answered {
323        Answered::Clear => None,
324        _ if !encrypt => Some("pgp-forward-needs-encryption"),
325        Answered::Elsewhere => Some("seckey-forward-not-here"),
326        Answered::NoKey => Some("compose-forward-sealed-no-key"),
327        Answered::Opens(_) | Answered::AtSend => None,
328    }
329}
330
331/// The security key that must open the message a draft answers or
332/// forwards, at Send: its copy is encrypted, the answer is encrypted (else
333/// it is not quoted), and neither a session key kept since you opened it nor
334/// a key here opens it now. Opened in memory only, to know.
335pub(crate) fn quote_card(draft: &Draft, keys: &Keys) -> Option<String> {
336    if !draft.encrypt {
337        return None;
338    }
339    let raw = crate::crypto::copy_of(draft)?;
340    if !pgp::is_encrypted(&raw) {
341        return None;
342    }
343    let passphrase = |fingerprint: &pgp::Fingerprint| sioul_sync::secret::pgp_passphrase(&fingerprint.to_hex());
344    let unlock = pgp::Unlock::new(&passphrase, pgp::SessionKeys::global());
345    match pgp::open(&raw, keys, &unlock) {
346        Some((Some(_), _)) => None,
347        _ => unlock.wanted(),
348    }
349}
350
351/// What the writing window says when the security key that signs for
352/// `address` has an expired certificate: the date, and how to renew it.
353pub(crate) fn expired_line(keys: &Keys, address: &str) -> Option<String> {
354    let (known, cert) = keys.card_for(address)?;
355    let expires = securitykey::expiry_of(cert, &[&known.sign, &known.decrypt]);
356    securitykey::expiry_line(tr(), expires, &cert.fingerprint().to_hex(), now_unix()).filter(|(_, warm)| *warm).map(|(line, _)| line)
357}
358
359fn now_unix() -> i64 {
360    jiff::Timestamp::now().as_second()
361}
362
363/// The PIN to use: the one typed, else the one held (not for a key that asks
364/// it for each signature, decision 5); with what the key says, read without
365/// its PIN. None as the PIN: the band asks for it.
366fn pin_for(ident: &str, typed: Option<&Password>, purpose: Purpose) -> Result<(CardInfo, Option<Password>), CardError> {
367    pin_from(readers(), || Keys::load().all_cards().find(|c| c.ident.eq_ignore_ascii_case(ident)).cloned(), ident, typed, purpose)
368}
369
370/// `pin_for` with its readers, and what Sioul knows of the key (`known`).
371fn pin_from(readers: &dyn Readers, known: impl FnOnce() -> Option<KnownCard>, ident: &str, typed: Option<&Password>, purpose: Purpose) -> Result<(CardInfo, Option<Password>), CardError> {
372    // A phone: the PIN first, from what Sioul knows of the key, before the tap
373    // that does everything (never a read on its own); never held there.
374    if readers.taps() {
375        let known = known().ok_or(CardError::Absent)?;
376        return Ok((as_info(&known), typed.cloned()));
377    }
378    let info = securitykey::cards(readers)?.into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident)).ok_or(CardError::Absent)?;
379    if info.pin_tries == 0 {
380        return Err(CardError::Blocked);
381    }
382    if let Some(typed) = typed {
383        return Ok((info, Some(typed.clone())));
384    }
385    if purpose == Purpose::Sign && info.pin_each_signature {
386        return Ok((info, None));
387    }
388    let held = PIN.lock().ok().and_then(|mut memory| memory.get(ident, Instant::now()));
389    Ok((info, held))
390}
391
392/// The band asks for the PIN, with the tries left when fewer than the most seen.
393fn ask_pin(qt: &QtThread, context: &str, info: &CardInfo, purpose: Purpose) {
394    let known = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&info.ident)).unwrap_or_default();
395    let tries = known.tries_line(info.pin_tries, tr()).unwrap_or_default();
396    let line = if purpose == Purpose::Sign && info.pin_each_signature { "seckey-pin-each" } else { "seckey-pin" };
397    tell_band(qt, context, Band { state: "pin", line: tr().text(line, None), warm: !tries.is_empty(), tries, ..Band::default() });
398}
399
400/// Tells the window the keys' state changed: the PIN held, or forgotten.
401fn keys_changed() {
402    if let Some(qt) = WINDOW.lock().ok().and_then(|w| w.clone()) {
403        let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
404    }
405}
406
407/// The PIN worked: held for its quiet time, watched; what the key said, kept.
408fn remember(ident: &str, pin: Password, reader: Option<String>, info: &CardInfo) {
409    if keeps_pin(readers())
410        && let Ok(mut memory) = PIN.lock()
411    {
412        memory.keep(ident, pin, &reader.unwrap_or_else(|| info.reader.clone()), Instant::now());
413    }
414    keys_changed();
415    let mut known = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident));
416    if let Some(known) = known.as_mut() {
417        let before = known.clone();
418        known.update(info);
419        if *known != before {
420            let updated = known.clone();
421            let _ = securitykey::change_known(|cards| {
422                if let Some(card) = cards.iter_mut().find(|c| c.ident == updated.ident) {
423                    *card = updated;
424                }
425            });
426        }
427    }
428    watch_pin();
429}
430
431/// Whether a PIN that worked is held for its quiet time: on a computer; on a
432/// phone never, typed for each use.
433fn keeps_pin(readers: &dyn Readers) -> bool {
434    !readers.taps()
435}
436
437/// While a PIN is held: forgotten when its quiet time ends, or when its key
438/// is pulled out (its reader gone).
439fn watch_pin() {
440    if WATCHING.swap(true, Ordering::Relaxed) {
441        return;
442    }
443    std::thread::spawn(|| {
444        loop {
445            std::thread::sleep(LOOK_EVERY);
446            // While Sioul's own GnuPG works with a key, no look: the next turn.
447            if !sioul_sync::securitykey::may_look() {
448                continue;
449            }
450            let present = readers().present();
451            let Ok(mut memory) = PIN.lock() else { break };
452            memory.expire(Instant::now());
453            if let Ok(present) = present {
454                memory.went_away(&present);
455            }
456            if memory.holder().is_none() {
457                break;
458            }
459        }
460        WATCHING.store(false, Ordering::Relaxed);
461        keys_changed();
462    });
463}
464
465/// "Forget the PIN now".
466pub(crate) fn forget_pin() -> String {
467    if let Ok(mut memory) = PIN.lock() {
468        memory.forget();
469    }
470    tr().text("seckey-pin-forgotten", None)
471}
472
473/// Sioul closes: the PIN and the session keys go with it.
474pub(crate) fn closing() {
475    if let Ok(mut memory) = PIN.lock() {
476        memory.forget();
477    }
478    pgp::SessionKeys::global().forget();
479}
480
481/// A PIN the key took before something else failed (a touch missed, the key
482/// pulled out): held, so that "Try again" does not ask it again.
483fn keep_taken(ident: &str, pin: &Password, access: &CardAccess, info: &CardInfo) {
484    if access.pin_accepted() {
485        remember(ident, pin.clone(), access.reader(), info);
486    }
487}
488
489/// What a failure does to the band, and to a PIN held that the key refused.
490/// A key not plugged in is waited for instead ([`wait_for`]).
491/// A key with one PIN try left takes the PIN only once the band said so and
492/// it was typed again; a PIN held, which the key took before, goes.
493fn confirm_last_try(access: &CardAccess, context: &str, typed: bool) {
494    let told = LAST_TRY.lock().is_ok_and(|mut told| told.remove(context));
495    if told || !typed {
496        access.allow_last_try();
497    }
498}
499
500fn failed(qt: &QtThread, context: &str, error: CardError, purpose: Purpose, typed: bool) {
501    if error == CardError::LastTry
502        && let Ok(mut told) = LAST_TRY.lock()
503    {
504        told.insert(context.to_string());
505    }
506    if matches!(error, CardError::WrongPin { .. } | CardError::Blocked) && !typed {
507        // The PIN held is refused: never given again.
508        if let Ok(mut memory) = PIN.lock() {
509            memory.forget();
510        }
511    }
512    let line = sentence(&error, purpose);
513    match error.remedy() {
514        Remedy::Pin => tell_band(qt, context, Band { state: "pin", line, warm: true, ..Band::default() }),
515        Remedy::Again => problem_line(qt, context, line, "retry"),
516        Remedy::Release => problem_line(qt, context, line, "release"),
517        Remedy::NfcSettings => problem_line(qt, context, line, "nfc-settings"),
518        Remedy::None => problem_line(qt, context, line, ""),
519    }
520}
521
522/// Remembers the window, to tell it when the PIN is held or forgotten.
523fn know_window(qt: &QtThread) {
524    if let Ok(mut window) = WINDOW.lock() {
525        *window = Some(qt.clone());
526    }
527}
528
529/// Signs a draft with your security key at Send, then schedules it with its
530/// ten seconds of "Undo"; `typed` is the PIN typed in the band, else "".
531pub(crate) fn sign_and_send(qt: &QtThread, shared: &Arc<Shared>, id: &str, typed: String) {
532    know_window(qt);
533    if typed_by_the_key(&typed) {
534        take_pin(typed);
535        return not_a_pin(qt, id);
536    }
537    let typed = take_pin(typed);
538    let (qt, shared, id) = (qt.clone(), Arc::clone(shared), id.to_string());
539    std::thread::spawn(move || sign_now(&qt, &shared, &id, typed));
540}
541
542fn sign_now(qt: &QtThread, shared: &Arc<Shared>, id: &str, typed: Option<Password>) {
543    working(qt, id, "seckey-reading");
544    let Some(draft) = Draft::by_id(id) else { return problem_line(qt, id, tr().text("mail-draft-gone", None), "") };
545    let config = load_config();
546    let Some(account) = config.account(&draft.account).filter(|a| a.syncs()).cloned() else {
547        return problem_line(qt, id, say("account-unknown", &[("id", draft.account.clone())]), "");
548    };
549    let keys = Keys::load();
550    // Signed with your security key, or its quote opened with it (the copy of
551    // the message answered is encrypted, and no session key opens it now): one band, one
552    // PIN, both done with the same access.
553    let signs = match draft.sign.then(|| keys.own_for(account.address.as_deref().unwrap_or_default())).flatten() {
554        Some(pgp::Own { place: Place::Card { ident }, .. }) => Some(ident),
555        _ => None,
556    };
557    let opens = quote_card(&draft, &keys);
558    let ident = match (signs.as_deref(), opens.as_deref()) {
559        // Two keys (the message answered was encrypted to another one): that
560        // one opens it first, its session key then kept in memory; then the
561        // one that signs, each with its own PIN, never the other's.
562        (Some(sign), Some(open)) if !same_key(&keys, sign, open) => return open_quote_now(qt, shared, &draft, open, typed),
563        (Some(ident), _) | (None, Some(ident)) => ident.to_string(),
564        // Not (or no longer) signed with a security key: sent as any message is.
565        (None, None) => {
566            match crate::mail::send(qt, shared, id) {
567                Some(problem) => problem_line(qt, id, problem, ""),
568                None => tell_band(qt, id, Band { state: "done", ..Band::default() }),
569            }
570            return;
571        }
572    };
573    let purpose = if signs.is_some() { Purpose::Sign } else { Purpose::Open };
574    let (info, pin) = match pin_for(&ident, typed.as_ref(), purpose) {
575        Ok(found) => found,
576        Err(CardError::Absent) => return wait_for(qt, shared, id, &ident, purpose, typed, sign_now),
577        Err(error) => return failed(qt, id, error, purpose, typed.is_some()),
578    };
579    let Some(pin) = pin else { return ask_pin(qt, id, &info, purpose) };
580    working(qt, id, if signs.is_some() { "seckey-signing" } else { "seckey-opening" });
581    // Said at the moment the key is asked, when it may wait for a touch: hold it a second or two.
582    // Opening the quote and signing: said for whichever slot asks a touch.
583    let opening = opens.is_some().then_some(info.decrypt.as_ref()).flatten().filter(|s| s.touch.asked());
584    let slot = opening.or(if signs.is_some() { info.sign.as_ref() } else { info.decrypt.as_ref() });
585    let (touch_qt, context, line) = (qt.clone(), id.to_string(), touch_line(slot));
586    let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
587    let access = CardAccess::new(readers(), &pin, &touch);
588    confirm_last_try(&access, id, typed.is_some());
589    match sioul_sync::send::build_draft(&account, &draft, tr(), Some(&access)) {
590        Ok(outgoing) => {
591            remember(&ident, pin.clone(), access.reader(), &info);
592            crate::mail::send_signed(qt, shared, id, account, outgoing);
593            tell_band(qt, id, Band { state: "done", ..Band::default() });
594            lift(qt, shared);
595        }
596        Err(BuildError::Card(CardError::Absent)) => wait_for(qt, shared, id, &ident, purpose, typed, sign_now),
597        Err(BuildError::Card(error)) => {
598            keep_taken(&ident, &pin, &access, &info);
599            failed(qt, id, error, purpose, typed.is_some());
600        }
601        // A server's words, never read as a command to copy (the band shows backticked commands as such).
602        Err(other) => problem_line(qt, id, other.into_sync(tr()).sentence(tr(), &account.id).replace('`', "'"), ""),
603    }
604}
605
606/// Whether the security keys `a` and `b` open the same messages: the same
607/// key, or a copy of it (the same decryption key on both).
608fn same_key(keys: &Keys, a: &str, b: &str) -> bool {
609    let decrypt = |ident: &str| keys.all_cards().find(|c| c.ident.eq_ignore_ascii_case(ident)).map(|c| c.decrypt.to_ascii_lowercase()).filter(|f| !f.is_empty());
610    a.eq_ignore_ascii_case(b) || decrypt(a).is_some_and(|f| decrypt(b) == Some(f))
611}
612
613/// Opens the encrypted message a draft answers with the security key `ident`,
614/// another than the one that signs: its session key is kept in memory, and
615/// the draft goes on to its signature, whose key asks its own PIN.
616fn open_quote_now(qt: &QtThread, shared: &Arc<Shared>, draft: &Draft, ident: &str, typed: Option<Password>) {
617    let id = draft.id.as_str();
618    let (info, pin) = match pin_for(ident, typed.as_ref(), Purpose::Open) {
619        Ok(found) => found,
620        Err(CardError::Absent) => return wait_for(qt, shared, id, ident, Purpose::Open, typed, sign_now),
621        Err(error) => return failed(qt, id, error, Purpose::Open, typed.is_some()),
622    };
623    let Some(pin) = pin else { return ask_pin(qt, id, &info, Purpose::Open) };
624    working(qt, id, "seckey-opening");
625    let (touch_qt, context, line) = (qt.clone(), id.to_string(), touch_line(info.decrypt.as_ref()));
626    let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
627    let access = CardAccess::new(readers(), &pin, &touch);
628    confirm_last_try(&access, id, typed.is_some());
629    let passphrase = |fingerprint: &pgp::Fingerprint| sioul_sync::secret::pgp_passphrase(&fingerprint.to_hex());
630    let unlock = pgp::Unlock::new(&passphrase, pgp::SessionKeys::global()).with_card(&access);
631    let raw = crate::crypto::copy_of(draft);
632    match raw.and_then(|raw| pgp::open(&raw, &Keys::load(), &unlock)) {
633        Some((Some(_), _)) => {
634            remember(ident, pin.clone(), access.reader(), &info);
635            // Its session key kept, it opens without this key now; else said, never asked again in a loop.
636            if quote_card(draft, &Keys::load()).is_some() {
637                return problem_line(qt, id, tr().text("pgp-quote-locked", None), "retry");
638            }
639            // The PIN typed was this key's: the one that signs asks its own.
640            sign_now(qt, shared, id, None);
641        }
642        _ => match access.failure() {
643            Some(CardError::Absent) => wait_for(qt, shared, id, ident, Purpose::Open, typed, sign_now),
644            Some(error) => {
645                keep_taken(ident, &pin, &access, &info);
646                failed(qt, id, error, Purpose::Open, typed.is_some());
647            }
648            None if draft.kind == DraftKind::Forward => problem_line(qt, id, tr().text("pgp-forward-locked", None), ""),
649            None => problem_line(qt, id, tr().text("pgp-quote-locked", None), ""),
650        },
651    }
652}
653
654/// A phone: once the key has done its work, the status line says it may be taken away, or unplugged.
655fn lift(qt: &QtThread, shared: &Arc<Shared>) {
656    let way = way();
657    if way.taps {
658        tell(qt, shared, tr().text(if way.cable { "seckey-done-unplug" } else { "seckey-done-lift" }, None));
659    }
660}
661
662/// What the band says when the key's slot may wait for a touch, its setting
663/// read from the key this session (`Touch::words`): "If your key asks for a
664/// touch…" when it could not be read. Held to a phone, the tap is the touch:
665/// keep holding it.
666fn touch_line(slot: Option<&securitykey::SlotInfo>) -> String {
667    // Plugged into a phone, the computer's words: the key waits for a touch.
668    if way().held() {
669        return tr().text("seckey-holding", None);
670    }
671    slot.map_or(Touch::Unknown, |s| s.touch).words(tr()).unwrap_or_else(|| tr().text("seckey-touch-maybe", None))
672}
673
674/// Opens a message encrypted to your security key ("Open with your security
675/// key"); its session key then stays in memory until Sioul closes, so that it
676/// opens again, with its attachments and its answer, without the key.
677pub(crate) fn open_message(qt: &QtThread, shared: &Arc<Shared>, key: &str, typed: String) {
678    know_window(qt);
679    if typed_by_the_key(&typed) {
680        take_pin(typed);
681        return not_a_pin(qt, key);
682    }
683    let typed = take_pin(typed);
684    let (qt, shared, key) = (qt.clone(), Arc::clone(shared), key.to_string());
685    std::thread::spawn(move || open_now(&qt, &shared, &key, typed));
686}
687
688fn open_now(qt: &QtThread, shared: &Arc<Shared>, key: &str, typed: Option<Password>) {
689    working(qt, key, "seckey-reading");
690    let Some(raw) = crate::mail::locate(key).and_then(|(_, file)| std::fs::read(file).ok()) else {
691        return problem_line(qt, key, tr().text("mail-message-gone", None), "");
692    };
693    // Which key it waits for; opened already from its session key, it is done.
694    let ident = match crate::crypto::open(&raw) {
695        Some((Some(_), _)) | None => return tell_band(qt, key, Band { state: "done", ..Band::default() }),
696        Some((None, view)) => match view.security_key {
697            Some(ident) => ident,
698            None => return problem_line(qt, key, tr().text("pgp-not-opened", None), ""),
699        },
700    };
701    let (info, pin) = match pin_for(&ident, typed.as_ref(), Purpose::Open) {
702        Ok(found) => found,
703        Err(CardError::Absent) => return wait_for(qt, shared, key, &ident, Purpose::Open, typed, open_now),
704        Err(error) => return failed(qt, key, error, Purpose::Open, typed.is_some()),
705    };
706    let Some(pin) = pin else { return ask_pin(qt, key, &info, Purpose::Open) };
707    working(qt, key, "seckey-opening");
708    let (touch_qt, context, line) = (qt.clone(), key.to_string(), touch_line(info.decrypt.as_ref()));
709    let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
710    let access = CardAccess::new(readers(), &pin, &touch);
711    confirm_last_try(&access, key, typed.is_some());
712    let passphrase = |fingerprint: &pgp::Fingerprint| sioul_sync::secret::pgp_passphrase(&fingerprint.to_hex());
713    let unlock = pgp::Unlock::new(&passphrase, pgp::SessionKeys::global()).with_card(&access);
714    match pgp::open(&raw, &Keys::load(), &unlock) {
715        Some((Some(_), _)) => {
716            remember(&ident, pin.clone(), access.reader(), &info);
717            tell_band(qt, key, Band { state: "done", ..Band::default() });
718            lift(qt, shared);
719        }
720        _ => match access.failure() {
721            Some(CardError::Absent) => wait_for(qt, shared, key, &ident, Purpose::Open, typed, open_now),
722            Some(error) => {
723                keep_taken(&ident, &pin, &access, &info);
724                failed(qt, key, error, Purpose::Open, typed.is_some());
725            }
726            None => problem_line(qt, key, tr().text("pgp-not-opened", None), ""),
727        },
728    }
729}
730
731/// What goes on once the key is plugged in: [`sign_now`] for a draft (its
732/// signature, or the message it answers opened), [`open_now`] for a message.
733type Resume = fn(&QtThread, &Arc<Shared>, &str, Option<Password>);
734
735/// The key is not plugged in: the band says so, and Sioul looks, without
736/// opening any key, until it comes, "Not now", or a few minutes pass.
737fn wait_for(qt: &QtThread, shared: &Arc<Shared>, context: &str, ident: &str, purpose: Purpose, typed: Option<Password>, resume: Resume) {
738    if let Ok(mut waiting) = WAITING.lock() {
739        waiting.insert(context.to_string());
740    }
741    tell_band(qt, context, Band { state: "plug", line: waiting_line(purpose), action: waiting_action(&way()), ..Band::default() });
742    let (qt, shared, context, ident) = (qt.clone(), Arc::clone(shared), context.to_string(), ident.to_string());
743    std::thread::spawn(move || {
744        let since = Instant::now();
745        let mut seen: Option<Vec<String>> = None;
746        loop {
747            // A phone wakes as soon as a key is held to it; a computer looks again.
748            readers().wait_change(LOOK_EVERY);
749            if !WAITING.lock().is_ok_and(|w| w.contains(&context)) {
750                return;
751            }
752            if since.elapsed() > WAIT_AT_MOST {
753                if let Ok(mut waiting) = WAITING.lock() {
754                    waiting.remove(&context);
755                }
756                return problem_line(&qt, &context, waiting_line(purpose), "retry");
757            }
758            // A PIN typed waits a minute for its key at most: forgotten, asked again.
759            if pin_waited_too_long(typed.is_some(), since.elapsed()) {
760                if let Ok(mut waiting) = WAITING.lock() {
761                    waiting.remove(&context);
762                }
763                drop(typed);
764                return tell_band(&qt, &context, Band { state: "pin", line: tr().text("seckey-pin-dropped", None), ..Band::default() });
765            }
766            // Held to a phone: the work goes on at once, in this one tap (never read on its own first).
767            if taps() {
768                match readers().present() {
769                    Ok(held) if held.is_empty() => continue,
770                    Ok(_) => {
771                        if let Ok(mut waiting) = WAITING.lock() {
772                            waiting.remove(&context);
773                        }
774                        return resume(&qt, &shared, &context, typed);
775                    }
776                    Err(error) => {
777                        if let Ok(mut waiting) = WAITING.lock() {
778                            waiting.remove(&context);
779                        }
780                        return failed(&qt, &context, error, purpose, typed.is_some());
781                    }
782                }
783            }
784            // While Sioul's own GnuPG works with a key, no look: the next turn.
785            if !sioul_sync::securitykey::may_look() {
786                continue;
787            }
788            // A key is opened only when the readers change: another key plugged in is left alone.
789            let present = readers().present().ok();
790            if present == seen {
791                continue;
792            }
793            seen = present;
794            match key_in(readers(), &ident) {
795                Ok(_) => {
796                    if let Ok(mut waiting) = WAITING.lock() {
797                        waiting.remove(&context);
798                    }
799                    return resume(&qt, &shared, &context, typed);
800                }
801                // Plugged in, but GnuPG holds it: said, with "Let GnuPG release it", rather than waited for in vain.
802                Err(CardError::Busy) => {
803                    if let Ok(mut waiting) = WAITING.lock() {
804                        waiting.remove(&context);
805                    }
806                    return problem_line(&qt, &context, CardError::Busy.sentence(tr(), purpose), "release");
807                }
808                Err(_) => {}
809            }
810        }
811    });
812}
813
814/// "Not now": the band stops waiting for the key.
815pub(crate) fn not_now(context: &str) {
816    if let Ok(mut waiting) = WAITING.lock() {
817        waiting.remove(context);
818    }
819    if let Ok(mut told) = LAST_TRY.lock() {
820        told.remove(context);
821    }
822    if taps() {
823        readers().want(false);
824    }
825}
826
827/// "Open NFC settings": Android's own switch, on a phone.
828pub(crate) fn open_nfc_settings() {
829    #[cfg(target_os = "android")]
830    crate::nfc::open_settings();
831}
832
833/// "Let GnuPG release it", pressed: GnuPG's smart card daemon stopped.
834/// Returns JSON {line, done}: done, the band tries again at once.
835pub(crate) fn release() -> String {
836    let released = if offline() { Released::Done } else { sioul_sync::securitykey::release_gnupg() };
837    json(&serde_json::json!({ "line": released.sentence(tr()), "done": released == Released::Done }))
838}
839
840/// The cardholder's name as written on the key ("Ferrand<<Noa",
841/// ISO/IEC 7501-1), in the usual order ("Noa Ferrand").
842fn holder_name(written: &str) -> String {
843    match written.split_once("<<") {
844        Some((surname, given)) => format!("{} {}", given.replace('<', " ").trim(), surname.replace('<', " ").trim()).trim().to_string(),
845        None => written.replace('<', " ").trim().to_string(),
846    }
847}
848
849/// "YubiKey 12 345 678, Noa Ferrand. It signs (Ed25519, made on 3 March 2026) and decrypts (Curve25519)."
850fn describe(info: &CardInfo) -> String {
851    let holder = holder_name(&info.holder);
852    let holder = if holder.is_empty() { String::new() } else { format!(", {holder}") };
853    let made = |slot: &securitykey::SlotInfo| match slot.created {
854        0 => slot.algorithm.clone(),
855        at => say("seckey-made-on", &[("algorithm", slot.algorithm.clone()), ("date", securitykey::full_date(tr(), i64::from(at)))]),
856    };
857    let (sign, decrypt) = (info.sign.as_ref().map(made), info.decrypt.as_ref().map(made));
858    let id = match (&sign, &decrypt) {
859        (Some(_), Some(_)) => "seckey-found-both",
860        (Some(_), None) => "seckey-found-sign",
861        _ => "seckey-found-decrypt",
862    };
863    say(id, &[("label", info.label(tr())), ("holder", holder), ("sign", sign.unwrap_or_default()), ("decrypt", decrypt.unwrap_or_default())])
864}
865
866/// "Use a security key": reads the key plugged in, without its PIN.
867pub(crate) fn read_for_setup(qt: &QtThread) {
868    let qt = qt.clone();
869    std::thread::spawn(move || {
870        working(&qt, SETUP_BAND, "seckey-reading");
871        // A phone: the key is read when held to it; the band says where, and waits.
872        if taps() && !held_for_setup(&qt) {
873            return;
874        }
875        match securitykey::cards(readers()) {
876            Ok(found) => {
877                let known = securitykey::known_cards();
878                // A key not set up yet comes first, when several are plugged in.
879                let Some(info) = found.iter().find(|c| !known.iter().any(|k| k.ident == c.ident)).or(found.first()).cloned() else { return };
880                // Known from another device of yours, its certificate with it: read here
881                // now, kept at once (on a phone, this is how it comes to sign there).
882                if let Some((cert, check, source)) = shared_certificate(&info) {
883                    return adopt(&qt, &info, &cert, &check, &source);
884                }
885                let line = describe(&info);
886                let fingerprint = fingerprint_of(&info);
887                let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
888                if let Ok(mut setup) = SETUP.lock() {
889                    *setup = Some(info);
890                }
891                tell_band(&qt, SETUP_BAND, Band { state: "found", line, fingerprint, commands, ..Band::default() });
892            }
893            Err(error) => tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
894        }
895    });
896}
897
898/// A phone, at the setup: waits for a key held to it, the band saying so,
899/// until one comes, "Not now", or a few minutes pass; whether one came.
900fn held_for_setup(qt: &QtThread) -> bool {
901    if let Ok(mut waiting) = WAITING.lock() {
902        waiting.insert(SETUP_BAND.to_string());
903    }
904    tell_band(qt, SETUP_BAND, Band { state: "plug", line: waiting_line(Purpose::Read), action: waiting_action(&way()), ..Band::default() });
905    let since = Instant::now();
906    loop {
907        match readers().present() {
908            Ok(held) if !held.is_empty() => break,
909            Ok(_) => {}
910            Err(error) => {
911                failed(qt, SETUP_BAND, error, Purpose::Read, false);
912                break;
913            }
914        }
915        if !WAITING.lock().is_ok_and(|w| w.contains(SETUP_BAND)) {
916            return false;
917        }
918        if since.elapsed() > WAIT_AT_MOST {
919            problem_line(qt, SETUP_BAND, waiting_line(Purpose::Read), "retry");
920            break;
921        }
922        readers().wait_change(LOOK_EVERY);
923    }
924    let came = WAITING.lock().is_ok_and(|mut w| w.remove(SETUP_BAND)) && readers().present().is_ok_and(|held| !held.is_empty());
925    if came {
926        working(qt, SETUP_BAND, "seckey-holding");
927    }
928    came
929}
930
931/// The certificate your other devices made known with the key `info` (read
932/// on another computer of yours, shared), when it is the key's: what the
933/// setup keeps at once, with where it came from, rather than looking it up.
934fn shared_certificate(info: &CardInfo) -> Option<(securitykey::Certificate, securitykey::CertCheck, String)> {
935    shared_in(&Keys::load(), info)
936}
937
938fn shared_in(keys: &Keys, info: &CardInfo) -> Option<(securitykey::Certificate, securitykey::CertCheck, String)> {
939    let record = keys.shared.iter().find(|c| c.ident.eq_ignore_ascii_case(&info.ident))?;
940    let cert = keys.cert(&record.cert)?;
941    let check = securitykey::check(cert, info).ok()?;
942    Some((cert.clone(), check, record.source.clone()))
943}
944
945/// The fingerprint `gpg --export` takes for the key: its signing key's, else its decryption key's.
946fn fingerprint_of(info: &CardInfo) -> String {
947    info.sign.as_ref().or(info.decrypt.as_ref()).map(|s| s.fingerprint.clone()).unwrap_or_default()
948}
949
950/// The key to look up a certificate for: the one the setup read, else a key
951/// known already ("Look for a newer version"), from what Sioul kept of it.
952fn card_for_lookup(ident: &str) -> Result<CardInfo, CardError> {
953    let read = SETUP.lock().ok().and_then(|s| s.clone()).filter(|c| ident.is_empty() || c.ident.eq_ignore_ascii_case(ident));
954    let known = || securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident)).map(|known| as_info(&known));
955    match read.or_else(known) {
956        Some(info) => Ok(info),
957        // Neither read nor known: the key itself, which GnuPG may hold (offered to release).
958        None => key_in(readers(), ident),
959    }
960}
961
962/// The key `ident` as it says itself ("": the first one), read without its PIN.
963fn key_in(readers: &dyn Readers, ident: &str) -> Result<CardInfo, CardError> {
964    securitykey::cards(readers)?.into_iter().find(|c| ident.is_empty() || c.ident.eq_ignore_ascii_case(ident)).ok_or(CardError::Absent)
965}
966
967/// What the setup's band says when the key could not be read: a key another
968/// program holds offers "Let GnuPG release it", never a dead end; one not
969/// plugged in, "Try again".
970fn setup_problem(error: &CardError, words: &Translator) -> Band {
971    setup_problem_in(&way(), error, words)
972}
973
974fn setup_problem_in(way: &Way, error: &CardError, words: &Translator) -> Band {
975    Band { state: "problem", line: sentence_in(way, error, Purpose::Read, words), warm: true, action: setup_action(error), ..Band::default() }
976}
977
978/// The setup's button for a key that could not be read: "Let GnuPG release
979/// it", "Open NFC settings", else "Try again".
980fn setup_action(error: &CardError) -> &'static str {
981    match error.remedy() {
982        Remedy::Release => "release",
983        Remedy::NfcSettings => "nfc-settings",
984        _ => "retry",
985    }
986}
987
988/// What Sioul kept of a key, as the key would say it (fingerprints, no public parts).
989fn as_info(known: &KnownCard) -> CardInfo {
990    let slot = |fingerprint: &str, touch: Touch| (!fingerprint.is_empty()).then(|| securitykey::SlotInfo { fingerprint: fingerprint.to_string(), created: 0, algorithm: String::new(), touch, public: None });
991    CardInfo {
992        ident: known.ident.clone(),
993        manufacturer: known.manufacturer,
994        serial: known.serial,
995        holder: known.holder.clone(),
996        url: String::new(),
997        sign: slot(&known.sign, known.touch_sign),
998        decrypt: slot(&known.decrypt, known.touch_decrypt),
999        pin_tries: known.pin_tries_full,
1000        reset_tries: 0,
1001        admin_tries: 0,
1002        pin_each_signature: known.pin_each_signature,
1003        signatures: 0,
1004        reader: String::new(),
1005    }
1006}
1007
1008/// Your mail addresses.
1009fn your_addresses() -> Vec<String> {
1010    load_config().accounts.iter().filter(|a| a.syncs()).filter_map(|a| a.address.clone()).map(|a| a.to_ascii_lowercase()).collect()
1011}
1012
1013/// "Look for it", "Look for a newer version": the key's certificate looked
1014/// up, only now that you asked, checked against the key, and kept.
1015pub(crate) fn look_for_certificate(qt: &QtThread, ident: &str) {
1016    let (qt, ident) = (qt.clone(), ident.to_string());
1017    std::thread::spawn(move || {
1018        let info = match card_for_lookup(&ident) {
1019            Ok(info) => info,
1020            Err(error) => return tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
1021        };
1022        working(&qt, SETUP_BAND, "seckey-looking");
1023        let looked = if offline() {
1024            // A demo profile stays off the network: its own key's certificate, as if found.
1025            match demo().map(|d| securitykey::certificate_in(&d.certificate, &info)) {
1026                Some(Ok((cert, check))) => Ok(Looked::Found { cert, check, source: "https://keys.openpgp.org/".into() }),
1027                _ => Ok(Looked::NotFound),
1028            }
1029        } else {
1030            sioul_sync::securitykey::find_certificate(&info, &your_addresses())
1031        };
1032        match looked {
1033            Ok(Looked::Found { cert, check, source }) => adopt(&qt, &info, &cert, &check, &source),
1034            Ok(Looked::Mismatch(why)) => problem_line(&qt, SETUP_BAND, why.sentence(tr()), ""),
1035            Ok(Looked::NotFound) => {
1036                let fingerprint = fingerprint_of(&info);
1037                let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
1038                tell_band(&qt, SETUP_BAND, Band { state: "missing", line: tr().text("seckey-not-found", None), fingerprint, commands, ..Band::default() })
1039            }
1040            Err(error) => problem_line(&qt, SETUP_BAND, error.sentence(tr(), ""), "lookup"),
1041        }
1042    });
1043}
1044
1045/// "Import a file…": the key's certificate from `gpg --export`, checked, kept.
1046pub(crate) fn import_certificate(qt: &QtThread, ident: &str, url: &str) {
1047    let info = match card_for_lookup(ident) {
1048        Ok(info) => info,
1049        Err(error) => return tell_band(qt, SETUP_BAND, setup_problem(&error, tr())),
1050    };
1051    let path = local_path(url);
1052    match std::fs::read(&path) {
1053        Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
1054            Ok((cert, check)) => adopt(qt, &info, &cert, &check, "file"),
1055            Err(why) => problem_line(qt, SETUP_BAND, why.sentence(tr()), ""),
1056        },
1057        Err(e) => problem_line(qt, SETUP_BAND, format!("{}: {e}", path.display()), ""),
1058    }
1059}
1060
1061/// The certificate found is the key's: kept, the key made yours for the
1062/// addresses it names among yours, and said.
1063fn adopt(qt: &QtThread, info: &CardInfo, cert: &securitykey::Certificate, check: &securitykey::CertCheck, source: &str) {
1064    match keep(info, cert, check, source) {
1065        Ok(kept) => {
1066            // A part expired: renewed from here, where GnuPG can, or by hand, each subkey named.
1067            let (action, commands) = if kept.expired && renewable() { ("renew-offer", kept.commands) } else if kept.expired { ("", kept.commands) } else { ("", Vec::new()) };
1068            tell_band(qt, SETUP_BAND, Band { state: "done", line: kept.line, warm: kept.warm, action, ident: info.ident.clone(), commands, ..Band::default() });
1069            let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
1070        }
1071        Err(e) => problem_line(qt, SETUP_BAND, e, ""),
1072    }
1073}
1074
1075/// What keeping a certificate says, and what it offers.
1076struct Kept {
1077    line: String,
1078    warm: bool,
1079    /// A part of it expired: said exactly, with a way to renew it.
1080    expired: bool,
1081    /// The commands that renew it by hand, each subkey named.
1082    commands: Vec<String>,
1083}
1084
1085
1086/// The certificate kept for the key, the key made yours for the addresses it
1087/// names among yours: what to say, and whether warmly.
1088fn keep(info: &CardInfo, cert: &securitykey::Certificate, check: &securitykey::CertCheck, source: &str) -> Result<Kept, String> {
1089    let yours = your_addresses();
1090    let named: Vec<String> = check.addresses.iter().filter(|a| yours.contains(a)).cloned().collect();
1091    // A certificate naming no address at all (keys.openpgp.org gives none
1092    // until you confirm one) still signs your mail; one naming others' does not.
1093    let addresses = if check.addresses.is_empty() { yours } else { named };
1094    securitykey::adopt(info, cert, source, addresses.clone(), now_unix())?;
1095    let names = cert.userids().map(|u| String::from_utf8_lossy(u.userid().value()).to_string()).collect::<Vec<_>>().join(", ");
1096    let mut line = if check.addresses.is_empty() {
1097        tr().text("seckey-kept-no-address", None)
1098    } else if addresses.is_empty() {
1099        say("seckey-kept-not-yours", &[("names", names)])
1100    } else {
1101        say("seckey-kept", &[("names", names), ("addresses", addresses.join(", "))])
1102    };
1103    let mut warm = addresses.is_empty();
1104    let (expiry, late, expired) = securitykey::expiry_words(tr(), cert, check.expires, now_unix());
1105    if !expiry.is_empty() {
1106        line = format!("{line} {expiry}");
1107        warm |= late;
1108    }
1109    if let Ok(mut setup) = SETUP.lock() {
1110        *setup = None;
1111    }
1112    Ok(Kept { line, warm, expired, commands: sioul_sync::securitykey::renew_commands(&check.cert, &securitykey::subkeys_of(cert)) })
1113}
1114
1115/// What GnuPG did not do, in words, and what to do then.
1116fn gnupg_sentence(problem: &GnupgProblem, fingerprint: &str) -> String {
1117    match problem {
1118        GnupgProblem::Missing => tr().text("seckey-gnupg-missing", None),
1119        GnupgProblem::Sandboxed => tr().text("seckey-gnupg-sandboxed", None),
1120        GnupgProblem::NoSuchKey => say("seckey-gnupg-no-key", &[("fingerprint", fingerprint.to_string())]),
1121        GnupgProblem::TouchMissed => tr().text("seckey-gnupg-touch-missed", None),
1122        // Its own words, never read as a command to copy.
1123        GnupgProblem::Failed(detail) => say("seckey-gnupg-failed", &[("detail", detail.replace('`', "'"))]),
1124    }
1125}
1126
1127/// GnuPG here, or why not; in a demo, none: the demo's key stands in.
1128fn gnupg() -> Result<Gnupg, GnupgProblem> {
1129    if offline() { Err(GnupgProblem::Missing) } else { Gnupg::find() }
1130}
1131
1132/// "Import from GnuPG": the key's certificate as your GnuPG keeps it
1133/// (`gpg --export --armor`), checked against the key, kept. A demo profile
1134/// takes its own key's certificate, as if GnuPG had it.
1135pub(crate) fn import_from_gnupg(qt: &QtThread, ident: &str) {
1136    let (qt, ident) = (qt.clone(), ident.to_string());
1137    std::thread::spawn(move || {
1138        let info = match card_for_lookup(&ident) {
1139            Ok(info) => info,
1140            Err(error) => return tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
1141        };
1142        working(&qt, SETUP_BAND, "seckey-gnupg-reading");
1143        let fingerprint = fingerprint_of(&info);
1144        let exported = match demo().filter(|_| offline()) {
1145            Some(demo) => Ok(demo.certificate.clone()),
1146            None => gnupg().and_then(|g| g.export(&fingerprint)),
1147        };
1148        match exported {
1149            Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
1150                Ok((cert, check)) => adopt(&qt, &info, &cert, &check, "gnupg"),
1151                Err(why) => problem_line(&qt, SETUP_BAND, why.sentence(tr()), ""),
1152            },
1153            Err(problem) => {
1154                let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
1155                tell_band(&qt, SETUP_BAND, Band { state: "problem", line: gnupg_sentence(&problem, &fingerprint), warm: true, fingerprint, commands, ..Band::default() });
1156            }
1157        }
1158    });
1159}
1160
1161/// Whether GnuPG is here to renew a key: never on a phone, in a Flatpak, or
1162/// a demo. Whether it holds the key is asked only on "Renew for two years":
1163/// GnuPG is never run behind your back, nor the security key woken for it.
1164fn renewable() -> bool {
1165    gnupg().is_ok()
1166}
1167
1168/// "Renew for two years": GnuPG renews the key, then its subkeys
1169/// (`gpg --quick-set-expire`, its own pinentry asking the security key's PIN,
1170/// and a touch); the certificate it then holds is checked and kept here.
1171pub(crate) fn renew(qt: &QtThread, ident: &str) {
1172    let (qt, ident) = (qt.clone(), ident.to_string());
1173    std::thread::spawn(move || {
1174        let Some(known) = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&ident)) else { return problem_line(&qt, SETUP_BAND, CardError::Absent.sentence(tr(), Purpose::Read), "") };
1175        let info = as_info(&known);
1176        let subkeys = Keys::load().cert(&known.cert).map(securitykey::subkeys_of).unwrap_or_default();
1177        let commands: Vec<String> = sioul_sync::securitykey::renew_commands(&known.cert, &subkeys);
1178        // The key GnuPG renews with is the one that certifies: on a security key, its signing key.
1179        let touch = known.touch_sign;
1180        tell_band(&qt, SETUP_BAND, Band { state: "working", line: tr().text("seckey-renewing", None), ident: ident.clone(), ..Band::default() });
1181        let watch = |step: GnupgStep| tell_band(&qt, SETUP_BAND, Band { state: "working", line: renew_line(step, touch, tr()), ident: ident.clone(), ..Band::default() });
1182        let renewed = gnupg().and_then(|g| {
1183            g.renew(&known.cert, "2y", &watch)?;
1184            g.export(&known.cert)
1185        });
1186        let failed = |line: String, action: &'static str| tell_band(&qt, SETUP_BAND, Band { state: "problem", line, warm: true, action, ident: ident.clone(), commands: commands.clone(), ..Band::default() });
1187        match renewed {
1188            Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
1189                // Read again, part by part: a part still expired is said, and nothing is kept as renewed.
1190                Ok((cert, _)) if !securitykey::expired_parts(tr(), &securitykey::parts(&cert), now_unix()).is_empty() => {
1191                    let still = securitykey::expired_parts(tr(), &securitykey::parts(&cert), now_unix()).join(" ");
1192                    failed(say("seckey-renew-incomplete", &[("parts", still)]), "renew");
1193                }
1194                Ok((cert, check)) => match keep(&info, &cert, &check, "gnupg") {
1195                    Ok(_) => {
1196                        let parts = securitykey::parts_until(tr(), &securitykey::parts(&cert));
1197                        tell_band(&qt, SETUP_BAND, Band { state: "done", line: say("seckey-renewed", &[("parts", parts)]), action: "publish", ident: ident.clone(), ..Band::default() });
1198                        let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
1199                    }
1200                    Err(e) => failed(e, ""),
1201                },
1202                Err(why) => failed(why.sentence(tr()), ""),
1203            },
1204            // GnuPG does not know the card yet: `gpg --card-status` teaches it.
1205            Err(GnupgProblem::NoSuchKey) => failed(tr().text("seckey-renew-unknown", None), ""),
1206            // The key waited for a touch in vain: said plainly, and tried again on a press.
1207            Err(GnupgProblem::TouchMissed) => failed(gnupg_sentence(&GnupgProblem::TouchMissed, &known.cert), "renew"),
1208            Err(problem) => failed(gnupg_sentence(&problem, &known.cert), ""),
1209        }
1210    });
1211}
1212
1213/// What the setup says while GnuPG renews the key: its pinentry open, the PIN
1214/// typed there (then a touch, when the key may ask for one); the key's turn,
1215/// to touch it now and hold the finger a second or two, as long as GnuPG works
1216/// (`Touch::words`); else that GnuPG renews it.
1217fn renew_line(step: GnupgStep, touch: Touch, tr: &sioul_core::i18n::Translator) -> String {
1218    match (step, touch.words(tr)) {
1219        (GnupgStep::Pin, Some(_)) => format!("{} {}", tr.text("seckey-gnupg-pin", None), tr.text("seckey-gnupg-then-touch", None)),
1220        (GnupgStep::Pin, None) => tr.text("seckey-gnupg-pin", None),
1221        (GnupgStep::Card, Some(words)) => words,
1222        (GnupgStep::Card, None) => tr.text("seckey-renewing", None),
1223    }
1224}
1225
1226/// "Send it to keys.openpgp.org", pressed after the sentence that says what
1227/// goes public: the certificate Sioul keeps for the key, its public part only,
1228/// sent; keys.openpgp.org then mails each address it names a link. A demo
1229/// profile sends nothing, and says what would come.
1230pub(crate) fn send_to_keys_openpgp(qt: &QtThread, ident: &str) {
1231    let (qt, ident) = (qt.clone(), ident.to_string());
1232    std::thread::spawn(move || {
1233        let Some(known) = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&ident)) else { return problem_line(&qt, SETUP_BAND, CardError::Absent.sentence(tr(), Purpose::Read), "") };
1234        let Some(armored) = pgp::export_public(&known.cert) else { return problem_line(&qt, SETUP_BAND, tr().text("seckey-not-found", None), "") };
1235        working(&qt, SETUP_BAND, "seckey-sending");
1236        let sent = if offline() {
1237            Ok(sioul_sync::securitykey::Published { fingerprint: known.cert.clone(), mailed: known.addresses.clone(), published: Vec::new() })
1238        } else {
1239            sioul_sync::securitykey::send_to_keys_openpgp(armored.as_bytes(), tr().language())
1240        };
1241        match sent {
1242            Ok(published) => {
1243                let line = if !published.mailed.is_empty() {
1244                    say("seckey-sent-mailed", &[("addresses", published.mailed.join(", "))])
1245                } else if !published.published.is_empty() {
1246                    say("seckey-sent-published", &[("addresses", published.published.join(", "))])
1247                } else {
1248                    tr().text("seckey-sent-no-address", None)
1249                };
1250                tell_band(&qt, SETUP_BAND, Band { state: "done", line, ..Band::default() });
1251            }
1252            Err(error) => problem_line(&qt, SETUP_BAND, error.sentence(tr(), ""), ""),
1253        }
1254    });
1255}
1256
1257/// "Stop using this security key": it leaves Sioul's list, its certificate
1258/// is kept aside; the PIN held, if it was its, is forgotten.
1259pub(crate) fn stop_using(qt: &QtThread, shared: &Shared, ident: &str) {
1260    // A key this device did not read, on a device that cannot read one (a
1261    // phone): nothing there could take it back (review of 19 October 2026,
1262    // N5). Stopped on a computer that read it, it leaves every device.
1263    let read_here = Keys::load().cards.iter().any(|card| card.ident.eq_ignore_ascii_case(ident));
1264    if !may_stop(read_here, sioul_sync::securitykey::usable_here()) {
1265        tell(qt, shared, tr().text("seckey-stop-on-computer", None));
1266        return;
1267    }
1268    if PIN.lock().is_ok_and(|m| m.holder().is_some_and(|h| h.eq_ignore_ascii_case(ident))) {
1269        let _ = forget_pin();
1270    }
1271    let line = match securitykey::forget(ident) {
1272        // Read here: stopped here, its record and certificate gone from here; your other computers' own.
1273        Ok(true) => tr().text("seckey-stopped", None),
1274        // Known here only from another device: stopped here alone.
1275        Ok(false) => tr().text("seckey-stopped-shared", None),
1276        Err(e) => e,
1277    };
1278    tell(qt, shared, line);
1279    let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
1280}
1281
1282/// Whether "Stop using this security key" is offered, and done: for a key
1283/// read here; for one another device read, only on a device that can read
1284/// a security key, where reading it again takes it back. Not on a phone: it
1285/// cannot read one yet, so a stop there could never be undone there, and
1286/// would only stop it encrypting your own copies (N5).
1287fn may_stop(read_here: bool, card_here: bool) -> bool {
1288    read_here || card_here
1289}
1290
1291/// One known security key, as Accounts ▸ Encryption lists it.
1292#[derive(serde::Serialize)]
1293struct KnownView {
1294    ident: String,
1295    /// "YubiKey 12 345 678, Jane Doe".
1296    title: String,
1297    /// "Signs for jane@example.org."; or that it signs for none.
1298    signs_for: String,
1299    /// "Its certificate came from keys.openpgp.org on 7 October 2026."
1300    source: String,
1301    /// Its expiry, and whether it is said in warm colours (expired, or within a month).
1302    expiry: String,
1303    late: bool,
1304    /// "Touch asked to sign and to open."
1305    touch: String,
1306    /// Its PIN is held now.
1307    pin_held: bool,
1308    /// Expired or expiring, read here, and GnuPG here can renew it: "Renew for two years".
1309    renewable: bool,
1310    /// "Stop using this security key" offered (`may_stop`).
1311    stoppable: bool,
1312    /// The commands that do by hand what its buttons do, to copy ("By hand"):
1313    /// its certificate written into a file; expired or expiring, its renewal.
1314    by_hand: Vec<String>,
1315}
1316
1317/// Your security keys, as JSON {cards: […]}, for Accounts ▸ Encryption.
1318pub(crate) fn known_view() -> String {
1319    let keys = Keys::load();
1320    let holder = PIN.lock().ok().and_then(|m| m.holder().map(str::to_string));
1321    let here = sioul_sync::securitykey::usable_here();
1322    let cards: Vec<KnownView> = keys
1323        .all_cards()
1324        .map(|card| {
1325            let name = holder_name(&card.holder);
1326            let title = if name.is_empty() { card.label(tr()) } else { format!("{}, {name}", card.label(tr())) };
1327            let read_here = keys.cards.iter().any(|known| known.ident.eq_ignore_ascii_case(&card.ident));
1328            let source = match card.source.as_str() {
1329                // Read on another computer of yours: on a computer, read it here to sign here (F18); a phone cannot yet.
1330                _ if !read_here && here => tr().text(read_here_words("seckey-source-shared-computer"), None),
1331                _ if !read_here => tr().text("seckey-source-shared", None),
1332                "file" => say("seckey-source-file", &[("date", securitykey::full_date(tr(), card.checked))]),
1333                "gnupg" => say("seckey-source-gnupg", &[("date", securitykey::full_date(tr(), card.checked))]),
1334                url => say("seckey-source", &[("source", host_of(url)), ("date", securitykey::full_date(tr(), card.checked))]),
1335            };
1336            let cert = keys.cert(&card.cert);
1337            let (expiry, late, _) = cert.map(|cert| securitykey::expiry_words(tr(), cert, securitykey::expiry_of(cert, &[&card.sign, &card.decrypt]), now_unix())).unwrap_or_default();
1338            let subkeys = cert.map(securitykey::subkeys_of).unwrap_or_default();
1339            let unknown = (card.touch_sign == Touch::Unknown && !card.sign.is_empty()) || (card.touch_decrypt == Touch::Unknown && !card.decrypt.is_empty());
1340            let touch = match (card.touch_sign.asked() && !card.sign.is_empty(), card.touch_decrypt.asked() && !card.decrypt.is_empty()) {
1341                _ if unknown => "seckey-touch-unknown",
1342                (true, true) => "seckey-touch-both",
1343                (true, false) => "seckey-touch-sign",
1344                (false, true) => "seckey-touch-open",
1345                (false, false) => "seckey-touch-none",
1346            };
1347            KnownView {
1348                ident: card.ident.clone(),
1349                title,
1350                signs_for: if card.addresses.is_empty() { tr().text("seckey-signs-for-none", None) } else { say("seckey-signs-for", &[("addresses", card.addresses.join(", "))]) },
1351                source,
1352                expiry,
1353                late,
1354                touch: tr().text(touch, None),
1355                pin_held: holder.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(&card.ident)),
1356                // GnuPG asked only for a key that needs it, and read here: renewing
1357                // looks for it among the keys read here.
1358                renewable: read_here && late && renewable(),
1359                stoppable: may_stop(read_here, here),
1360                by_hand: std::iter::once(sioul_sync::securitykey::export_command(&card.cert)).chain(if late { sioul_sync::securitykey::renew_commands(&card.cert, &subkeys) } else { Vec::new() }).collect(),
1361            }
1362        })
1363        .collect();
1364    json(&serde_json::json!({ "cards": cards }))
1365}
1366
1367/// `keys.openpgp.org` from `https://keys.openpgp.org/vks/v1/…`.
1368fn host_of(url: &str) -> String {
1369    url.split_once("://").map_or(url, |(_, rest)| rest).split(['/', '?', '#']).next().unwrap_or(url).to_string()
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375
1376    /// Review of 19 October 2026, N5: on a phone, a key another device read
1377    /// cannot be stopped (nothing there could take it back); a key read here,
1378    /// and any key on a computer (reading it again takes it back), can.
1379    #[test]
1380    fn a_shared_key_is_stopped_only_where_it_can_be_read_again() {
1381        assert!(!may_stop(false, false), "a phone, a key another device read");
1382        assert!(may_stop(false, true) && may_stop(true, true) && may_stop(true, false));
1383    }
1384
1385    /// Review of 19 October 2026, F15: a forward of an encrypted message
1386    /// unencrypted, or of one that opens nowhere here, is refused at Send
1387    /// (it was only at the end of the ten seconds); every other forward goes.
1388    #[test]
1389    fn a_forward_that_cannot_go_is_refused_at_send() {
1390        use crate::crypto::Answered;
1391        assert_eq!(forward_refused(false, &Answered::Clear), None);
1392        for answered in [Answered::Opens(Vec::new()), Answered::AtSend, Answered::Elsewhere, Answered::NoKey] {
1393            assert_eq!(forward_refused(false, &answered), Some("pgp-forward-needs-encryption"));
1394        }
1395        assert_eq!(forward_refused(true, &Answered::Elsewhere), Some("seckey-forward-not-here"));
1396        assert_eq!(forward_refused(true, &Answered::NoKey), Some("compose-forward-sealed-no-key"));
1397        assert_eq!(forward_refused(true, &Answered::Opens(Vec::new())), None);
1398        assert_eq!(forward_refused(true, &Answered::AtSend), None);
1399    }
1400
1401    /// On a phone (a key held to it, NFC): the PIN is asked first, from what
1402    /// Sioul knows of the key, never from a read of its own (one tap does
1403    /// everything), and never kept; on a computer, the key is read first and
1404    /// the PIN kept for its quiet time.
1405    #[test]
1406    fn a_phone_asks_the_pin_first_and_never_keeps_it() {
1407        let known = KnownCard { ident: "0006:12345678".into(), sign: "AB".repeat(20), decrypt: "CD".repeat(20), cert: "EF".repeat(20), pin_tries_full: 3, ..KnownCard::default() };
1408        // Any read would fail: the phone's path reads nothing.
1409        let phone = SoftReaders { taps: true, failure: Some(CardError::NoService), ..SoftReaders::with(Vec::new()) };
1410        let (info, pin) = pin_from(&phone, || Some(known.clone()), "0006:12345678", None, Purpose::Sign).unwrap();
1411        assert_eq!((info.ident.as_str(), info.pin_tries, pin.is_none()), ("0006:12345678", 3, true), "the band asks for the PIN");
1412        assert_eq!(info.sign.map(|s| s.fingerprint), Some("AB".repeat(20)));
1413        let typed = Password::from("123456");
1414        let (_, pin) = pin_from(&phone, || Some(known.clone()), "0006:12345678", Some(&typed), Purpose::Open).unwrap();
1415        assert!(pin.is_some(), "the PIN typed goes with the tap");
1416        assert_eq!(pin_from(&phone, || None, "0006:12345678", None, Purpose::Open).map(|(i, _)| i.ident), Err(CardError::Absent), "a key not known here");
1417        assert!(!keeps_pin(&phone));
1418        // A computer reads the key first (here, no service), and keeps the PIN.
1419        let computer = SoftReaders { failure: Some(CardError::NoService), ..SoftReaders::with(Vec::new()) };
1420        assert_eq!(pin_from(&computer, || Some(known.clone()), "0006:12345678", None, Purpose::Sign).map(|(i, _)| i.ident), Err(CardError::NoService));
1421        assert!(keeps_pin(&SoftReaders::with(Vec::new())));
1422    }
1423
1424    /// What a phone says: hold the key or plug it in, keep holding it, a key
1425    /// moved away or unplugged, a touch NFC cannot give (plugged in, the
1426    /// computer's), a key locked, NFC missing or off while a key can still be
1427    /// plugged in (its settings offered), Android's refusal, another app
1428    /// holding the key; a computer's words unchanged. In both languages.
1429    #[test]
1430    fn a_phone_says_hold_or_plug_and_what_went_wrong() {
1431        let computer = Way::default();
1432        let held = Way { taps: true, ..Way::default() };
1433        let plugged = Way { taps: true, cable: true, ..Way::default() };
1434        for lang in ["en", "fr"] {
1435            let words = Translator::new(lang);
1436            assert_eq!(waiting_in(&held, Purpose::Sign, &words), words.text("seckey-hold", None));
1437            assert_eq!(waiting_in(&Way { note: Some(CardError::NoNfc), ..held.clone() }, Purpose::Sign, &words), words.text("seckey-plug-phone", None));
1438            let off = Way { note: Some(CardError::NfcOff), ..held.clone() };
1439            assert_eq!((waiting_in(&off, Purpose::Open, &words), waiting_action(&off)), (words.text("seckey-plug-nfc-off", None), "nfc-settings"));
1440            assert_eq!((waiting_in(&computer, Purpose::Sign, &words), waiting_action(&computer)), (CardError::Absent.sentence(&words, Purpose::Sign), ""));
1441            assert_eq!(waiting_action(&held), "");
1442            assert_eq!(sentence_in(&held, &CardError::Removed, Purpose::Sign, &words), words.text("seckey-moved-away-sign", None));
1443            assert_eq!(sentence_in(&held, &CardError::Removed, Purpose::Open, &words), words.text("seckey-moved-away", None));
1444            assert_eq!(sentence_in(&plugged, &CardError::Removed, Purpose::Sign, &words), words.text("seckey-unplugged-sign", None));
1445            assert_eq!(sentence_in(&plugged, &CardError::Removed, Purpose::Read, &words), words.text("seckey-unplugged", None));
1446            assert_eq!(sentence_in(&held, &CardError::TouchMissed, Purpose::Sign, &words), words.text("seckey-nfc-touch", None));
1447            assert_eq!(sentence_in(&plugged, &CardError::TouchMissed, Purpose::Sign, &words), CardError::TouchMissed.sentence(&words, Purpose::Sign));
1448            for way in [&held, &plugged] {
1449                assert_eq!(sentence_in(way, &CardError::WrongPin { left: 0 }, Purpose::Open, &words), words.text("seckey-blocked-phone", None));
1450            }
1451            assert_eq!(sentence_in(&held, &CardError::NotYours, Purpose::Open, &words), CardError::NotYours.sentence(&words, Purpose::Open));
1452            assert_eq!(sentence_in(&plugged, &CardError::UsbRefused, Purpose::Open, &words), words.text("seckey-usb-refused", None));
1453            assert_eq!(sentence_in(&plugged, &CardError::UsbBusy, Purpose::Open, &words), words.text("seckey-usb-busy", None));
1454            for error in [CardError::Removed, CardError::TouchMissed, CardError::Blocked] {
1455                assert_eq!(sentence_in(&computer, &error, Purpose::Sign, &words), error.sentence(&words, Purpose::Sign));
1456            }
1457            for id in ["seckey-hold", "seckey-holding", "seckey-done-lift", "seckey-done-unplug", "seckey-moved-away", "seckey-moved-away-sign", "seckey-unplugged", "seckey-unplugged-sign", "seckey-nfc-touch", "seckey-blocked-phone", "seckey-nfc-settings", "seckey-plug-phone", "seckey-plug-nfc-off", "seckey-usb-refused", "seckey-usb-busy", "seckey-pin-otp"] {
1458                let said = words.text(id, None);
1459                assert!(!said.is_empty() && !said.contains("seckey-"), "{lang} {id}");
1460            }
1461            let off = setup_problem_in(&held, &CardError::NfcOff, &words);
1462            assert_eq!((off.state, off.action, off.line), ("problem", "nfc-settings", words.text("seckey-nfc-off", None)));
1463            for error in [CardError::NoNfc, CardError::NotYours, CardError::UsbRefused, CardError::UsbBusy] {
1464                assert_eq!(setup_problem_in(&plugged, &error, &words).action, "retry", "{error:?}");
1465            }
1466        }
1467        assert_eq!(working_words(&held, "seckey-signing"), "seckey-holding");
1468        assert_eq!(working_words(&held, "seckey-opening"), "seckey-holding");
1469        assert_eq!(working_words(&held, "seckey-reading"), "seckey-reading");
1470        assert_eq!(working_words(&plugged, "seckey-signing"), "seckey-signing");
1471        assert_eq!(working_words(&computer, "seckey-signing"), "seckey-signing");
1472        assert_eq!(Way::of(&SoftReaders::with(Vec::new())), computer);
1473    }
1474
1475    /// Yubico OTP left on: a touch while the PIN's field has the focus types
1476    /// a one-time password and Enter. Such a code is never given to the key
1477    /// as its PIN (it would cost a try); PINs, of digits or letters, are.
1478    #[test]
1479    fn a_code_the_key_typed_is_not_taken_for_its_pin() {
1480        // A YubiKey's OTP: 12 letters of its public id, 32 of the code, in modhex.
1481        assert!(typed_by_the_key("ccccccjlkgjhvbtnedkdfiricjgtilhkbhuelkljdlgf"));
1482        assert!(typed_by_the_key(&"c".repeat(32)) && typed_by_the_key(&"v".repeat(64)));
1483        // In capitals too (Caps Lock, Shift): review of 23 October 2026, F7.
1484        assert!(typed_by_the_key("CCCCCCJLKGJHVBTNEDKDFIRICJGTILHKBHUELKLJDLGF"));
1485        assert!(typed_by_the_key("Ccccccjlkgjhvbtnedkdfiricjgtilhkbhuelkljdlgf"));
1486        for pin in ["123456", "12345678", "correct horse", "cbdefghijk", "CBDEFGHIJK", &"c".repeat(31), &"c".repeat(65), "ccccccjlkgjhvbtnedkdfiricjgtilhkbhuelkljdlga", "CCCCCCJLKGJHVBTNEDKDFIRICJGTILHKBHUELKLJDLGA"] {
1487            assert!(!typed_by_the_key(pin), "{pin}");
1488        }
1489    }
1490
1491    /// Review of 23 October 2026, F1: a band told that its key has one try
1492    /// left sends the PIN typed next there, once; a PIN typed elsewhere, or
1493    /// before the band said so, is not sent; a PIN held (taken before) is.
1494    /// F2: a PIN typed waits a minute for its key at most. F3: the plain copy
1495    /// of the PIN typed is wiped once taken.
1496    #[test]
1497    fn the_last_try_is_sent_only_once_said_and_a_waiting_pin_is_dropped() {
1498        let readers = SoftReaders::with(Vec::new());
1499        let pin = Password::from("123456");
1500        let first = CardAccess::new(&readers, &pin, &|| {});
1501        confirm_last_try(&first, "draft-f1", true);
1502        assert!(!first.last_try_allowed(), "typed, not told: not sent with one try left");
1503        LAST_TRY.lock().unwrap().insert("draft-f1".to_string());
1504        let other = CardAccess::new(&readers, &pin, &|| {});
1505        confirm_last_try(&other, "draft-other", true);
1506        assert!(!other.last_try_allowed(), "another band's word does not count");
1507        let again = CardAccess::new(&readers, &pin, &|| {});
1508        confirm_last_try(&again, "draft-f1", true);
1509        assert!(again.last_try_allowed(), "told, typed again: sent");
1510        let after = CardAccess::new(&readers, &pin, &|| {});
1511        confirm_last_try(&after, "draft-f1", true);
1512        assert!(!after.last_try_allowed(), "once only");
1513        let held = CardAccess::new(&readers, &pin, &|| {});
1514        confirm_last_try(&held, "draft-f1", false);
1515        assert!(held.last_try_allowed(), "a PIN held, which the key took before");
1516        assert!(!pin_waited_too_long(true, Duration::from_secs(59)));
1517        assert!(pin_waited_too_long(true, Duration::from_secs(61)));
1518        assert!(!pin_waited_too_long(false, Duration::from_secs(3600)), "no PIN typed: the band waits on");
1519        assert!(take_pin(String::new()).is_none());
1520        let taken = take_pin("123456".to_string()).unwrap();
1521        taken.map(|protected| assert_eq!(&protected[..], b"123456"));
1522        for lang in ["en", "fr"] {
1523            let words = Translator::new(lang);
1524            let phone = Way { taps: true, ..Way::default() };
1525            assert_eq!(sentence_in(&phone, &CardError::LastTry, Purpose::Sign, &words), words.text("seckey-last-try-phone", None));
1526            assert_eq!(sentence_in(&Way::default(), &CardError::LastTry, Purpose::Sign, &words), words.text("seckey-last-try", None));
1527            for id in ["seckey-last-try", "seckey-last-try-phone", "seckey-pin-dropped"] {
1528                assert!(!words.text(id, None).contains("seckey-"), "{lang} {id}");
1529            }
1530        }
1531    }
1532
1533    /// "Read it here" says, on a phone, to hold the key against it; each of
1534    /// the phone's words exists in both languages.
1535    #[test]
1536    fn a_phone_says_to_hold_the_key_where_a_computer_says_to_plug_it() {
1537        for computer in ["seckey-sign-read-here", "seckey-source-shared-computer", "pgp-read-card-here"] {
1538            assert_eq!(read_here_in(false, computer), computer);
1539            let phone = read_here_in(true, computer);
1540            assert_eq!(phone, format!("{computer}-phone").replace("-computer-phone", "-phone"));
1541            for lang in ["en", "fr"] {
1542                let said = Translator::new(lang).text(phone, None);
1543                assert!(!said.is_empty() && !said.contains(phone), "{lang} {phone}");
1544            }
1545        }
1546        assert_eq!(read_here_in(true, "seckey-other"), "seckey-other");
1547    }
1548
1549    /// A key your other device made known, its certificate shared, is kept at
1550    /// once when the phone reads it (how a phone comes to sign, F18); never a
1551    /// certificate that is not the key's.
1552    #[test]
1553    fn a_key_known_from_another_device_is_kept_when_held_to_the_phone() {
1554        let (card, bytes) = SoftCard::generate("0006:12345678", &["Jane <jane@example.org>"], "123456").unwrap();
1555        let (cert, check) = securitykey::certificate_in(&bytes, &card.info).unwrap();
1556        let record = KnownCard {
1557            ident: card.info.ident.clone(),
1558            sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
1559            decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
1560            cert: check.cert.clone(),
1561            source: "laptop".into(),
1562            ..KnownCard::default()
1563        };
1564        let keys = Keys { own: vec![cert.clone()], shared: vec![record], ..Keys::default() };
1565        let (kept, checked, source) = shared_in(&keys, &card.info).unwrap();
1566        assert_eq!((kept.fingerprint(), checked.cert, source.as_str()), (cert.fingerprint(), check.cert.clone(), "laptop"));
1567        assert!(checked.addresses.contains(&"jane@example.org".to_string()));
1568        // Another key under the same number: its keys are not the certificate's.
1569        let (other, _) = SoftCard::generate("0006:12345678", &["Jane <jane@example.org>"], "123456").unwrap();
1570        assert!(shared_in(&keys, &other.info).is_none());
1571        // Not known from another device: looked up as usual.
1572        assert!(shared_in(&Keys { own: vec![cert], ..Keys::default() }, &card.info).is_none());
1573    }
1574
1575    #[test]
1576    fn the_holder_reads_in_the_usual_order() {
1577        assert_eq!(holder_name("Doe<<Jane<Marie"), "Jane Marie Doe");
1578        assert_eq!(holder_name("Jane Doe"), "Jane Doe");
1579        assert_eq!(holder_name(""), "");
1580        assert_eq!(host_of("https://keys.openpgp.org/vks/v1/by-fingerprint/ABC"), "keys.openpgp.org");
1581        assert_eq!(host_of("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/x?l=jane"), "openpgpkey.example.org");
1582    }
1583
1584    /// What the setup says while GnuPG renews the key, step by step: the PIN
1585    /// in GnuPG's window, then the touch held a second or two, for as long as
1586    /// GnuPG works; "If your key asks for a touch…" when its setting did not
1587    /// read; nothing of a touch for a key that asks none.
1588    /// A key GnuPG holds for itself, met while adding or renewing it: the band
1589    /// says so and offers "Let GnuPG release it", with the line that lets
1590    /// both share it for good; never a dead end. With software keys only.
1591    #[test]
1592    fn a_key_gnupg_holds_offers_to_release_it() {
1593        let tr = sioul_core::i18n::Translator::new("en");
1594        let held = SoftReaders { cards: std::sync::Mutex::new(Vec::new()), busy: 1, failure: None, taps: false };
1595        assert_eq!(key_in(&held, "0006:12345678").map(|c| c.ident), Err(CardError::Busy));
1596        assert_eq!(key_in(&held, "").map(|c| c.ident), Err(CardError::Busy), "the last step of adding it, the key not read yet");
1597        let band = setup_problem(&CardError::Busy, &tr);
1598        assert_eq!((band.state, band.action, band.warm), ("problem", "release", true));
1599        assert!(band.line.starts_with("Another program holds your security key") && band.line.ends_with("`pcsc-shared`"), "{}", band.line);
1600        // Not plugged in: tried again, nothing to release.
1601        assert_eq!(key_in(&SoftReaders::with(Vec::new()), "").map(|c| c.ident), Err(CardError::Absent));
1602        assert_eq!(setup_problem(&CardError::Absent, &tr).action, "retry");
1603    }
1604
1605    #[test]
1606    fn the_renewal_says_the_pin_then_the_touch() {
1607        let tr = sioul_core::i18n::Translator::new("en");
1608        let pin_then_touch = renew_line(GnupgStep::Pin, Touch::On, &tr);
1609        assert!(pin_then_touch.contains("PIN") && pin_then_touch.contains("hold your finger"), "{pin_then_touch}");
1610        let touch = renew_line(GnupgStep::Card, Touch::On, &tr);
1611        assert!(touch.starts_with("Touch your security key now") && touch.contains("15 seconds"), "{touch}");
1612        assert_eq!(renew_line(GnupgStep::Card, Touch::Fixed, &tr), touch);
1613        assert!(renew_line(GnupgStep::Card, Touch::Unknown, &tr).starts_with("If your key asks for a touch"));
1614        assert!(!renew_line(GnupgStep::Pin, Touch::Off, &tr).contains("touch"));
1615        assert_eq!(renew_line(GnupgStep::Card, Touch::Off, &tr), tr.text("seckey-renewing", None));
1616        // In French too.
1617        let fr = sioul_core::i18n::Translator::new("fr");
1618        assert!(renew_line(GnupgStep::Card, Touch::On, &fr).starts_with("Touchez votre clé de sécurité"));
1619    }
1620}