Skip to main content

sioul_sync/
securitykey.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Your security key where it meets the world: its certificate looked up when
5//! you ask, and GnuPG asked to let the key go when you press "Let GnuPG
6//! release it". What is checked and kept is the core's
7//! (`sioul_core::securitykey`).
8//!
9//! - **The certificate.** A security key holds no certificate, only its
10//!   keys' fingerprints and public parts. Sioul looks for it only when you ask
11//!   (at setup, and "Look for a newer version"), since each lookup tells a
12//!   server that someone wants your key: the address written on the key
13//!   (HTTPS only), then the Web Key Directory of your addresses' domains,
14//!   then keys.openpgp.org by fingerprint. Each certificate found is checked
15//!   against the key, and one that is not the key's is never kept.
16//! - **GnuPG's hold.** GnuPG's smart card daemon keeps the key for itself
17//!   after any use (scdaemon connects exclusively unless `pcsc-shared` is set
18//!   in `scdaemon.conf`). "Let GnuPG release it" runs `gpgconf --kill
19//!   scdaemon`, on that click only: gpg starts it again when it needs the key,
20//!   and asks its PIN again then. Sioul never changes GnuPG's settings.
21//! - **GnuPG's own steps, on a press** ([`Gnupg`]): "Import from GnuPG" runs
22//!   `gpg --export --armor` for the key's fingerprint; "Renew for two years"
23//!   runs `gpg --quick-set-expire` for the key, then its subkeys, GnuPG's own
24//!   pinentry asking the card's PIN. The person's GnuPG, its home as GnuPG
25//!   finds it; never a setting changed, never a key made or deleted.
26//! - **keys.openpgp.org** ([`send_to_keys_openpgp`]): the public certificate
27//!   sent on "Send it to keys.openpgp.org", over HTTPS, then a confirmation
28//!   asked for each address it names that is not published yet: the server
29//!   mails each a link, and publishes the address once the link is opened.
30//! - **The key itself**: on a computer, the system's smart card service,
31//!   PC/SC ([`smartcard`]); on a phone, a key held against it over NFC
32//!   ([`phone`]); the card's own code shared by both ([`ocard`]).
33//! - **Logs**: [`cap_logging`], as each program starts.
34
35use crate::SyncError;
36use sioul_core::i18n::Translator;
37use sioul_core::pgp;
38#[cfg(target_os = "android")]
39use sioul_core::securitykey::NoReaders;
40use sioul_core::securitykey::{self, CardInfo, CertCheck, Certificate, Mismatch, Readers};
41use std::path::{Path, PathBuf};
42use std::process::Stdio;
43use std::time::Duration;
44
45pub mod ocard;
46pub mod phone;
47#[cfg(not(target_os = "android"))]
48pub mod smartcard;
49
50/// Where security keys are found: the system's smart card service on a
51/// computer; on a phone, a key held against it over NFC, through the link
52/// the app gives as it starts ([`phone::set_link`]), else nowhere.
53pub fn readers() -> &'static dyn Readers {
54    #[cfg(not(target_os = "android"))]
55    {
56        static PCSC: smartcard::PcscReaders = smartcard::PcscReaders;
57        &PCSC
58    }
59    #[cfg(target_os = "android")]
60    {
61        static NONE: NoReaders = NoReaders;
62        static PHONE: std::sync::OnceLock<phone::PhoneReaders> = std::sync::OnceLock::new();
63        match phone::link() {
64            Some(link) => PHONE.get_or_init(|| phone::PhoneReaders::new(link)),
65            None => &NONE,
66        }
67    }
68}
69
70/// Whether this device can use a security key: a computer, through its smart
71/// card service; a phone with NFC (switched off, it says so) or a USB-C port
72/// that takes a key (USB host). A phone with neither knows your key's
73/// certificate all the same, from your computer: it encrypts to you and says
74/// what waits for the key.
75pub fn usable_here() -> bool {
76    #[cfg(not(target_os = "android"))]
77    {
78        true
79    }
80    #[cfg(target_os = "android")]
81    {
82        phone::link().is_some_and(|link| link.state() != phone::LinkState::NoNfc || link.usb_host())
83    }
84}
85
86/// Caps what any crate may log at "info", whatever level it asks, as a
87/// program starts (`sioul-app`, `sioul`). `openpgp-card` writes whole APDUs
88/// at "trace", a VERIFY's PIN included, and a card's answers, session keys
89/// among them. Sioul installs no logger, so nothing is written anywhere; this
90/// keeps "debug" and "trace" away even if one came, and never raises the
91/// level (it starts at "off").
92pub fn cap_logging() {
93    log::set_max_level(log::max_level().min(log::LevelFilter::Info));
94}
95
96/// What looking for a security key's certificate found.
97pub enum Looked {
98    /// The key's certificate (public parts), what it is, and where it came from.
99    Found {
100        /// The certificate.
101        cert: Certificate,
102        /// What it is, checked against the key.
103        check: CertCheck,
104        /// The address it came from.
105        source: String,
106    },
107    /// Certificates were found, none of them the key's: why.
108    Mismatch(Mismatch),
109    /// Nothing was found anywhere.
110    NotFound,
111}
112
113/// The places to look, in order: the address written on the key (HTTPS only),
114/// the Web Key Directory of each of `addresses`, then keys.openpgp.org by the
115/// fingerprint of the key's signing key (else of its decryption key).
116pub fn places(card: &CardInfo, addresses: &[String]) -> Vec<String> {
117    let mut urls = Vec::new();
118    let url = card.url.trim();
119    if url.get(..8).is_some_and(|s| s.eq_ignore_ascii_case("https://")) {
120        urls.push(url.to_string());
121    }
122    for address in addresses {
123        urls.extend(pgp::wkd_urls(address).into_iter().flatten());
124    }
125    if let Some(slot) = card.sign.as_ref().or(card.decrypt.as_ref()) {
126        // "MUST be uppercase, and MUST NOT be prefixed with 0x" (keys.openpgp.org/about/api).
127        urls.push(format!("https://keys.openpgp.org/vks/v1/by-fingerprint/{}", slot.fingerprint.to_ascii_uppercase()));
128    }
129    let mut seen = std::collections::BTreeSet::new();
130    urls.retain(|u| seen.insert(u.to_ascii_lowercase()));
131    urls
132}
133
134/// Looks for the certificate of the security key `card`, only when you ask:
135/// over HTTPS, in the order of [`places`], keeping the first that is the key's.
136pub fn find_certificate(card: &CardInfo, addresses: &[String]) -> Result<Looked, SyncError> {
137    // HTTPS to the end: an older certificate served in clear could hide a revocation.
138    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(15))).https_only(true).user_agent("Sioul").build().into();
139    find_at(&agent, &places(card, addresses), card)
140}
141
142fn find_at(agent: &ureq::Agent, urls: &[String], card: &CardInfo) -> Result<Looked, SyncError> {
143    let mut mismatch = None;
144    let mut last_error = None;
145    for url in urls {
146        match agent.get(url).call() {
147            Ok(mut response) => {
148                let bytes = response.body_mut().read_to_vec().unwrap_or_default();
149                match securitykey::certificate_in(&bytes, card) {
150                    Ok((cert, check)) => return Ok(Looked::Found { cert, check, source: url.clone() }),
151                    // Nothing of the key's there: the next place.
152                    Err(Mismatch::Missing) => {}
153                    Err(why) => mismatch = Some(why),
154                }
155            }
156            Err(ureq::Error::StatusCode(_)) => {}
157            Err(e) => last_error = Some(e.to_string()),
158        }
159    }
160    match (mismatch, last_error) {
161        (Some(why), _) => Ok(Looked::Mismatch(why)),
162        (None, Some(e)) => Err(SyncError::Network(e)),
163        (None, None) => Ok(Looked::NotFound),
164    }
165}
166
167/// What "Let GnuPG release it" did.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Released {
170    /// GnuPG's smart card daemon stopped: the key is free until gpg needs it again.
171    Done,
172    /// No `gpgconf` here: GnuPG is not installed, or not where Sioul looks.
173    NoGnupg,
174    /// In a Flatpak, GnuPG runs outside Sioul's sandbox, out of its reach.
175    Sandboxed,
176    /// `gpgconf` refused: what it said.
177    Failed(String),
178}
179
180impl Released {
181    /// What happened, in words, and what to do when it did not work.
182    pub fn sentence(&self, tr: &Translator) -> String {
183        match self {
184            Released::Done => tr.text("seckey-released", None),
185            Released::NoGnupg => tr.text("seckey-release-no-gnupg", None),
186            Released::Sandboxed => tr.text("seckey-release-sandboxed", None),
187            Released::Failed(detail) => {
188                let mut args = sioul_core::i18n::args();
189                // GnuPG's words, never read as a command to copy.
190                args.set("detail", detail.replace('`', "'"));
191                tr.text("seckey-release-failed", Some(&args))
192            }
193        }
194    }
195}
196
197/// Asks GnuPG to let the security key go: `gpgconf --kill scdaemon`. Run only
198/// when you press "Let GnuPG release it", never by itself.
199pub fn release_gnupg() -> Released {
200    if Path::new("/.flatpak-info").exists() {
201        return Released::Sandboxed;
202    }
203    for program in gpgconf_places() {
204        match release_with(&program) {
205            Released::NoGnupg => continue,
206            done => return done,
207        }
208    }
209    Released::NoGnupg
210}
211
212/// Where `gpgconf` may be: on the PATH, then where GnuPG's installers put it,
213/// since a window started from the desktop may not have them on its PATH.
214fn gpgconf_places() -> Vec<PathBuf> {
215    let mut places = vec![PathBuf::from(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" })];
216    if cfg!(target_os = "macos") {
217        places.extend(["/opt/homebrew/bin/gpgconf", "/usr/local/bin/gpgconf", "/usr/local/MacGPG2/bin/gpgconf"].map(PathBuf::from));
218    }
219    if cfg!(windows) {
220        places.extend([r"C:\Program Files (x86)\GnuPG\bin\gpgconf.exe", r"C:\Program Files\GnuPG\bin\gpgconf.exe"].map(PathBuf::from));
221    }
222    places
223}
224
225fn release_with(program: &Path) -> Released {
226    release_at(program, None)
227}
228
229/// `gpgconf --kill scdaemon`, for the GnuPG home `home` when given (`--homedir`).
230fn release_at(program: &Path, home: Option<&Path>) -> Released {
231    let mut command = std::process::Command::new(program);
232    if let Some(home) = home {
233        command.arg("--homedir").arg(home);
234    }
235    command.args(["--kill", "scdaemon"]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
236    // No console window flashing up from Sioul's.
237    #[cfg(windows)]
238    {
239        use std::os::windows::process::CommandExt;
240        command.creation_flags(0x0800_0000);
241    }
242    match command.output() {
243        Ok(out) if out.status.success() => Released::Done,
244        Ok(out) => Released::Failed(String::from_utf8_lossy(&out.stderr).lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("").to_string()),
245        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Released::NoGnupg,
246        Err(e) => Released::Failed(e.to_string()),
247    }
248}
249
250/// GnuPG, as Sioul runs it on your press: the `gpg` program, and the home it
251/// reads (none: GnuPG's own, `GNUPGHOME` or `~/.gnupg`; a throwaway one in tests).
252#[derive(Debug, Clone)]
253pub struct Gnupg {
254    program: PathBuf,
255    home: Option<PathBuf>,
256}
257
258/// Why GnuPG did not do what was asked.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum GnupgProblem {
261    /// No `gpg` here (on a phone, never).
262    Missing,
263    /// In a Flatpak, GnuPG runs outside Sioul's sandbox, out of its reach.
264    Sandboxed,
265    /// GnuPG here holds no such key: none to export, or no secret part to renew it with.
266    NoSuchKey,
267    /// The security key waited for a touch and none came in time (GnuPG's
268    /// "Timeout", whatever its language says).
269    TouchMissed,
270    /// It refused, or did not answer in time: its last word.
271    Failed(String),
272}
273
274/// How long GnuPG may take when it asks for a PIN and a touch: then it is
275/// said as not answering, and its process is ended.
276const GNUPG_WAITS: Duration = Duration::from_secs(5 * 60);
277
278/// libgpg-error's GPG_ERR_TIMEOUT: a security key that waited for a touch in vain.
279const GPG_ERR_TIMEOUT: u32 = 62;
280
281/// Where a GnuPG step on the security key is, for what the window says.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum GnupgStep {
284    /// GnuPG's pinentry is open: the PIN is typed there.
285    Pin,
286    /// No pinentry open (none needed, or closed): the key may wait for a touch.
287    Card,
288}
289
290/// A line of GnuPG's status (`--status-fd`), as far as Sioul needs it.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum Status {
293    /// `[GNUPG:] PINENTRY_LAUNCHED <pid> …`: its pinentry opened.
294    Pinentry(u32),
295    /// `[GNUPG:] FAILURE <where> <code>`, `[GNUPG:] ERROR <where> <code>`:
296    /// the error's code, its source left out (libgpg-error's low 16 bits).
297    Failed(u32),
298}
299
300/// A status line read, if it is one Sioul needs.
301pub fn status_of(line: &str) -> Option<Status> {
302    let mut words = line.trim().strip_prefix("[GNUPG:] ")?.split_whitespace();
303    match words.next()? {
304        "PINENTRY_LAUNCHED" => words.next()?.parse().ok().map(Status::Pinentry),
305        "FAILURE" | "ERROR" => words.last()?.parse::<u32>().ok().map(|code| Status::Failed(code & 0xFFFF)),
306        _ => None,
307    }
308}
309
310/// Whether the process `pid` still runs (its pinentry): on Linux, as /proc
311/// says; elsewhere not known, taken as running until GnuPG answers.
312fn running(pid: u32) -> bool {
313    if cfg!(target_os = "linux") { Path::new(&format!("/proc/{pid}")).exists() } else { true }
314}
315
316/// While Sioul's own GnuPG works with the security key, Sioul's own looks at
317/// the keys wait (`Gnupg::renew`): a key opened by Sioul between two of
318/// GnuPG's commands, and reset when closed, would undo the PIN GnuPG gave it.
319static GNUPG_ON_CARD: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
320
321/// Held while Sioul's own GnuPG works with the security key.
322pub struct OnCard;
323
324impl OnCard {
325    /// From now until dropped, Sioul's looks at the keys wait.
326    pub fn start() -> OnCard {
327        GNUPG_ON_CARD.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
328        OnCard
329    }
330}
331
332impl Drop for OnCard {
333    fn drop(&mut self) {
334        GNUPG_ON_CARD.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
335    }
336}
337
338/// Whether Sioul may look at the security keys now: not while its own GnuPG works with one.
339pub fn may_look() -> bool {
340    GNUPG_ON_CARD.load(std::sync::atomic::Ordering::SeqCst) == 0
341}
342
343impl Gnupg {
344    /// GnuPG here: `gpg` on the path, else where its installers put it (a
345    /// window started from the desktop may not have them on its path).
346    pub fn find() -> Result<Gnupg, GnupgProblem> {
347        if cfg!(target_os = "android") {
348            return Err(GnupgProblem::Missing);
349        }
350        if Path::new("/.flatpak-info").exists() {
351            return Err(GnupgProblem::Sandboxed);
352        }
353        let name = if cfg!(windows) { "gpg.exe" } else { "gpg" };
354        let on_path = std::env::var_os("PATH").into_iter().flat_map(|path| std::env::split_paths(&path).collect::<Vec<_>>()).map(|dir| dir.join(name));
355        let known: Vec<PathBuf> = if cfg!(target_os = "macos") {
356            ["/opt/homebrew/bin/gpg", "/usr/local/bin/gpg", "/usr/local/MacGPG2/bin/gpg"].map(PathBuf::from).to_vec()
357        } else if cfg!(windows) {
358            [r"C:\Program Files (x86)\GnuPG\bin\gpg.exe", r"C:\Program Files\GnuPG\bin\gpg.exe"].map(PathBuf::from).to_vec()
359        } else {
360            vec![PathBuf::from("/usr/bin/gpg")]
361        };
362        on_path.chain(known).find(|p| p.is_file()).map(|program| Gnupg { program, home: None }).ok_or(GnupgProblem::Missing)
363    }
364
365    /// This `gpg` with its home in `home` (`--homedir`): tests, never yours.
366    pub fn with_home(program: &Path, home: &Path) -> Gnupg {
367        Gnupg { program: program.to_path_buf(), home: Some(home.to_path_buf()) }
368    }
369
370    /// `gpg` run with `args`, in batch mode, nothing on its input; its output,
371    /// or why not. GnuPG's own pinentry may ask for a PIN meanwhile.
372    fn run(&self, args: &[&str]) -> Result<Vec<u8>, GnupgProblem> {
373        self.run_watching(args, None)
374    }
375
376    /// The same, saying where it is as it goes (`watch`: its status read as
377    /// it comes): the pinentry open, then the key that may wait for a touch.
378    /// A security key that waited in vain is said as such.
379    fn run_watching(&self, args: &[&str], watch: Option<&(dyn Fn(GnupgStep) + Sync)>) -> Result<Vec<u8>, GnupgProblem> {
380        let mut command = std::process::Command::new(&self.program);
381        if let Some(home) = &self.home {
382            command.arg("--homedir").arg(home);
383        }
384        command.args(["--batch", "--no-tty"]);
385        if watch.is_some() {
386            command.args(["--status-fd", "2"]);
387        }
388        command.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
389        // No console window flashing up from Sioul's.
390        #[cfg(windows)]
391        {
392            use std::os::windows::process::CommandExt;
393            command.creation_flags(0x0800_0000);
394        }
395        let mut child = command.spawn().map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { GnupgProblem::Missing } else { GnupgProblem::Failed(e.to_string()) })?;
396        // Its output read as it comes, so that a long one never blocks it.
397        let mut out = child.stdout.take();
398        let mut err = child.stderr.take();
399        let reading = std::thread::spawn(move || {
400            use std::io::Read;
401            let mut bytes = Vec::new();
402            if let Some(out) = out.as_mut() {
403                let _ = out.read_to_end(&mut bytes);
404            }
405            bytes
406        });
407        // Its words and its status, line by line as they come.
408        let (tell, heard) = std::sync::mpsc::channel::<Status>();
409        let said = std::thread::spawn(move || {
410            use std::io::BufRead;
411            let mut text = String::new();
412            if let Some(err) = err.take() {
413                for line in std::io::BufReader::new(err).lines().map_while(Result::ok) {
414                    match status_of(&line) {
415                        Some(status) => {
416                            let _ = tell.send(status);
417                        }
418                        None if !line.starts_with("[GNUPG:]") => {
419                            text.push_str(&line);
420                            text.push('\n');
421                        }
422                        None => {}
423                    }
424                }
425            }
426            text
427        });
428        let started = std::time::Instant::now();
429        let mut shown: Option<GnupgStep> = None;
430        let mut pinentry: Option<u32> = None;
431        let mut failed: Option<u32> = None;
432        let status = loop {
433            if let Some(watch) = watch {
434                for status in heard.try_iter() {
435                    match status {
436                        Status::Pinentry(pid) => pinentry = Some(pid),
437                        Status::Failed(code) => failed = Some(code),
438                    }
439                }
440                if pinentry.is_some_and(|pid| !running(pid)) {
441                    pinentry = None;
442                }
443                let step = if pinentry.is_some() { GnupgStep::Pin } else { GnupgStep::Card };
444                if shown != Some(step) {
445                    shown = Some(step);
446                    watch(step);
447                }
448            }
449            match child.try_wait() {
450                Ok(Some(status)) => break status,
451                Ok(None) if started.elapsed() > GNUPG_WAITS => {
452                    let _ = child.kill();
453                    let _ = child.wait();
454                    return Err(GnupgProblem::Failed("gpg did not answer".into()));
455                }
456                Ok(None) => std::thread::sleep(Duration::from_millis(100)),
457                Err(e) => return Err(GnupgProblem::Failed(e.to_string())),
458            }
459        };
460        let output = reading.join().unwrap_or_default();
461        let errors = said.join().unwrap_or_default();
462        failed = heard.try_iter().fold(failed, |failed, status| if let Status::Failed(code) = status { Some(code) } else { failed });
463        if status.success() {
464            Ok(output)
465        } else if failed == Some(GPG_ERR_TIMEOUT) {
466            Err(GnupgProblem::TouchMissed)
467        } else {
468            Err(GnupgProblem::Failed(errors.lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("").to_string()))
469        }
470    }
471
472    /// The certificate of the key `fingerprint`, armored, as GnuPG keeps it
473    /// (`gpg --export --armor`): public parts only.
474    pub fn export(&self, fingerprint: &str) -> Result<Vec<u8>, GnupgProblem> {
475        let fingerprint = fingerprint.trim();
476        if fingerprint.is_empty() || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
477            return Err(GnupgProblem::NoSuchKey);
478        }
479        let out = self.run(&["--armor", "--export", fingerprint])?;
480        if out.iter().all(u8::is_ascii_whitespace) { Err(GnupgProblem::NoSuchKey) } else { Ok(out) }
481    }
482
483    /// Whether GnuPG holds the secret part of `fingerprint`, or knows the card
484    /// that does: then it can renew the key.
485    pub fn holds_secret(&self, fingerprint: &str) -> bool {
486        let fingerprint = fingerprint.trim();
487        !fingerprint.is_empty()
488            && fingerprint.chars().all(|c| c.is_ascii_hexdigit())
489            && self.run(&["--with-colons", "--list-secret-keys", fingerprint]).is_ok_and(|out| String::from_utf8_lossy(&out).lines().any(|l| l.starts_with("sec:")))
490    }
491
492    /// The key `fingerprint` renewed for `period` ("2y"), then its subkeys:
493    /// `gpg --quick-set-expire`, twice, GnuPG's own pinentry asking the PIN
494    /// (a security key's: and a touch) each time it needs it.
495    /// Its subkeys are named one by one, expired ones included: GnuPG's "*"
496    /// sets only those not yet expired, and a subkey expired since long
497    /// stayed so. `watch` is told where it is: GnuPG's pinentry open, then
498    /// the key that may wait for a touch. Meanwhile Sioul's own looks at the
499    /// keys wait ([`may_look`]); at the end, done or not, GnuPG lets the
500    /// security key go ([`Gnupg::release`]), so that Sioul can read it next.
501    pub fn renew(&self, fingerprint: &str, period: &str, watch: &(dyn Fn(GnupgStep) + Sync)) -> Result<(), GnupgProblem> {
502        if !self.holds_secret(fingerprint) {
503            return Err(GnupgProblem::NoSuchKey);
504        }
505        let fingerprint = fingerprint.trim();
506        let subkeys = self.subkeys(fingerprint);
507        let renewed = {
508            let _on_card = OnCard::start();
509            self.run_watching(&["--quick-set-expire", fingerprint, period], Some(watch)).and_then(|_| {
510                if subkeys.is_empty() {
511                    return Ok(Vec::new());
512                }
513                let mut args = vec!["--quick-set-expire", fingerprint, period];
514                args.extend(subkeys.iter().map(String::as_str));
515                self.run_watching(&args, Some(watch))
516            })
517        };
518        // Sioul's own GnuPG used the key: it lets it go, not a setting changed.
519        let _ = self.release();
520        renewed.map(|_| ())
521    }
522
523    /// The subkeys of `fingerprint` that are not revoked, expired ones
524    /// included, as GnuPG lists them; none when it does not list them.
525    pub fn subkeys(&self, fingerprint: &str) -> Vec<String> {
526        let Ok(listed) = self.run(&["--with-colons", "--list-keys", fingerprint.trim()]) else { return Vec::new() };
527        let mut out = Vec::new();
528        let mut next_is_sub = false;
529        for line in String::from_utf8_lossy(&listed).lines() {
530            let fields: Vec<&str> = line.split(':').collect();
531            match fields.first().copied() {
532                // Its validity: "r" revoked, left alone.
533                Some("sub") => next_is_sub = fields.get(1).is_some_and(|v| *v != "r"),
534                Some("fpr") if next_is_sub => {
535                    out.extend(fields.get(9).filter(|f| !f.is_empty()).map(|f| f.to_string()));
536                    next_is_sub = false;
537                }
538                Some("fpr") => {}
539                _ => next_is_sub = false,
540            }
541        }
542        out
543    }
544
545    /// GnuPG's smart card daemon stopped, after Sioul's own GnuPG used the
546    /// security key: it holds the key for itself (unless `pcsc-shared` is in
547    /// scdaemon.conf) and Sioul could not read it next. gpg starts it again
548    /// when it needs the key. GnuPG's settings stay as they are.
549    pub fn release(&self) -> Released {
550        let gpgconf = self.program.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" });
551        let program = if gpgconf.is_file() { gpgconf } else { PathBuf::from(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" }) };
552        release_at(&program, self.home.as_deref())
553    }
554}
555
556/// The commands that renew the key `fingerprint` for two years by hand: the
557/// key, then its `subkeys`, each named (GnuPG's "*" leaves the expired ones).
558pub fn renew_commands(fingerprint: &str, subkeys: &[String]) -> Vec<String> {
559    let mut out = vec![format!("gpg --quick-set-expire {fingerprint} 2y")];
560    if !subkeys.is_empty() {
561        out.push(format!("gpg --quick-set-expire {fingerprint} 2y {}", subkeys.join(" ")));
562    }
563    out
564}
565
566/// The command that writes the key's certificate into a file by hand.
567pub fn export_command(fingerprint: &str) -> String {
568    format!("gpg --export --armor {fingerprint} > key.asc")
569}
570
571/// What keys.openpgp.org said of a certificate sent to it.
572#[derive(Debug, Clone, Default, PartialEq, Eq)]
573pub struct Published {
574    /// Its fingerprint, as the server read it.
575    pub fingerprint: String,
576    /// The addresses it mailed a link to: each published once its link is opened.
577    pub mailed: Vec<String>,
578    /// The addresses findable there already.
579    pub published: Vec<String>,
580}
581
582/// keys.openpgp.org, where "Send it to keys.openpgp.org" sends.
583pub const KEYS_OPENPGP: &str = "https://keys.openpgp.org";
584
585/// Sends the public certificate `armored` to keys.openpgp.org, only when you
586/// press its button: over HTTPS, then a link asked for each address it names
587/// that is not published yet, the mail in your language (`locale`, "fr").
588/// Anyone can then find the key there by its fingerprint; by an address,
589/// only once that address's link is opened.
590pub fn send_to_keys_openpgp(armored: &[u8], locale: &str) -> Result<Published, SyncError> {
591    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).https_only(true).http_status_as_error(false).user_agent("Sioul").build().into();
592    send_at(&agent, KEYS_OPENPGP, armored, locale)
593}
594
595fn send_at(agent: &ureq::Agent, base: &str, armored: &[u8], locale: &str) -> Result<Published, SyncError> {
596    let call = |path: &str, body: serde_json::Value| -> Result<serde_json::Value, SyncError> {
597        let mut response = agent.post(&format!("{base}{path}")).header("Content-Type", "application/json").send(body.to_string()).map_err(|e| SyncError::Network(e.to_string()))?;
598        let status = response.status().as_u16();
599        let answer: serde_json::Value = serde_json::from_str(&response.body_mut().read_to_string().unwrap_or_default()).unwrap_or_default();
600        if status == 200 {
601            Ok(answer)
602        } else {
603            Err(SyncError::Server(format!("keys.openpgp.org: {status} {}", answer["error"].as_str().unwrap_or("")).trim().to_string()))
604        }
605    };
606    let uploaded = call("/vks/v1/upload", serde_json::json!({ "keytext": String::from_utf8_lossy(armored) }))?;
607    let addresses = |answer: &serde_json::Value, states: &[&str]| -> Vec<String> {
608        answer["status"].as_object().map(|status| status.iter().filter(|(_, state)| states.contains(&state.as_str().unwrap_or(""))).map(|(address, _)| address.clone()).collect()).unwrap_or_default()
609    };
610    let mut published = Published { fingerprint: uploaded["key_fpr"].as_str().unwrap_or("").to_string(), mailed: Vec::new(), published: addresses(&uploaded, &["published"]) };
611    let waiting = addresses(&uploaded, &["unpublished", "pending"]);
612    let token = uploaded["token"].as_str().unwrap_or("");
613    if !waiting.is_empty() && !token.is_empty() {
614        let locale: Vec<&str> = Some(locale).filter(|l| !l.is_empty()).into_iter().collect();
615        let asked = call("/vks/v1/request-verify", serde_json::json!({ "token": token, "addresses": waiting, "locale": locale }))?;
616        published.mailed = addresses(&asked, &["pending"]);
617        published.published = addresses(&asked, &["published"]);
618    }
619    Ok(published)
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use sioul_core::securitykey::SoftCard;
626    use std::io::{BufRead, BufReader, Write};
627    use std::net::TcpListener;
628
629    /// A server on this computer answering each request with the next of
630    /// `answers`, and telling which paths were asked.
631    fn stand_in(answers: Vec<Vec<u8>>) -> (String, std::sync::mpsc::Receiver<String>) {
632        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
633        let address = format!("http://{}", listener.local_addr().unwrap());
634        let (tell, heard) = std::sync::mpsc::channel();
635        std::thread::spawn(move || {
636            for answer in answers {
637                let Ok((stream, _)) = listener.accept() else { return };
638                let mut reader = BufReader::new(stream.try_clone().unwrap());
639                let mut line = String::new();
640                reader.read_line(&mut line).unwrap();
641                loop {
642                    let mut header = String::new();
643                    reader.read_line(&mut header).unwrap();
644                    if header.trim_end().is_empty() {
645                        break;
646                    }
647                }
648                let _ = tell.send(line.split_whitespace().nth(1).unwrap_or("").to_string());
649                let mut stream = stream;
650                let _ = stream.write_all(&answer);
651            }
652        });
653        (address, heard)
654    }
655
656    fn ok(body: &[u8]) -> Vec<u8> {
657        let mut answer = format!("HTTP/1.1 200 OK\r\nContent-Type: application/pgp-keys\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).into_bytes();
658        answer.extend_from_slice(body);
659        answer
660    }
661
662    const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
663
664    fn agent() -> ureq::Agent {
665        ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).build().into()
666    }
667
668    #[test]
669    fn the_places_come_in_order_and_https_only() {
670        let (mut card, _) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
671        card.info.url = "https://keys.example.org/me.asc".into();
672        let urls = places(&card.info, &["me@example.org".to_string()]);
673        assert_eq!(urls[0], "https://keys.example.org/me.asc");
674        assert!(urls[1].starts_with("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/"), "{urls:?}");
675        assert!(urls[2].starts_with("https://example.org/.well-known/openpgpkey/hu/"), "{urls:?}");
676        let fingerprint = card.info.sign.as_ref().unwrap().fingerprint.clone();
677        assert_eq!(urls[3], format!("https://keys.openpgp.org/vks/v1/by-fingerprint/{fingerprint}"));
678        assert_eq!(fingerprint, fingerprint.to_ascii_uppercase());
679        // An address in clear on the key is not used.
680        card.info.url = "http://keys.example.org/me.asc".into();
681        assert!(!places(&card.info, &[]).iter().any(|u| u.starts_with("http:")));
682        assert_eq!(places(&card.info, &[]).len(), 1);
683    }
684
685    #[test]
686    fn the_first_certificate_that_is_the_keys_is_kept() {
687        let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
688        let (_, stranger) = SoftCard::generate("0006:87654321", &["Someone Else <someone@example.net>"], "123456").unwrap();
689        let (base, heard) = stand_in(vec![NOT_FOUND.to_vec(), ok(&stranger), ok(&public)]);
690        let urls = vec![format!("{base}/card"), format!("{base}/wkd"), format!("{base}/vks")];
691        match find_at(&agent(), &urls, &card.info).unwrap() {
692            Looked::Found { check, source, .. } => {
693                assert_eq!(source, format!("{base}/vks"));
694                assert_eq!(check.addresses, vec!["me@example.org".to_string()]);
695                assert!(check.signs && check.decrypts && !check.expired);
696            }
697            _ => panic!("not found"),
698        }
699        let asked: Vec<String> = heard.try_iter().collect();
700        assert_eq!(asked, vec!["/card", "/wkd", "/vks"]);
701        // Nothing of the key's anywhere: said as not found.
702        let (base, _heard) = stand_in(vec![NOT_FOUND.to_vec(), ok(&stranger)]);
703        assert!(matches!(find_at(&agent(), &[format!("{base}/a"), format!("{base}/b")], &card.info).unwrap(), Looked::NotFound));
704    }
705
706    #[test]
707    fn no_network_is_said_as_such() {
708        let (card, _) = SoftCard::generate("0006:12345678", &[], "123456").unwrap();
709        // A port nobody listens on: bound, then let go.
710        let closed = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
711        assert!(matches!(find_at(&agent(), &[format!("http://{closed}/key")], &card.info), Err(SyncError::Network(_))));
712    }
713
714    #[test]
715    fn logs_never_carry_a_pin() {
716        // Raised by a test as anything could: capped again at "info", never higher.
717        log::set_max_level(log::LevelFilter::Trace);
718        cap_logging();
719        assert_eq!(log::max_level(), log::LevelFilter::Info);
720        assert!(!log::log_enabled!(target: "openpgp_card::ocard::apdu", log::Level::Trace));
721        // Never raised by the cap: "off" stays off.
722        log::set_max_level(log::LevelFilter::Off);
723        cap_logging();
724        assert_eq!(log::max_level(), log::LevelFilter::Off);
725        // No crate of Sioul's sets a logger or a level but the cap, and no crate it builds with installs a logger.
726        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
727        let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
728        for logger in ["env_logger", "simple_logger", "simplelog", "fern", "log4rs", "android_logger", "oslog", "syslog", "systemd-journal-logger", "tracing-log", "pretty_env_logger", "flexi_logger", "slog-stdlog", "stderrlog", "tracing-subscriber"] {
729            assert!(!lock.contains(&format!("name = \"{logger}\"")), "{logger} would write what crates log");
730        }
731        let mut sources = vec![root.join("crates")];
732        while let Some(path) = sources.pop() {
733            if path.is_dir() {
734                sources.extend(std::fs::read_dir(&path).unwrap().filter_map(Result::ok).map(|e| e.path()));
735            } else if path.extension().is_some_and(|x| x == "rs") {
736                let text = std::fs::read_to_string(&path).unwrap();
737                // Spelt in two halves, so that this test does not find itself.
738                for (call, here) in [(concat!("set_", "logger("), false), (concat!("set_boxed", "_logger("), false), (concat!("set_max", "_level("), true)] {
739                    let calls = text.matches(call).count();
740                    let allowed = if here && path.ends_with("sioul-sync/src/securitykey.rs") { calls } else { 0 };
741                    assert_eq!(calls, allowed, "{} calls {call}", path.display());
742                }
743            }
744        }
745    }
746
747    /// A stand-in program written at `path`, executable, never open for
748    /// writing in this process: written aside, then put in place by a child
749    /// (`install`). Written here, a test thread forking meanwhile carries its
750    /// open file into its child until that child execs, and running the
751    /// program then fails with "Text file busy" (ETXTBSY; seen once in forty
752    /// runs, 23 October 2026).
753    #[cfg(unix)]
754    pub(super) fn executable(path: &std::path::Path, text: &str) {
755        let aside = path.with_extension("text");
756        std::fs::write(&aside, text).unwrap();
757        let status = std::process::Command::new("install").arg("-m").arg("755").arg(&aside).arg(path).status().unwrap();
758        assert!(status.success(), "install {}", path.display());
759        let _ = std::fs::remove_file(&aside);
760    }
761
762    #[cfg(unix)]
763    #[test]
764    fn gnupg_is_asked_to_let_go_with_a_stand_in() {
765        let dir = std::env::temp_dir().join(format!("sioul-gpgconf-{}", std::process::id()));
766        std::fs::create_dir_all(&dir).unwrap();
767        // Never GnuPG's own gpgconf: a script that only says what it was asked.
768        let program = dir.join("gpgconf");
769        executable(&program, &format!("#!/bin/sh\necho \"$@\" > '{}'\n", dir.join("asked").display()));
770        assert_eq!(release_with(&program), Released::Done);
771        assert_eq!(std::fs::read_to_string(dir.join("asked")).unwrap().trim(), "--kill scdaemon");
772        let refusing = dir.join("refusing");
773        executable(&refusing, "#!/bin/sh\necho 'gpgconf: no such component' >&2\nexit 2\n");
774        assert_eq!(release_with(&refusing), Released::Failed("gpgconf: no such component".into()));
775        assert_eq!(release_with(&dir.join("nowhere")), Released::NoGnupg);
776        let tr = Translator::new("en");
777        for said in [Released::Done, Released::NoGnupg, Released::Sandboxed, Released::Failed("x".into())] {
778            assert!(!said.sentence(&tr).starts_with("seckey-"), "{said:?}");
779        }
780        std::fs::remove_dir_all(&dir).unwrap();
781    }
782
783    /// A throwaway GnuPG home, never yours: its agent never starts the smart
784    /// card daemon (no security key plugged in is touched), and is stopped
785    /// with the folder when the test ends.
786    struct ThrowawayHome {
787        dir: PathBuf,
788        gpg: PathBuf,
789    }
790
791    impl ThrowawayHome {
792        fn new(name: &str) -> Option<ThrowawayHome> {
793            let gpg = Gnupg::find().ok()?.program;
794            // Git for Windows' gpg (the one CI finds) is an MSYS build: it reads
795            // a Windows folder as a relative path. Gpg4win's would do.
796            if cfg!(windows) && gpg.components().any(|c| c.as_os_str().eq_ignore_ascii_case("usr")) {
797                return None;
798            }
799            // Short: GnuPG's sockets may live in it, 108 bytes at most.
800            let dir = std::env::temp_dir().join(format!("sg-{name}-{}", std::process::id()));
801            let _ = std::fs::remove_dir_all(&dir);
802            std::fs::create_dir_all(&dir).unwrap();
803            #[cfg(unix)]
804            {
805                use std::os::unix::fs::PermissionsExt;
806                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
807            }
808            std::fs::write(dir.join("gpg-agent.conf"), "disable-scdaemon\n").unwrap();
809            std::fs::write(dir.join("gpg.conf"), "no-auto-key-locate\nno-auto-key-retrieve\n").unwrap();
810            Some(ThrowawayHome { dir, gpg })
811        }
812
813        /// `gpg` in this home, no passphrase asked (the keys made here have none).
814        fn gpg(&self, args: &[&str]) -> String {
815            self.gpg_said(args).0
816        }
817
818        /// The same, with what it said besides (an import names what it left out).
819        fn gpg_said(&self, args: &[&str]) -> (String, String) {
820            let out = std::process::Command::new(&self.gpg).arg("--homedir").arg(&self.dir).args(["--batch", "--no-tty", "--pinentry-mode", "loopback", "--passphrase", ""]).args(args).stdin(Stdio::null()).output().unwrap();
821            assert!(out.status.success(), "gpg {args:?}: {}", String::from_utf8_lossy(&out.stderr));
822            (String::from_utf8_lossy(&out.stdout).to_string(), String::from_utf8_lossy(&out.stderr).to_string())
823        }
824
825        fn gnupg(&self) -> Gnupg {
826            Gnupg::with_home(&self.gpg, &self.dir)
827        }
828    }
829
830    impl Drop for ThrowawayHome {
831        fn drop(&mut self) {
832            let gpgconf = self.gpg.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" });
833            let _ = std::process::Command::new(gpgconf).arg("--homedir").arg(&self.dir).args(["--kill", "all"]).stdin(Stdio::null()).output();
834            let _ = std::fs::remove_dir_all(&self.dir);
835        }
836    }
837
838    /// When each key of `fingerprint`'s certificate expires (Unix seconds), from GnuPG's own listing.
839    fn expiries(home: &ThrowawayHome, fingerprint: &str) -> Vec<i64> {
840        home.gpg(&["--with-colons", "--list-keys", fingerprint]).lines().filter(|l| l.starts_with("pub:") || l.starts_with("sub:")).map(|l| l.split(':').nth(6).unwrap_or("").parse().unwrap_or(0)).collect()
841    }
842
843    /// "Import from GnuPG" and "Renew for two years", run against a GnuPG home
844    /// made for the test, with a key made there without a passphrase: never
845    /// yours, never a card, never the network. Skipped where GnuPG is not installed.
846    #[test]
847    fn gnupg_exports_and_renews_in_a_throwaway_home() {
848        let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
849        let Some(home) = ThrowawayHome::new("gpg") else { return };
850        // A security key's certificate, known to this GnuPG as it would be after `gpg --card-status` and a fetch.
851        let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
852        let asc = home.dir.join("card.asc");
853        std::fs::write(&asc, &public).unwrap();
854        let (_, imported) = home.gpg_said(&["--import", asc.to_str().unwrap()]);
855        let fingerprint = card.info.sign.as_ref().unwrap().fingerprint.clone();
856        let gnupg = home.gnupg();
857        // Exported, then checked against the key as "Import from GnuPG" does: the key's own.
858        let exported = gnupg.export(&fingerprint).unwrap();
859        assert!(String::from_utf8_lossy(&exported).starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
860        // Failed once on CI, never here: GnuPG 2.4.4 (Ubuntu 24.04's) writes
861        // about one key signature in 128 with a number one octet long, which
862        // Sequoia alone refuses; read as its value since (`pgp::minimal_mpis`).
863        // What GnuPG kept, should it happen again.
864        let (_, check) = securitykey::certificate_in(&exported, &card.info).unwrap_or_else(|e| panic!("{e:?}; the import said: {imported}; GnuPG holds: {}; exported: {}", home.gpg(&["--with-colons", "--list-keys"]), String::from_utf8_lossy(&exported)));
865        assert_eq!(check.addresses, vec!["me@example.org".to_string()]);
866        // A key GnuPG does not hold, or a fingerprint that is none: said so, nothing run with it.
867        assert_eq!(gnupg.export("0123456789ABCDEF0123456789ABCDEF01234567"), Err(GnupgProblem::NoSuchKey));
868        assert_eq!(gnupg.export("me; rm -rf ~"), Err(GnupgProblem::NoSuchKey));
869        // Its secret part on the card, unknown to this GnuPG: it cannot renew it.
870        assert!(!gnupg.holds_secret(&fingerprint));
871        assert_eq!(gnupg.renew(&fingerprint, "2y", &|_| {}), Err(GnupgProblem::NoSuchKey));
872        // A key of its own, valid a year: renewed for two, the key and its subkey.
873        home.gpg(&["--quick-gen-key", "Test Key <test@example.org>", "ed25519", "cert,sign", "1y"]);
874        let own = home.gpg(&["--with-colons", "--list-secret-keys", "test@example.org"]).lines().find(|l| l.starts_with("fpr:")).unwrap().split(':').nth(9).unwrap().to_string();
875        home.gpg(&["--quick-add-key", &own, "cv25519", "encr", "1y"]);
876        assert!(gnupg.holds_secret(&own));
877        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
878        assert!(expiries(&home, &own).iter().all(|at| *at < now + 400 * 86_400), "a year first");
879        // Where it is, as it goes: no pinentry for a key without a passphrase, so the key (here, none) at once;
880        // Sioul's own looks at the keys wait meanwhile.
881        let steps = std::sync::Mutex::new(Vec::new());
882        let looks = std::sync::Mutex::new(Vec::new());
883        gnupg
884            .renew(&own, "2y", &|step| {
885                steps.lock().unwrap().push(step);
886                looks.lock().unwrap().push(may_look());
887            })
888            .unwrap();
889        assert_eq!(*steps.lock().unwrap(), [GnupgStep::Card, GnupgStep::Card], "one for the key, one for its subkeys");
890        assert!(looks.lock().unwrap().iter().all(|may| !may), "no look at the keys while GnuPG works with one");
891        assert!(may_look(), "and again once it is done");
892        let renewed = expiries(&home, &own);
893        assert_eq!(renewed.len(), 2);
894        assert!(renewed.iter().all(|at| *at > now + 700 * 86_400 && *at < now + 740 * 86_400), "{renewed:?}");
895        // No GnuPG there: said as missing.
896        assert_eq!(Gnupg::with_home(&home.dir.join("no-gpg"), &home.dir).export(&own), Err(GnupgProblem::Missing));
897        // The commands, for doing it by hand.
898        assert_eq!(renew_commands("ABCD", &["EF01".to_string(), "2345".to_string()]), ["gpg --quick-set-expire ABCD 2y".to_string(), "gpg --quick-set-expire ABCD 2y EF01 2345".to_string()]);
899        assert_eq!(renew_commands("ABCD", &[]), ["gpg --quick-set-expire ABCD 2y".to_string()]);
900        assert_eq!(export_command("ABCD"), "gpg --export --armor ABCD > key.asc");
901    }
902
903    /// A key whose primary key was renewed alone, in a terminal, its subkeys
904    /// expired long ago (made in 2022 for a year, in a GnuPG home made for
905    /// the test): GnuPG's "*" leaves them so; each said exactly; then
906    /// "Renew for two years" names each subkey, and every part is renewed.
907    /// Each step of GnuPG's must succeed (`ThrowawayHome::gpg`). It failed on
908    /// CI now and then, a part or every part missing: GnuPG 2.4.4 (Ubuntu
909    /// 24.04's) wrote one of the key's signatures with a number one octet
910    /// long, which Sequoia alone refuses (`pgp::minimal_mpis`). Run 300 times
911    /// against that GnuPG on 14 October 2026: 8 failures before, none after.
912    #[test]
913    fn subkeys_expired_are_said_and_renewed_one_by_one() {
914        let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
915        let Some(home) = ThrowawayHome::new("sub") else { return };
916        let past = "20220101T000000";
917        home.gpg(&["--faked-system-time", past, "--quick-gen-key", "Old Key <old@example.org>", "ed25519", "cert,sign", "1y"]);
918        let primary = home.gpg(&["--with-colons", "--list-secret-keys", "old@example.org"]).lines().find(|l| l.starts_with("fpr:")).unwrap().split(':').nth(9).unwrap().to_string();
919        // Each subkey a minute after the key it binds to: at the same faked second, a
920        // primary key made a second late (a slow first run) makes GnuPG refuse the
921        // subkey as made before it ("Time conflict", once in forty runs, 23 October 2026).
922        home.gpg(&["--faked-system-time", "20220101T000100", "--quick-add-key", &primary, "cv25519", "encr", "1y"]);
923        home.gpg(&["--faked-system-time", "20220101T000200", "--quick-add-key", &primary, "ed25519", "auth", "1y"]);
924        // As the owner did: the primary key renewed, then "*", which leaves expired subkeys as they are.
925        home.gpg(&["--quick-set-expire", &primary, "2y"]);
926        home.gpg(&["--quick-set-expire", &primary, "2y", "*"]);
927        let gnupg = home.gnupg();
928        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
929        let exported = gnupg.export(&primary).unwrap();
930        let parts = securitykey::parts_in(&exported);
931        let roles: Vec<(securitykey::Role, bool)> = parts.iter().map(|p| (p.role, p.expires.is_some_and(|at| at <= now))).collect();
932        assert_eq!(roles, [(securitykey::Role::Primary, false), (securitykey::Role::Encryption, true), (securitykey::Role::Authentication, true)], "GnuPG exported: {}", String::from_utf8_lossy(&exported));
933        let tr = Translator::new("en");
934        let said = securitykey::expired_parts(&tr, &parts, now);
935        assert_eq!(said.len(), 2, "{said:?}");
936        let encryption = securitykey::short_fingerprint(&parts[1].fingerprint);
937        assert!(said[0].starts_with(&format!("Its encryption subkey ({encryption}) expired on ")) && said[0].ends_with("2023: nobody can encrypt to you with it."), "{}", said[0]);
938        assert!(said[1].starts_with("Its authentication subkey ("), "{}", said[1]);
939        // GnuPG lists both subkeys, the expired ones too: Renew names them.
940        let mut listed = gnupg.subkeys(&primary);
941        listed.sort();
942        let mut expected = vec![parts[1].fingerprint.clone(), parts[2].fingerprint.clone()];
943        expected.sort();
944        assert_eq!(listed, expected);
945        gnupg.renew(&primary, "2y", &|_| {}).unwrap();
946        let renewed = securitykey::parts_in(&gnupg.export(&primary).unwrap());
947        assert!(renewed.iter().all(|p| p.expires.is_some_and(|at| at > now + 700 * 86_400)), "{renewed:?}");
948        assert!(securitykey::expired_parts(&tr, &renewed, now).is_empty());
949        let until = securitykey::parts_until(&tr, &renewed);
950        assert!(until.starts_with("primary key until ") && until.contains("encryption subkey until ") && until.contains("authentication subkey until "), "{until}");
951    }
952
953    /// Sioul's own GnuPG step on the security key ends with GnuPG letting the
954    /// key go, asked once, done or not; with stand-ins for gpg and gpgconf,
955    /// never GnuPG's own.
956    #[cfg(unix)]
957    #[test]
958    fn renewing_ends_with_the_key_let_go_once() {
959        let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
960        let dir = std::env::temp_dir().join(format!("sioul-gpg-standin-{}", std::process::id()));
961        let _ = std::fs::remove_dir_all(&dir);
962        std::fs::create_dir_all(&dir).unwrap();
963        let primary = "0123456789ABCDEF0123456789ABCDEF01234567";
964        let sub = "89ABCDEF0123456789ABCDEF0123456789ABCDEF";
965        let asked = dir.join("asked");
966        let script = |name: &str, body: String| {
967            let path = dir.join(name);
968            executable(&path, &body);
969            path
970        };
971        let fail = dir.join("fail");
972        let gpg = script(
973            "gpg",
974            format!(
975                "#!/bin/sh\ncase \"$*\" in\n*--list-secret-keys*) echo 'sec:u:255:22:0123456789ABCDEF:1:::::::scESC:::#:::23::0:' ;;\n*--list-keys*) printf 'pub:u:255:22:0123456789ABCDEF:1:::::::scESC:\\nfpr:::::::::{primary}:\\nsub:e:255:18:89ABCDEF0123:1:::::::e:\\nfpr:::::::::{sub}:\\n' ;;\n*--quick-set-expire*) echo \"gpg $*\" >> '{asked}'; if [ -e '{fail}' ]; then echo '[GNUPG:] FAILURE sign 100663358' >&2; exit 2; fi ;;\nesac\nexit 0\n",
976                asked = asked.display(),
977                fail = fail.display()
978            ),
979        );
980        script("gpgconf", format!("#!/bin/sh\necho \"gpgconf $*\" >> '{}'\n", asked.display()));
981        let home = dir.join("home");
982        let gnupg = Gnupg::with_home(&gpg, &home);
983        gnupg.renew(primary, "2y", &|_| {}).unwrap();
984        let lines: Vec<String> = std::fs::read_to_string(&asked).unwrap().lines().map(str::to_string).collect();
985        let homedir = format!("--homedir {}", home.display());
986        assert_eq!(
987            lines,
988            [
989                format!("gpg {homedir} --batch --no-tty --status-fd 2 --quick-set-expire {primary} 2y"),
990                format!("gpg {homedir} --batch --no-tty --status-fd 2 --quick-set-expire {primary} 2y {sub}"),
991                format!("gpgconf {homedir} --kill scdaemon"),
992            ],
993            "the key and its expired subkey, each named; then the key let go, once"
994        );
995        // A touch missed: said as such, and the key let go all the same.
996        std::fs::write(&asked, "").unwrap();
997        std::fs::write(&fail, "").unwrap();
998        assert_eq!(gnupg.renew(primary, "2y", &|_| {}), Err(GnupgProblem::TouchMissed));
999        let lines = std::fs::read_to_string(&asked).unwrap();
1000        assert_eq!(lines.lines().filter(|l| l.starts_with("gpgconf")).count(), 1, "{lines}");
1001        std::fs::remove_dir_all(&dir).unwrap();
1002    }
1003
1004    /// GnuPG's status lines, invented as GnuPG writes them: its pinentry
1005    /// opened, and a security key that waited in vain for a touch (the code
1006    /// GPG_ERR_TIMEOUT, from the smart card daemon or not), whatever the
1007    /// language of its words.
1008    /// The tests that hold the pause on the keys, one at a time: it is one for the whole program.
1009    static ON_CARD_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
1010
1011    #[test]
1012    fn gnupg_says_where_it_is_and_a_touch_missed() {
1013        let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
1014        assert_eq!(status_of("[GNUPG:] PINENTRY_LAUNCHED 41235 gnome3 1.3.1 - - - - 1000/1000 0"), Some(Status::Pinentry(41235)));
1015        assert_eq!(status_of("[GNUPG:] FAILURE sign 100663358"), Some(Status::Failed(GPG_ERR_TIMEOUT)), "6 << 24 | 62: the smart card daemon's timeout");
1016        assert_eq!(status_of("[GNUPG:] ERROR keyedit.setexpire 62"), Some(Status::Failed(GPG_ERR_TIMEOUT)));
1017        assert_eq!(status_of("[GNUPG:] FAILURE sign 83918950"), Some(Status::Failed(83918950 & 0xFFFF)));
1018        assert_eq!(status_of("[GNUPG:] KEY_CONSIDERED ABCD 0"), None);
1019        assert_eq!(status_of("gpg: signing failed: Délai d'attente dépassé"), None);
1020        // While Sioul's own GnuPG works with a key, nested or not: no look; then again.
1021        assert!(may_look());
1022        let first = OnCard::start();
1023        let second = OnCard::start();
1024        assert!(!may_look());
1025        drop(first);
1026        assert!(!may_look());
1027        drop(second);
1028        assert!(may_look());
1029    }
1030
1031    /// A server on this computer answering each POST with the next of
1032    /// `answers`, and telling which path and body each was.
1033    fn stand_in_posts(answers: Vec<(u16, &'static str)>) -> (String, std::sync::mpsc::Receiver<(String, String)>) {
1034        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1035        let address = format!("http://{}", listener.local_addr().unwrap());
1036        let (tell, heard) = std::sync::mpsc::channel();
1037        std::thread::spawn(move || {
1038            for (status, body) in answers {
1039                let Ok((stream, _)) = listener.accept() else { return };
1040                let mut reader = BufReader::new(stream.try_clone().unwrap());
1041                let mut line = String::new();
1042                reader.read_line(&mut line).unwrap();
1043                let mut length = 0;
1044                loop {
1045                    let mut header = String::new();
1046                    reader.read_line(&mut header).unwrap();
1047                    if header.trim_end().is_empty() {
1048                        break;
1049                    }
1050                    if let Some((name, value)) = header.split_once(':')
1051                        && name.eq_ignore_ascii_case("content-length")
1052                    {
1053                        length = value.trim().parse().unwrap_or(0);
1054                    }
1055                }
1056                let mut sent = vec![0u8; length];
1057                std::io::Read::read_exact(&mut reader, &mut sent).unwrap();
1058                let _ = tell.send((line.split_whitespace().nth(1).unwrap_or("").to_string(), String::from_utf8_lossy(&sent).to_string()));
1059                let mut stream = stream;
1060                let _ = stream.write_all(format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes());
1061            }
1062        });
1063        (address, heard)
1064    }
1065
1066    /// "Send it to keys.openpgp.org", against a stand-in server: the
1067    /// certificate sent, then a link asked for each address not published
1068    /// yet, in your language; a refusal said with the server's words.
1069    #[test]
1070    fn keys_openpgp_gets_the_certificate_and_mails_each_address() {
1071        let (_, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>", "Card Holder <old@example.org>"], "123456").unwrap();
1072        let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).http_status_as_error(false).build().into();
1073        let (base, heard) = stand_in_posts(vec![
1074            (200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"unpublished","old@example.org":"published"},"token":"t0k3n"}"#),
1075            (200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"pending","old@example.org":"published"},"token":"t0k3n"}"#),
1076        ]);
1077        let published = send_at(&agent, &base, &public, "fr").unwrap();
1078        assert_eq!(published, Published { fingerprint: "ABCD".into(), mailed: vec!["me@example.org".into()], published: vec!["old@example.org".into()] });
1079        let (path, body) = heard.recv().unwrap();
1080        assert_eq!(path, "/vks/v1/upload");
1081        let sent: serde_json::Value = serde_json::from_str(&body).unwrap();
1082        assert_eq!(sent["keytext"].as_str().unwrap().as_bytes(), &public[..], "the public certificate, as it is");
1083        let (path, body) = heard.recv().unwrap();
1084        assert_eq!(path, "/vks/v1/request-verify");
1085        assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap(), serde_json::json!({ "token": "t0k3n", "addresses": ["me@example.org"], "locale": ["fr"] }));
1086        // Every address published already: nothing asked.
1087        let (base, heard) = stand_in_posts(vec![(200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"published"},"token":"t"}"#)]);
1088        assert_eq!(send_at(&agent, &base, &public, "en").unwrap().mailed, Vec::<String>::new());
1089        assert_eq!(heard.try_iter().count(), 1);
1090        // Refused: the server's words.
1091        let (base, _heard) = stand_in_posts(vec![(400, r#"{"error":"Rate limit exceeded"}"#)]);
1092        assert!(matches!(send_at(&agent, &base, &public, "en"), Err(SyncError::Server(said)) if said.contains("Rate limit exceeded")));
1093        // Never in clear: the agent Sioul sends with refuses http.
1094        let https: ureq::Agent = ureq::Agent::config_builder().https_only(true).timeout_global(Some(Duration::from_secs(5))).build().into();
1095        let (base, _heard) = stand_in_posts(vec![]);
1096        assert!(send_at(&https, &base, &public, "en").is_err());
1097    }
1098}