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;
22use sioul_core::pgp::{self, Keys, Place};
23use sioul_core::securitykey::{self, CardAccess, CardError, CardInfo, KnownCard, NoReaders, Password, PinMemory, Purpose, Readers, Remedy, SoftCard, SoftReaders, Touch};
24use sioul_sync::securitykey::{Gnupg, GnupgProblem, GnupgStep, Looked, Released};
25use sioul_sync::send::BuildError;
26use std::collections::BTreeSet;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::{Duration, Instant};
30
31/// The PIN, held in memory for fifteen quiet minutes after its last use.
32static PIN: Mutex<PinMemory> = Mutex::new(PinMemory::new());
33/// Whether the watch over the held PIN runs.
34static WATCHING: AtomicBool = AtomicBool::new(false);
35/// The bands waiting for the key to be plugged in, by their context, until "Not now".
36static WAITING: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
37/// What the setup read from the key, for "Look for it" and "Import a file…".
38static SETUP: Mutex<Option<CardInfo>> = Mutex::new(None);
39/// The window, told when the PIN is held or forgotten: Accounts offers "Forget the PIN now" then.
40static WINDOW: Mutex<Option<QtThread>> = Mutex::new(None);
41
42/// How often Sioul looks whether a key it waits for came, or whether the key whose PIN it holds went.
43const LOOK_EVERY: Duration = Duration::from_millis(1500);
44/// How long a band waits for its key before it stops looking.
45const WAIT_AT_MOST: Duration = Duration::from_secs(5 * 60);
46/// The band of the setup, in Accounts ▸ Encryption.
47const SETUP_BAND: &str = "setup";
48
49/// The demo's security key: software behind the same traits, its PIN 123456,
50/// a touch asked to sign; its certificate names the profile's first address.
51struct Demo {
52    readers: SoftReaders,
53    certificate: Vec<u8>,
54}
55
56fn demo() -> Option<&'static Demo> {
57    static DEMO: OnceLock<Option<Demo>> = OnceLock::new();
58    DEMO.get_or_init(|| {
59        let config = load_config();
60        let accounts: Vec<_> = config.accounts.iter().filter(|a| a.address.is_some()).collect();
61        // One user ID per address, named when an account names it.
62        let mut userids: Vec<String> = Vec::new();
63        for account in &accounts {
64            let address = account.address.clone().unwrap_or_default();
65            if userids.iter().any(|u| u.ends_with(&format!("<{address}>"))) {
66                continue;
67            }
68            let name = accounts.iter().filter(|a| a.address.as_deref() == Some(address.as_str())).find_map(|a| a.name.clone()).unwrap_or_default();
69            userids.push(format!("{name} <{address}>").trim().to_string());
70        }
71        let userids: Vec<&str> = userids.iter().map(String::as_str).collect();
72        let (mut card, certificate) = SoftCard::generate("0006:01234567", &userids, "123456").ok()?;
73        card.info.holder = accounts.first().and_then(|a| a.name.clone()).unwrap_or_default();
74        if let Some(slot) = card.info.sign.as_mut() {
75            slot.touch = Touch::On;
76        }
77        card.touch_delay = Duration::from_secs(4);
78        Some(Demo { readers: SoftReaders::with(vec![card]), certificate })
79    })
80    .as_ref()
81}
82
83/// Where security keys are: the system's smart card service; in a demo, the demo's own key, never a real one.
84fn readers() -> &'static dyn Readers {
85    static NONE: NoReaders = NoReaders;
86    if offline() {
87        return demo().map_or(&NONE as &dyn Readers, |d| &d.readers as &dyn Readers);
88    }
89    sioul_sync::securitykey::readers()
90}
91
92/// What the band of a writing window, of the Reader or of the setup shows, as JSON.
93#[derive(serde::Serialize, Default)]
94struct Band {
95    /// "working", "plug", "pin", "touch", "problem", "found", "missing", "done".
96    state: &'static str,
97    /// What it says.
98    line: String,
99    /// Under the PIN's field: "2 tries left.", when fewer than the most seen.
100    tries: String,
101    /// Said in warm colours: a wrong PIN, a problem; never red.
102    warm: bool,
103    /// The problem's button: "retry" (ask the key again), "release" (Let
104    /// GnuPG release it), "lookup" (look for the certificate again), or none.
105    action: &'static str,
106    /// The key's fingerprint, for `gpg --export` in the setup's hint.
107    fingerprint: String,
108    /// The key it is about ("0006:12345678"): "Send it to keys.openpgp.org" after a renewal.
109    ident: String,
110    /// The commands that do by hand what the buttons do, to copy ("By hand").
111    commands: Vec<String>,
112}
113
114fn tell_band(qt: &QtThread, context: &str, band: Band) {
115    let (context, state) = (context.to_string(), json(&band));
116    let _ = qt.queue(move |mut sioul| sioul.as_mut().security_key_changed(QString::from(&context), QString::from(&state)));
117}
118
119fn working(qt: &QtThread, context: &str, id: &str) {
120    tell_band(qt, context, Band { state: "working", line: tr().text(id, None), ..Band::default() });
121}
122
123fn problem_line(qt: &QtThread, context: &str, line: String, action: &'static str) {
124    tell_band(qt, context, Band { state: "problem", line, warm: true, action, ..Band::default() });
125}
126
127/// The security key that signs a draft at Send, as JSON {ident, label}; or
128/// {expired: …} when its certificate expired; "" when the draft is signed
129/// another way, or not at all.
130pub(crate) fn for_draft(id: &str) -> String {
131    let Some(draft) = Draft::by_id(id).filter(|d| d.sign) else { return String::new() };
132    let Some(address) = load_config().account(&draft.account).and_then(|a| a.address.clone()) else { return String::new() };
133    let keys = Keys::load();
134    match keys.own_for(&address) {
135        Some(pgp::Own { place: Place::Card { ident }, .. }) => {
136            let label = keys.cards.iter().find(|c| c.ident == ident).map(|c| c.label(tr())).unwrap_or_default();
137            json(&serde_json::json!({ "ident": ident, "label": label }))
138        }
139        Some(_) => String::new(),
140        None => expired_line(&keys, &address).map(|line| json(&serde_json::json!({ "expired": line }))).unwrap_or_default(),
141    }
142}
143
144/// What the writing window says when the security key that signs for
145/// `address` has an expired certificate: the date, and how to renew it.
146pub(crate) fn expired_line(keys: &Keys, address: &str) -> Option<String> {
147    let (known, cert) = keys.card_for(address)?;
148    let expires = securitykey::expiry_of(cert, &[&known.sign, &known.decrypt]);
149    securitykey::expiry_line(tr(), expires, &cert.fingerprint().to_hex(), now_unix()).filter(|(_, warm)| *warm).map(|(line, _)| line)
150}
151
152fn now_unix() -> i64 {
153    jiff::Timestamp::now().as_second()
154}
155
156/// The PIN to use: the one typed, else the one held (not for a key that asks
157/// it for each signature, decision 5); with what the key says, read without
158/// its PIN. None as the PIN: the band asks for it.
159fn pin_for(ident: &str, typed: Option<&Password>, purpose: Purpose) -> Result<(CardInfo, Option<Password>), CardError> {
160    let info = securitykey::cards(readers())?.into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident)).ok_or(CardError::Absent)?;
161    if info.pin_tries == 0 {
162        return Err(CardError::Blocked);
163    }
164    if let Some(typed) = typed {
165        return Ok((info, Some(typed.clone())));
166    }
167    if purpose == Purpose::Sign && info.pin_each_signature {
168        return Ok((info, None));
169    }
170    let held = PIN.lock().ok().and_then(|mut memory| memory.get(ident, Instant::now()));
171    Ok((info, held))
172}
173
174/// The band asks for the PIN, with the tries left when fewer than the most seen.
175fn ask_pin(qt: &QtThread, context: &str, info: &CardInfo, purpose: Purpose) {
176    let known = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&info.ident)).unwrap_or_default();
177    let tries = known.tries_line(info.pin_tries, tr()).unwrap_or_default();
178    let line = if purpose == Purpose::Sign && info.pin_each_signature { "seckey-pin-each" } else { "seckey-pin" };
179    tell_band(qt, context, Band { state: "pin", line: tr().text(line, None), warm: !tries.is_empty(), tries, ..Band::default() });
180}
181
182/// Tells the window the keys' state changed: the PIN held, or forgotten.
183fn keys_changed() {
184    if let Some(qt) = WINDOW.lock().ok().and_then(|w| w.clone()) {
185        let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
186    }
187}
188
189/// The PIN worked: held for its quiet time, watched; what the key said, kept.
190fn remember(ident: &str, pin: Password, reader: Option<String>, info: &CardInfo) {
191    if let Ok(mut memory) = PIN.lock() {
192        memory.keep(ident, pin, &reader.unwrap_or_else(|| info.reader.clone()), Instant::now());
193    }
194    keys_changed();
195    let mut known = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident));
196    if let Some(known) = known.as_mut() {
197        let before = known.clone();
198        known.update(info);
199        if *known != before {
200            let updated = known.clone();
201            let _ = securitykey::change_known(|cards| {
202                if let Some(card) = cards.iter_mut().find(|c| c.ident == updated.ident) {
203                    *card = updated;
204                }
205            });
206        }
207    }
208    watch_pin();
209}
210
211/// While a PIN is held: forgotten when its quiet time ends, or when its key
212/// is pulled out (its reader gone).
213fn watch_pin() {
214    if WATCHING.swap(true, Ordering::Relaxed) {
215        return;
216    }
217    std::thread::spawn(|| {
218        loop {
219            std::thread::sleep(LOOK_EVERY);
220            // While Sioul's own GnuPG works with a key, no look: the next turn.
221            if !sioul_sync::securitykey::may_look() {
222                continue;
223            }
224            let present = readers().present();
225            let Ok(mut memory) = PIN.lock() else { break };
226            memory.expire(Instant::now());
227            if let Ok(present) = present {
228                memory.went_away(&present);
229            }
230            if memory.holder().is_none() {
231                break;
232            }
233        }
234        WATCHING.store(false, Ordering::Relaxed);
235        keys_changed();
236    });
237}
238
239/// "Forget the PIN now".
240pub(crate) fn forget_pin() -> String {
241    if let Ok(mut memory) = PIN.lock() {
242        memory.forget();
243    }
244    tr().text("seckey-pin-forgotten", None)
245}
246
247/// Sioul closes: the PIN and the session keys go with it.
248pub(crate) fn closing() {
249    if let Ok(mut memory) = PIN.lock() {
250        memory.forget();
251    }
252    pgp::SessionKeys::global().forget();
253}
254
255/// A PIN the key took before something else failed (a touch missed, the key
256/// pulled out): held, so that "Try again" does not ask it again.
257fn keep_taken(ident: &str, pin: &Password, access: &CardAccess, info: &CardInfo) {
258    if access.pin_accepted() {
259        remember(ident, pin.clone(), access.reader(), info);
260    }
261}
262
263/// What a failure does to the band, and to a PIN held that the key refused.
264/// A key not plugged in is waited for instead ([`wait_for`]).
265fn failed(qt: &QtThread, context: &str, error: CardError, purpose: Purpose, typed: bool) {
266    if matches!(error, CardError::WrongPin { .. } | CardError::Blocked) && !typed {
267        // The PIN held is refused: never given again.
268        if let Ok(mut memory) = PIN.lock() {
269            memory.forget();
270        }
271    }
272    let line = error.sentence(tr(), purpose);
273    match error.remedy() {
274        Remedy::Pin => tell_band(qt, context, Band { state: "pin", line, warm: true, ..Band::default() }),
275        Remedy::Again => problem_line(qt, context, line, "retry"),
276        Remedy::Release => problem_line(qt, context, line, "release"),
277        Remedy::None => problem_line(qt, context, line, ""),
278    }
279}
280
281/// Remembers the window, to tell it when the PIN is held or forgotten.
282fn know_window(qt: &QtThread) {
283    if let Ok(mut window) = WINDOW.lock() {
284        *window = Some(qt.clone());
285    }
286}
287
288/// Signs a draft with your security key at Send, then schedules it with its
289/// ten seconds of "Undo"; `typed` is the PIN typed in the band, else "".
290pub(crate) fn sign_and_send(qt: &QtThread, shared: &Arc<Shared>, id: &str, typed: String) {
291    know_window(qt);
292    let typed = (!typed.is_empty()).then(|| Password::from(typed));
293    let (qt, shared, id) = (qt.clone(), Arc::clone(shared), id.to_string());
294    std::thread::spawn(move || sign_now(&qt, &shared, &id, typed));
295}
296
297fn sign_now(qt: &QtThread, shared: &Arc<Shared>, id: &str, typed: Option<Password>) {
298    working(qt, id, "seckey-reading");
299    let Some(draft) = Draft::by_id(id) else { return problem_line(qt, id, tr().text("mail-draft-gone", None), "") };
300    let config = load_config();
301    let Some(account) = config.account(&draft.account).filter(|a| a.syncs()).cloned() else {
302        return problem_line(qt, id, say("account-unknown", &[("id", draft.account.clone())]), "");
303    };
304    let keys = Keys::load();
305    let ident = match keys.own_for(account.address.as_deref().unwrap_or_default()) {
306        Some(pgp::Own { place: Place::Card { ident }, .. }) => ident,
307        // Not (or no longer) signed with a security key: sent as any message is.
308        _ => {
309            match crate::mail::send(qt, shared, id) {
310                Some(problem) => problem_line(qt, id, problem, ""),
311                None => tell_band(qt, id, Band { state: "done", ..Band::default() }),
312            }
313            return;
314        }
315    };
316    let (info, pin) = match pin_for(&ident, typed.as_ref(), Purpose::Sign) {
317        Ok(found) => found,
318        Err(CardError::Absent) => return wait_for(qt, shared, id, &ident, Purpose::Sign, typed),
319        Err(error) => return failed(qt, id, error, Purpose::Sign, typed.is_some()),
320    };
321    let Some(pin) = pin else { return ask_pin(qt, id, &info, Purpose::Sign) };
322    working(qt, id, "seckey-signing");
323    // Said at the moment the key is asked, when it may wait for a touch: hold it a second or two.
324    let (touch_qt, context, line) = (qt.clone(), id.to_string(), touch_line(info.sign.as_ref()));
325    let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
326    let access = CardAccess::new(readers(), &pin, &touch);
327    match sioul_sync::send::build_draft(&account, &draft, tr(), Some(&access)) {
328        Ok(outgoing) => {
329            remember(&ident, pin.clone(), access.reader(), &info);
330            crate::mail::send_signed(qt, shared, id, account, outgoing);
331            tell_band(qt, id, Band { state: "done", ..Band::default() });
332        }
333        Err(BuildError::Card(CardError::Absent)) => wait_for(qt, shared, id, &ident, Purpose::Sign, typed),
334        Err(BuildError::Card(error)) => {
335            keep_taken(&ident, &pin, &access, &info);
336            failed(qt, id, error, Purpose::Sign, typed.is_some());
337        }
338        // A server's words, never read as a command to copy (the band shows backticked commands as such).
339        Err(other) => problem_line(qt, id, other.into_sync(tr()).sentence(tr(), &account.id).replace('`', "'"), ""),
340    }
341}
342
343/// What the band says when the key's slot may wait for a touch, its setting
344/// read from the key this session (`Touch::words`): "If your key asks for a
345/// touch…" when it could not be read.
346fn touch_line(slot: Option<&securitykey::SlotInfo>) -> String {
347    slot.map_or(Touch::Unknown, |s| s.touch).words(tr()).unwrap_or_else(|| tr().text("seckey-touch-maybe", None))
348}
349
350/// Opens a message encrypted to your security key ("Open with your security
351/// key"); its session key then stays in memory until Sioul closes, so that it
352/// opens again, with its attachments and its answer, without the key.
353pub(crate) fn open_message(qt: &QtThread, shared: &Arc<Shared>, key: &str, typed: String) {
354    know_window(qt);
355    let typed = (!typed.is_empty()).then(|| Password::from(typed));
356    let (qt, shared, key) = (qt.clone(), Arc::clone(shared), key.to_string());
357    std::thread::spawn(move || open_now(&qt, &shared, &key, typed));
358}
359
360fn open_now(qt: &QtThread, shared: &Arc<Shared>, key: &str, typed: Option<Password>) {
361    working(qt, key, "seckey-reading");
362    let Some(raw) = crate::mail::locate(key).and_then(|(_, file)| std::fs::read(file).ok()) else {
363        return problem_line(qt, key, tr().text("mail-message-gone", None), "");
364    };
365    // Which key it waits for; opened already from its session key, it is done.
366    let ident = match crate::crypto::open(&raw) {
367        Some((Some(_), _)) | None => return tell_band(qt, key, Band { state: "done", ..Band::default() }),
368        Some((None, view)) => match view.security_key {
369            Some(ident) => ident,
370            None => return problem_line(qt, key, tr().text("pgp-not-opened", None), ""),
371        },
372    };
373    let (info, pin) = match pin_for(&ident, typed.as_ref(), Purpose::Open) {
374        Ok(found) => found,
375        Err(CardError::Absent) => return wait_for(qt, shared, key, &ident, Purpose::Open, typed),
376        Err(error) => return failed(qt, key, error, Purpose::Open, typed.is_some()),
377    };
378    let Some(pin) = pin else { return ask_pin(qt, key, &info, Purpose::Open) };
379    working(qt, key, "seckey-opening");
380    let (touch_qt, context, line) = (qt.clone(), key.to_string(), touch_line(info.decrypt.as_ref()));
381    let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
382    let access = CardAccess::new(readers(), &pin, &touch);
383    let passphrase = |fingerprint: &pgp::Fingerprint| sioul_sync::secret::pgp_passphrase(&fingerprint.to_hex());
384    let unlock = pgp::Unlock::new(&passphrase, pgp::SessionKeys::global()).with_card(&access);
385    match pgp::open(&raw, &Keys::load(), &unlock) {
386        Some((Some(_), _)) => {
387            remember(&ident, pin.clone(), access.reader(), &info);
388            tell_band(qt, key, Band { state: "done", ..Band::default() });
389        }
390        _ => match access.failure() {
391            Some(CardError::Absent) => wait_for(qt, shared, key, &ident, Purpose::Open, typed),
392            Some(error) => {
393                keep_taken(&ident, &pin, &access, &info);
394                failed(qt, key, error, Purpose::Open, typed.is_some());
395            }
396            None => problem_line(qt, key, tr().text("pgp-not-opened", None), ""),
397        },
398    }
399}
400
401/// The key is not plugged in: the band says so, and Sioul looks, without
402/// opening any key, until it comes, "Not now", or a few minutes pass.
403fn wait_for(qt: &QtThread, shared: &Arc<Shared>, context: &str, ident: &str, purpose: Purpose, typed: Option<Password>) {
404    if let Ok(mut waiting) = WAITING.lock() {
405        waiting.insert(context.to_string());
406    }
407    tell_band(qt, context, Band { state: "plug", line: CardError::Absent.sentence(tr(), purpose), ..Band::default() });
408    let (qt, shared, context, ident) = (qt.clone(), Arc::clone(shared), context.to_string(), ident.to_string());
409    std::thread::spawn(move || {
410        let since = Instant::now();
411        let mut seen: Option<Vec<String>> = None;
412        loop {
413            std::thread::sleep(LOOK_EVERY);
414            if !WAITING.lock().is_ok_and(|w| w.contains(&context)) {
415                return;
416            }
417            if since.elapsed() > WAIT_AT_MOST {
418                if let Ok(mut waiting) = WAITING.lock() {
419                    waiting.remove(&context);
420                }
421                return problem_line(&qt, &context, CardError::Absent.sentence(tr(), purpose), "retry");
422            }
423            // While Sioul's own GnuPG works with a key, no look: the next turn.
424            if !sioul_sync::securitykey::may_look() {
425                continue;
426            }
427            // A key is opened only when the readers change: another key plugged in is left alone.
428            let present = readers().present().ok();
429            if present == seen {
430                continue;
431            }
432            seen = present;
433            match key_in(readers(), &ident) {
434                Ok(_) => {
435                    if let Ok(mut waiting) = WAITING.lock() {
436                        waiting.remove(&context);
437                    }
438                    return match purpose {
439                        Purpose::Sign => sign_now(&qt, &shared, &context, typed),
440                        _ => open_now(&qt, &shared, &context, typed),
441                    };
442                }
443                // Plugged in, but GnuPG holds it: said, with "Let GnuPG release it", rather than waited for in vain.
444                Err(CardError::Busy) => {
445                    if let Ok(mut waiting) = WAITING.lock() {
446                        waiting.remove(&context);
447                    }
448                    return problem_line(&qt, &context, CardError::Busy.sentence(tr(), purpose), "release");
449                }
450                Err(_) => {}
451            }
452        }
453    });
454}
455
456/// "Not now": the band stops waiting for the key.
457pub(crate) fn not_now(context: &str) {
458    if let Ok(mut waiting) = WAITING.lock() {
459        waiting.remove(context);
460    }
461}
462
463/// "Let GnuPG release it", pressed: GnuPG's smart card daemon stopped.
464/// Returns JSON {line, done}: done, the band tries again at once.
465pub(crate) fn release() -> String {
466    let released = if offline() { Released::Done } else { sioul_sync::securitykey::release_gnupg() };
467    json(&serde_json::json!({ "line": released.sentence(tr()), "done": released == Released::Done }))
468}
469
470/// The cardholder's name as written on the key ("Ferrand<<Noa",
471/// ISO/IEC 7501-1), in the usual order ("Noa Ferrand").
472fn holder_name(written: &str) -> String {
473    match written.split_once("<<") {
474        Some((surname, given)) => format!("{} {}", given.replace('<', " ").trim(), surname.replace('<', " ").trim()).trim().to_string(),
475        None => written.replace('<', " ").trim().to_string(),
476    }
477}
478
479/// "YubiKey 12 345 678, Noa Ferrand. It signs (Ed25519, made on 3 March 2026) and decrypts (Curve25519)."
480fn describe(info: &CardInfo) -> String {
481    let holder = holder_name(&info.holder);
482    let holder = if holder.is_empty() { String::new() } else { format!(", {holder}") };
483    let made = |slot: &securitykey::SlotInfo| match slot.created {
484        0 => slot.algorithm.clone(),
485        at => say("seckey-made-on", &[("algorithm", slot.algorithm.clone()), ("date", securitykey::full_date(tr(), i64::from(at)))]),
486    };
487    let (sign, decrypt) = (info.sign.as_ref().map(made), info.decrypt.as_ref().map(made));
488    let id = match (&sign, &decrypt) {
489        (Some(_), Some(_)) => "seckey-found-both",
490        (Some(_), None) => "seckey-found-sign",
491        _ => "seckey-found-decrypt",
492    };
493    say(id, &[("label", info.label(tr())), ("holder", holder), ("sign", sign.unwrap_or_default()), ("decrypt", decrypt.unwrap_or_default())])
494}
495
496/// "Use a security key": reads the key plugged in, without its PIN.
497pub(crate) fn read_for_setup(qt: &QtThread) {
498    let qt = qt.clone();
499    std::thread::spawn(move || {
500        working(&qt, SETUP_BAND, "seckey-reading");
501        match securitykey::cards(readers()) {
502            Ok(found) => {
503                let known = securitykey::known_cards();
504                // A key not set up yet comes first, when several are plugged in.
505                let Some(info) = found.iter().find(|c| !known.iter().any(|k| k.ident == c.ident)).or(found.first()).cloned() else { return };
506                let line = describe(&info);
507                let fingerprint = fingerprint_of(&info);
508                let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
509                if let Ok(mut setup) = SETUP.lock() {
510                    *setup = Some(info);
511                }
512                tell_band(&qt, SETUP_BAND, Band { state: "found", line, fingerprint, commands, ..Band::default() });
513            }
514            Err(error) => {
515                let action = if error.remedy() == Remedy::Release { "release" } else { "retry" };
516                problem_line(&qt, SETUP_BAND, error.sentence(tr(), Purpose::Read), action);
517            }
518        }
519    });
520}
521
522/// The fingerprint `gpg --export` takes for the key: its signing key's, else its decryption key's.
523fn fingerprint_of(info: &CardInfo) -> String {
524    info.sign.as_ref().or(info.decrypt.as_ref()).map(|s| s.fingerprint.clone()).unwrap_or_default()
525}
526
527/// The key to look up a certificate for: the one the setup read, else a key
528/// known already ("Look for a newer version"), from what Sioul kept of it.
529fn card_for_lookup(ident: &str) -> Result<CardInfo, CardError> {
530    let read = SETUP.lock().ok().and_then(|s| s.clone()).filter(|c| ident.is_empty() || c.ident.eq_ignore_ascii_case(ident));
531    let known = || securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident)).map(|known| as_info(&known));
532    match read.or_else(known) {
533        Some(info) => Ok(info),
534        // Neither read nor known: the key itself, which GnuPG may hold (offered to release).
535        None => key_in(readers(), ident),
536    }
537}
538
539/// The key `ident` as it says itself ("": the first one), read without its PIN.
540fn key_in(readers: &dyn Readers, ident: &str) -> Result<CardInfo, CardError> {
541    securitykey::cards(readers)?.into_iter().find(|c| ident.is_empty() || c.ident.eq_ignore_ascii_case(ident)).ok_or(CardError::Absent)
542}
543
544/// What the setup's band says when the key could not be read: a key another
545/// program holds offers "Let GnuPG release it", never a dead end; one not
546/// plugged in, "Try again".
547fn setup_problem(error: &CardError, words: &sioul_core::i18n::Translator) -> Band {
548    let action = match error.remedy() {
549        Remedy::Release => "release",
550        _ => "retry",
551    };
552    Band { state: "problem", line: error.sentence(words, Purpose::Read), warm: true, action, ..Band::default() }
553}
554
555/// What Sioul kept of a key, as the key would say it (fingerprints, no public parts).
556fn as_info(known: &KnownCard) -> CardInfo {
557    let slot = |fingerprint: &str, touch: Touch| (!fingerprint.is_empty()).then(|| securitykey::SlotInfo { fingerprint: fingerprint.to_string(), created: 0, algorithm: String::new(), touch, public: None });
558    CardInfo {
559        ident: known.ident.clone(),
560        manufacturer: known.manufacturer,
561        serial: known.serial,
562        holder: known.holder.clone(),
563        url: String::new(),
564        sign: slot(&known.sign, known.touch_sign),
565        decrypt: slot(&known.decrypt, known.touch_decrypt),
566        pin_tries: known.pin_tries_full,
567        reset_tries: 0,
568        admin_tries: 0,
569        pin_each_signature: known.pin_each_signature,
570        signatures: 0,
571        reader: String::new(),
572    }
573}
574
575/// Your mail addresses.
576fn your_addresses() -> Vec<String> {
577    load_config().accounts.iter().filter(|a| a.syncs()).filter_map(|a| a.address.clone()).map(|a| a.to_ascii_lowercase()).collect()
578}
579
580/// "Look for it", "Look for a newer version": the key's certificate looked
581/// up, only now that you asked, checked against the key, and kept.
582pub(crate) fn look_for_certificate(qt: &QtThread, ident: &str) {
583    let (qt, ident) = (qt.clone(), ident.to_string());
584    std::thread::spawn(move || {
585        let info = match card_for_lookup(&ident) {
586            Ok(info) => info,
587            Err(error) => return tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
588        };
589        working(&qt, SETUP_BAND, "seckey-looking");
590        let looked = if offline() {
591            // A demo profile stays off the network: its own key's certificate, as if found.
592            match demo().map(|d| securitykey::certificate_in(&d.certificate, &info)) {
593                Some(Ok((cert, check))) => Ok(Looked::Found { cert, check, source: "https://keys.openpgp.org/".into() }),
594                _ => Ok(Looked::NotFound),
595            }
596        } else {
597            sioul_sync::securitykey::find_certificate(&info, &your_addresses())
598        };
599        match looked {
600            Ok(Looked::Found { cert, check, source }) => adopt(&qt, &info, &cert, &check, &source),
601            Ok(Looked::Mismatch(why)) => problem_line(&qt, SETUP_BAND, why.sentence(tr()), ""),
602            Ok(Looked::NotFound) => {
603                let fingerprint = fingerprint_of(&info);
604                let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
605                tell_band(&qt, SETUP_BAND, Band { state: "missing", line: tr().text("seckey-not-found", None), fingerprint, commands, ..Band::default() })
606            }
607            Err(error) => problem_line(&qt, SETUP_BAND, error.sentence(tr(), ""), "lookup"),
608        }
609    });
610}
611
612/// "Import a file…": the key's certificate from `gpg --export`, checked, kept.
613pub(crate) fn import_certificate(qt: &QtThread, ident: &str, url: &str) {
614    let info = match card_for_lookup(ident) {
615        Ok(info) => info,
616        Err(error) => return tell_band(qt, SETUP_BAND, setup_problem(&error, tr())),
617    };
618    let path = local_path(url);
619    match std::fs::read(&path) {
620        Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
621            Ok((cert, check)) => adopt(qt, &info, &cert, &check, "file"),
622            Err(why) => problem_line(qt, SETUP_BAND, why.sentence(tr()), ""),
623        },
624        Err(e) => problem_line(qt, SETUP_BAND, format!("{}: {e}", path.display()), ""),
625    }
626}
627
628/// The certificate found is the key's: kept, the key made yours for the
629/// addresses it names among yours, and said.
630fn adopt(qt: &QtThread, info: &CardInfo, cert: &securitykey::Certificate, check: &securitykey::CertCheck, source: &str) {
631    match keep(info, cert, check, source) {
632        Ok(kept) => {
633            // A part expired: renewed from here, where GnuPG can, or by hand, each subkey named.
634            let (action, commands) = if kept.expired && renewable() { ("renew-offer", kept.commands) } else if kept.expired { ("", kept.commands) } else { ("", Vec::new()) };
635            tell_band(qt, SETUP_BAND, Band { state: "done", line: kept.line, warm: kept.warm, action, ident: info.ident.clone(), commands, ..Band::default() });
636            let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
637        }
638        Err(e) => problem_line(qt, SETUP_BAND, e, ""),
639    }
640}
641
642/// What keeping a certificate says, and what it offers.
643struct Kept {
644    line: String,
645    warm: bool,
646    /// A part of it expired: said exactly, with a way to renew it.
647    expired: bool,
648    /// The commands that renew it by hand, each subkey named.
649    commands: Vec<String>,
650}
651
652
653/// The certificate kept for the key, the key made yours for the addresses it
654/// names among yours: what to say, and whether warmly.
655fn keep(info: &CardInfo, cert: &securitykey::Certificate, check: &securitykey::CertCheck, source: &str) -> Result<Kept, String> {
656    let yours = your_addresses();
657    let named: Vec<String> = check.addresses.iter().filter(|a| yours.contains(a)).cloned().collect();
658    // A certificate naming no address at all (keys.openpgp.org gives none
659    // until you confirm one) still signs your mail; one naming others' does not.
660    let addresses = if check.addresses.is_empty() { yours } else { named };
661    securitykey::adopt(info, cert, source, addresses.clone(), now_unix())?;
662    let names = cert.userids().map(|u| String::from_utf8_lossy(u.userid().value()).to_string()).collect::<Vec<_>>().join(", ");
663    let mut line = if check.addresses.is_empty() {
664        tr().text("seckey-kept-no-address", None)
665    } else if addresses.is_empty() {
666        say("seckey-kept-not-yours", &[("names", names)])
667    } else {
668        say("seckey-kept", &[("names", names), ("addresses", addresses.join(", "))])
669    };
670    let mut warm = addresses.is_empty();
671    let (expiry, late, expired) = securitykey::expiry_words(tr(), cert, check.expires, now_unix());
672    if !expiry.is_empty() {
673        line = format!("{line} {expiry}");
674        warm |= late;
675    }
676    if let Ok(mut setup) = SETUP.lock() {
677        *setup = None;
678    }
679    Ok(Kept { line, warm, expired, commands: sioul_sync::securitykey::renew_commands(&check.cert, &securitykey::subkeys_of(cert)) })
680}
681
682/// What GnuPG did not do, in words, and what to do then.
683fn gnupg_sentence(problem: &GnupgProblem, fingerprint: &str) -> String {
684    match problem {
685        GnupgProblem::Missing => tr().text("seckey-gnupg-missing", None),
686        GnupgProblem::Sandboxed => tr().text("seckey-gnupg-sandboxed", None),
687        GnupgProblem::NoSuchKey => say("seckey-gnupg-no-key", &[("fingerprint", fingerprint.to_string())]),
688        GnupgProblem::TouchMissed => tr().text("seckey-gnupg-touch-missed", None),
689        // Its own words, never read as a command to copy.
690        GnupgProblem::Failed(detail) => say("seckey-gnupg-failed", &[("detail", detail.replace('`', "'"))]),
691    }
692}
693
694/// GnuPG here, or why not; in a demo, none: the demo's key stands in.
695fn gnupg() -> Result<Gnupg, GnupgProblem> {
696    if offline() { Err(GnupgProblem::Missing) } else { Gnupg::find() }
697}
698
699/// "Import from GnuPG": the key's certificate as your GnuPG keeps it
700/// (`gpg --export --armor`), checked against the key, kept. A demo profile
701/// takes its own key's certificate, as if GnuPG had it.
702pub(crate) fn import_from_gnupg(qt: &QtThread, ident: &str) {
703    let (qt, ident) = (qt.clone(), ident.to_string());
704    std::thread::spawn(move || {
705        let info = match card_for_lookup(&ident) {
706            Ok(info) => info,
707            Err(error) => return tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
708        };
709        working(&qt, SETUP_BAND, "seckey-gnupg-reading");
710        let fingerprint = fingerprint_of(&info);
711        let exported = match demo().filter(|_| offline()) {
712            Some(demo) => Ok(demo.certificate.clone()),
713            None => gnupg().and_then(|g| g.export(&fingerprint)),
714        };
715        match exported {
716            Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
717                Ok((cert, check)) => adopt(&qt, &info, &cert, &check, "gnupg"),
718                Err(why) => problem_line(&qt, SETUP_BAND, why.sentence(tr()), ""),
719            },
720            Err(problem) => {
721                let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
722                tell_band(&qt, SETUP_BAND, Band { state: "problem", line: gnupg_sentence(&problem, &fingerprint), warm: true, fingerprint, commands, ..Band::default() });
723            }
724        }
725    });
726}
727
728/// Whether GnuPG is here to renew a key: never on a phone, in a Flatpak, or
729/// a demo. Whether it holds the key is asked only on "Renew for two years":
730/// GnuPG is never run behind your back, nor the security key woken for it.
731fn renewable() -> bool {
732    gnupg().is_ok()
733}
734
735/// "Renew for two years": GnuPG renews the key, then its subkeys
736/// (`gpg --quick-set-expire`, its own pinentry asking the security key's PIN,
737/// and a touch); the certificate it then holds is checked and kept here.
738pub(crate) fn renew(qt: &QtThread, ident: &str) {
739    let (qt, ident) = (qt.clone(), ident.to_string());
740    std::thread::spawn(move || {
741        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), "") };
742        let info = as_info(&known);
743        let subkeys = Keys::load().cert(&known.cert).map(securitykey::subkeys_of).unwrap_or_default();
744        let commands: Vec<String> = sioul_sync::securitykey::renew_commands(&known.cert, &subkeys);
745        // The key GnuPG renews with is the one that certifies: on a security key, its signing key.
746        let touch = known.touch_sign;
747        tell_band(&qt, SETUP_BAND, Band { state: "working", line: tr().text("seckey-renewing", None), ident: ident.clone(), ..Band::default() });
748        let watch = |step: GnupgStep| tell_band(&qt, SETUP_BAND, Band { state: "working", line: renew_line(step, touch, tr()), ident: ident.clone(), ..Band::default() });
749        let renewed = gnupg().and_then(|g| {
750            g.renew(&known.cert, "2y", &watch)?;
751            g.export(&known.cert)
752        });
753        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() });
754        match renewed {
755            Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
756                // Read again, part by part: a part still expired is said, and nothing is kept as renewed.
757                Ok((cert, _)) if !securitykey::expired_parts(tr(), &securitykey::parts(&cert), now_unix()).is_empty() => {
758                    let still = securitykey::expired_parts(tr(), &securitykey::parts(&cert), now_unix()).join(" ");
759                    failed(say("seckey-renew-incomplete", &[("parts", still)]), "renew");
760                }
761                Ok((cert, check)) => match keep(&info, &cert, &check, "gnupg") {
762                    Ok(_) => {
763                        let parts = securitykey::parts_until(tr(), &securitykey::parts(&cert));
764                        tell_band(&qt, SETUP_BAND, Band { state: "done", line: say("seckey-renewed", &[("parts", parts)]), action: "publish", ident: ident.clone(), ..Band::default() });
765                        let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
766                    }
767                    Err(e) => failed(e, ""),
768                },
769                Err(why) => failed(why.sentence(tr()), ""),
770            },
771            // GnuPG does not know the card yet: `gpg --card-status` teaches it.
772            Err(GnupgProblem::NoSuchKey) => failed(tr().text("seckey-renew-unknown", None), ""),
773            // The key waited for a touch in vain: said plainly, and tried again on a press.
774            Err(GnupgProblem::TouchMissed) => failed(gnupg_sentence(&GnupgProblem::TouchMissed, &known.cert), "renew"),
775            Err(problem) => failed(gnupg_sentence(&problem, &known.cert), ""),
776        }
777    });
778}
779
780/// What the setup says while GnuPG renews the key: its pinentry open, the PIN
781/// typed there (then a touch, when the key may ask for one); the key's turn,
782/// to touch it now and hold the finger a second or two, as long as GnuPG works
783/// (`Touch::words`); else that GnuPG renews it.
784fn renew_line(step: GnupgStep, touch: Touch, tr: &sioul_core::i18n::Translator) -> String {
785    match (step, touch.words(tr)) {
786        (GnupgStep::Pin, Some(_)) => format!("{} {}", tr.text("seckey-gnupg-pin", None), tr.text("seckey-gnupg-then-touch", None)),
787        (GnupgStep::Pin, None) => tr.text("seckey-gnupg-pin", None),
788        (GnupgStep::Card, Some(words)) => words,
789        (GnupgStep::Card, None) => tr.text("seckey-renewing", None),
790    }
791}
792
793/// "Send it to keys.openpgp.org", pressed after the sentence that says what
794/// goes public: the certificate Sioul keeps for the key, its public part only,
795/// sent; keys.openpgp.org then mails each address it names a link. A demo
796/// profile sends nothing, and says what would come.
797pub(crate) fn send_to_keys_openpgp(qt: &QtThread, ident: &str) {
798    let (qt, ident) = (qt.clone(), ident.to_string());
799    std::thread::spawn(move || {
800        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), "") };
801        let Some(armored) = pgp::export_public(&known.cert) else { return problem_line(&qt, SETUP_BAND, tr().text("seckey-not-found", None), "") };
802        working(&qt, SETUP_BAND, "seckey-sending");
803        let sent = if offline() {
804            Ok(sioul_sync::securitykey::Published { fingerprint: known.cert.clone(), mailed: known.addresses.clone(), published: Vec::new() })
805        } else {
806            sioul_sync::securitykey::send_to_keys_openpgp(armored.as_bytes(), tr().language())
807        };
808        match sent {
809            Ok(published) => {
810                let line = if !published.mailed.is_empty() {
811                    say("seckey-sent-mailed", &[("addresses", published.mailed.join(", "))])
812                } else if !published.published.is_empty() {
813                    say("seckey-sent-published", &[("addresses", published.published.join(", "))])
814                } else {
815                    tr().text("seckey-sent-no-address", None)
816                };
817                tell_band(&qt, SETUP_BAND, Band { state: "done", line, ..Band::default() });
818            }
819            Err(error) => problem_line(&qt, SETUP_BAND, error.sentence(tr(), ""), ""),
820        }
821    });
822}
823
824/// "Stop using this security key": it leaves Sioul's list, its certificate
825/// is kept aside; the PIN held, if it was its, is forgotten.
826pub(crate) fn stop_using(qt: &QtThread, shared: &Shared, ident: &str) {
827    if PIN.lock().is_ok_and(|m| m.holder().is_some_and(|h| h.eq_ignore_ascii_case(ident))) {
828        let _ = forget_pin();
829    }
830    let line = match securitykey::forget(ident) {
831        Ok(()) => tr().text("seckey-stopped", None),
832        Err(e) => e,
833    };
834    tell(qt, shared, line);
835    let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
836}
837
838/// One known security key, as Accounts ▸ Encryption lists it.
839#[derive(serde::Serialize)]
840struct KnownView {
841    ident: String,
842    /// "YubiKey 12 345 678, Jane Doe".
843    title: String,
844    /// "Signs for jane@example.org."; or that it signs for none.
845    signs_for: String,
846    /// "Its certificate came from keys.openpgp.org on 7 October 2026."
847    source: String,
848    /// Its expiry, and whether it is said in warm colours (expired, or within a month).
849    expiry: String,
850    late: bool,
851    /// "Touch asked to sign and to open."
852    touch: String,
853    /// Its PIN is held now.
854    pin_held: bool,
855    /// Expired or expiring, and GnuPG here can renew it: "Renew for two years".
856    renewable: bool,
857    /// The commands that do by hand what its buttons do, to copy ("By hand"):
858    /// its certificate written into a file; expired or expiring, its renewal.
859    by_hand: Vec<String>,
860}
861
862/// Your security keys, as JSON {cards: […]}, for Accounts ▸ Encryption.
863pub(crate) fn known_view() -> String {
864    let keys = Keys::load();
865    let holder = PIN.lock().ok().and_then(|m| m.holder().map(str::to_string));
866    let cards: Vec<KnownView> = keys
867        .cards
868        .iter()
869        .map(|card| {
870            let name = holder_name(&card.holder);
871            let title = if name.is_empty() { card.label(tr()) } else { format!("{}, {name}", card.label(tr())) };
872            let source = match card.source.as_str() {
873                "file" => say("seckey-source-file", &[("date", securitykey::full_date(tr(), card.checked))]),
874                "gnupg" => say("seckey-source-gnupg", &[("date", securitykey::full_date(tr(), card.checked))]),
875                url => say("seckey-source", &[("source", host_of(url)), ("date", securitykey::full_date(tr(), card.checked))]),
876            };
877            let cert = keys.cert(&card.cert);
878            let (expiry, late, _) = cert.map(|cert| securitykey::expiry_words(tr(), cert, securitykey::expiry_of(cert, &[&card.sign, &card.decrypt]), now_unix())).unwrap_or_default();
879            let subkeys = cert.map(securitykey::subkeys_of).unwrap_or_default();
880            let unknown = (card.touch_sign == Touch::Unknown && !card.sign.is_empty()) || (card.touch_decrypt == Touch::Unknown && !card.decrypt.is_empty());
881            let touch = match (card.touch_sign.asked() && !card.sign.is_empty(), card.touch_decrypt.asked() && !card.decrypt.is_empty()) {
882                _ if unknown => "seckey-touch-unknown",
883                (true, true) => "seckey-touch-both",
884                (true, false) => "seckey-touch-sign",
885                (false, true) => "seckey-touch-open",
886                (false, false) => "seckey-touch-none",
887            };
888            KnownView {
889                ident: card.ident.clone(),
890                title,
891                signs_for: if card.addresses.is_empty() { tr().text("seckey-signs-for-none", None) } else { say("seckey-signs-for", &[("addresses", card.addresses.join(", "))]) },
892                source,
893                expiry,
894                late,
895                touch: tr().text(touch, None),
896                pin_held: holder.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(&card.ident)),
897                // GnuPG asked only for a key that needs it.
898                renewable: late && renewable(),
899                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(),
900            }
901        })
902        .collect();
903    json(&serde_json::json!({ "cards": cards }))
904}
905
906/// `keys.openpgp.org` from `https://keys.openpgp.org/vks/v1/…`.
907fn host_of(url: &str) -> String {
908    url.split_once("://").map_or(url, |(_, rest)| rest).split(['/', '?', '#']).next().unwrap_or(url).to_string()
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn the_holder_reads_in_the_usual_order() {
917        assert_eq!(holder_name("Doe<<Jane<Marie"), "Jane Marie Doe");
918        assert_eq!(holder_name("Jane Doe"), "Jane Doe");
919        assert_eq!(holder_name(""), "");
920        assert_eq!(host_of("https://keys.openpgp.org/vks/v1/by-fingerprint/ABC"), "keys.openpgp.org");
921        assert_eq!(host_of("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/x?l=jane"), "openpgpkey.example.org");
922    }
923
924    /// What the setup says while GnuPG renews the key, step by step: the PIN
925    /// in GnuPG's window, then the touch held a second or two, for as long as
926    /// GnuPG works; "If your key asks for a touch…" when its setting did not
927    /// read; nothing of a touch for a key that asks none.
928    /// A key GnuPG holds for itself, met while adding or renewing it: the band
929    /// says so and offers "Let GnuPG release it", with the line that lets
930    /// both share it for good; never a dead end. With software keys only.
931    #[test]
932    fn a_key_gnupg_holds_offers_to_release_it() {
933        let tr = sioul_core::i18n::Translator::new("en");
934        let held = SoftReaders { cards: std::sync::Mutex::new(Vec::new()), busy: 1, failure: None };
935        assert_eq!(key_in(&held, "0006:12345678").map(|c| c.ident), Err(CardError::Busy));
936        assert_eq!(key_in(&held, "").map(|c| c.ident), Err(CardError::Busy), "the last step of adding it, the key not read yet");
937        let band = setup_problem(&CardError::Busy, &tr);
938        assert_eq!((band.state, band.action, band.warm), ("problem", "release", true));
939        assert!(band.line.starts_with("Another program holds your security key") && band.line.ends_with("`pcsc-shared`"), "{}", band.line);
940        // Not plugged in: tried again, nothing to release.
941        assert_eq!(key_in(&SoftReaders::with(Vec::new()), "").map(|c| c.ident), Err(CardError::Absent));
942        assert_eq!(setup_problem(&CardError::Absent, &tr).action, "retry");
943    }
944
945    #[test]
946    fn the_renewal_says_the_pin_then_the_touch() {
947        let tr = sioul_core::i18n::Translator::new("en");
948        let pin_then_touch = renew_line(GnupgStep::Pin, Touch::On, &tr);
949        assert!(pin_then_touch.contains("PIN") && pin_then_touch.contains("hold your finger"), "{pin_then_touch}");
950        let touch = renew_line(GnupgStep::Card, Touch::On, &tr);
951        assert!(touch.starts_with("Touch your security key now") && touch.contains("15 seconds"), "{touch}");
952        assert_eq!(renew_line(GnupgStep::Card, Touch::Fixed, &tr), touch);
953        assert!(renew_line(GnupgStep::Card, Touch::Unknown, &tr).starts_with("If your key asks for a touch"));
954        assert!(!renew_line(GnupgStep::Pin, Touch::Off, &tr).contains("touch"));
955        assert_eq!(renew_line(GnupgStep::Card, Touch::Off, &tr), tr.text("seckey-renewing", None));
956        // In French too.
957        let fr = sioul_core::i18n::Translator::new("fr");
958        assert!(renew_line(GnupgStep::Card, Touch::On, &fr).starts_with("Touchez votre clé de sécurité"));
959    }
960}