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