Skip to main content

sioul_sync/securitykey/
smartcard.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Security keys through the system's smart card service, PC/SC: pcscd
5//! (pcsc-lite) on Linux, the Smart Card service on Windows, CryptoTokenKit on
6//! macOS. The OpenPGP card protocol is `openpgp-card` 0.7's, its transport
7//! `card-backend-pcsc` 0.5's (both Heiko Schaefer's, MIT OR Apache-2.0).
8//!
9//! - **Shared, one operation at a time.** Each reader is connected in shared
10//!   mode, so that GnuPG may share the key when its `scdaemon.conf` says
11//!   `pcsc-shared`; one transaction serves one operation, and the key is then
12//!   disconnected, which resets it (`pcsc`'s own `Drop` disposes with
13//!   `ResetCard`): its PIN, once given, does not stay given for another
14//!   program.
15//! - **Readers one by one.** `card-backend-pcsc` skips, silently, a reader
16//!   it cannot connect to: a key that GnuPG holds for itself would read as no
17//!   key. Sioul connects each reader itself, and counts a sharing violation
18//!   as a busy key, which the window says, with "Let GnuPG release it".
19//! - **The card itself** (its reading, its PIN, its signing and opening,
20//!   its status words in words, nothing logged): [`super::ocard`], shared
21//!   with the phone.
22
23use super::ocard::OpenCard;
24use card_backend_pcsc::PcscBackend;
25use openpgp_card::state::Open;
26use sioul_core::securitykey::{Card, CardError, Readers, Visit, Visited};
27use std::ffi::CString;
28use std::time::Duration;
29
30/// The system's readers, through PC/SC.
31pub struct PcscReaders;
32
33/// The smart card service, reached; else why not.
34fn establish() -> Result<pcsc::Context, CardError> {
35    pcsc::Context::establish(pcsc::Scope::User).map_err(|e| match e {
36        pcsc::Error::NoService | pcsc::Error::ServiceStopped | pcsc::Error::NoReadersAvailable => CardError::NoService,
37        other => CardError::Other(other.to_string()),
38    })
39}
40
41/// The readers the service knows: none is no error.
42fn readers_of(context: &pcsc::Context) -> Result<Vec<CString>, CardError> {
43    match context.list_readers_owned() {
44        Ok(readers) => Ok(readers),
45        Err(pcsc::Error::NoReadersAvailable) => Ok(Vec::new()),
46        Err(pcsc::Error::NoService | pcsc::Error::ServiceStopped) => Err(CardError::NoService),
47        Err(other) => Err(CardError::Other(other.to_string())),
48    }
49}
50
51impl Readers for PcscReaders {
52    fn present(&self) -> Result<Vec<String>, CardError> {
53        let context = establish()?;
54        let mut states: Vec<pcsc::ReaderState> = readers_of(&context)?.into_iter().map(|name| pcsc::ReaderState::new(name, pcsc::State::UNAWARE)).collect();
55        if states.is_empty() {
56            return Ok(Vec::new());
57        }
58        // Unaware of every state: the service says each at once, without opening any key.
59        match context.get_status_change(Duration::ZERO, &mut states) {
60            Ok(()) | Err(pcsc::Error::Timeout) => {}
61            Err(pcsc::Error::NoService | pcsc::Error::ServiceStopped) => return Err(CardError::NoService),
62            Err(other) => return Err(CardError::Other(other.to_string())),
63        }
64        Ok(states.iter().filter(|s| s.event_state().contains(pcsc::State::PRESENT)).map(|s| s.name().to_string_lossy().into_owned()).collect())
65    }
66
67    fn visit(&self, visit: &mut dyn FnMut(&mut dyn Card) -> Visit) -> Result<Visited, CardError> {
68        let context = establish()?;
69        let mut visited = Visited::default();
70        for reader in readers_of(&context)? {
71            let connected = match context.connect(&reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY) {
72                Ok(card) => card,
73                // Another program holds it for itself: GnuPG's scdaemon, most often.
74                Err(pcsc::Error::SharingViolation) => {
75                    visited.busy += 1;
76                    continue;
77                }
78                // An empty reader, or one gone meanwhile.
79                Err(_) => continue,
80            };
81            let Ok(backend) = PcscBackend::from_pcsc_card(&reader, pcsc::ShareMode::Shared, connected) else { continue };
82            // Not an OpenPGP card (another kind of card in the reader): left alone.
83            let Ok(mut opened) = openpgp_card::Card::<Open>::new(backend) else { continue };
84            let Ok(transaction) = opened.transaction() else { continue };
85            let mut card = OpenCard::new(transaction, reader.to_string_lossy().into_owned());
86            visited.opened += 1;
87            let next = visit(&mut card);
88            // Closed: the transaction ends, then the connection, which resets the key.
89            drop(card);
90            drop(opened);
91            if next == Visit::Done {
92                break;
93            }
94        }
95        Ok(visited)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    /// Reads the security keys plugged in, without their PIN: nothing is signed,
104    /// nothing is opened, no PIN is given, so no try is spent. Never in a test
105    /// run: the owner runs it himself, his key plugged in:
106    /// `cargo test --release -p sioul-sync -- --ignored --nocapture a_real_key_reads_without_its_pin`.
107    #[test]
108    #[ignore = "reads a real security key: the owner runs it himself, his key plugged in"]
109    fn a_real_key_reads_without_its_pin() {
110        let tr = sioul_core::i18n::Translator::new("en");
111        match sioul_core::securitykey::cards(&PcscReaders) {
112            Ok(cards) => {
113                for card in cards {
114                    println!("{} ({}), holder {:?}, reader {:?}", card.label(&tr), card.ident, card.holder, card.reader);
115                    for (what, slot) in [("signs", &card.sign), ("decrypts", &card.decrypt)] {
116                        if let Some(slot) = slot {
117                            println!("  {what}: {} {}, made {}, touch {:?}, public part read: {}", slot.algorithm, slot.fingerprint, slot.created, slot.touch, slot.public.is_some());
118                        }
119                    }
120                    println!("  PIN tries {}, reset code {}, Admin PIN {}; PIN for each signature: {}; signatures made {}; URL {:?}", card.pin_tries, card.reset_tries, card.admin_tries, card.pin_each_signature, card.signatures, card.url);
121                }
122            }
123            Err(e) => println!("{}", e.sentence(&tr, sioul_core::securitykey::Purpose::Read)),
124        }
125    }
126}