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//! - **Status words in words** ([`CardError`]): 63Cx a wrong PIN with x tries
20//!   left, 6983 a locked PIN, 6600 (the specification's "security-related
21//!   issues", kept for the touch button) a touch missed, and so is 6982 just
22//!   after the PIN was taken, when the key asks for a touch; a card taken out
23//!   while it worked, the key removed.
24//! - **Nothing is logged here.** `openpgp-card` writes whole APDUs at the
25//!   "trace" level, a VERIFY's PIN included, and the card's answers (session
26//!   keys): Sioul installs no logger, and caps the level at "info" as each
27//!   program starts ([`super::cap_logging`]).
28
29use card_backend::SmartcardError;
30use card_backend_pcsc::PcscBackend;
31use openpgp_card::ocard::algorithm::{AlgorithmAttributes, Curve};
32use openpgp_card::ocard::crypto::{Cryptogram, HashAlgo, PublicKeyMaterial, SigningAlgo};
33use openpgp_card::ocard::data::UserInteractionFlag;
34use openpgp_card::ocard::{KeyType, StatusBytes};
35use openpgp_card::state::{Open, Transaction};
36use sioul_core::securitykey::{Card, CardError, CardInfo, CardPublic, Password, PinMode, Protected, Readers, SlotInfo, ToDecipher, ToSign, Touch, Visit, Visited};
37use std::ffi::CString;
38use std::time::Duration;
39
40/// The system's readers, through PC/SC.
41pub struct PcscReaders;
42
43/// The smart card service, reached; else why not.
44fn establish() -> Result<pcsc::Context, CardError> {
45    pcsc::Context::establish(pcsc::Scope::User).map_err(|e| match e {
46        pcsc::Error::NoService | pcsc::Error::ServiceStopped | pcsc::Error::NoReadersAvailable => CardError::NoService,
47        other => CardError::Other(other.to_string()),
48    })
49}
50
51/// The readers the service knows: none is no error.
52fn readers_of(context: &pcsc::Context) -> Result<Vec<CString>, CardError> {
53    match context.list_readers_owned() {
54        Ok(readers) => Ok(readers),
55        Err(pcsc::Error::NoReadersAvailable) => Ok(Vec::new()),
56        Err(pcsc::Error::NoService | pcsc::Error::ServiceStopped) => Err(CardError::NoService),
57        Err(other) => Err(CardError::Other(other.to_string())),
58    }
59}
60
61impl Readers for PcscReaders {
62    fn present(&self) -> Result<Vec<String>, CardError> {
63        let context = establish()?;
64        let mut states: Vec<pcsc::ReaderState> = readers_of(&context)?.into_iter().map(|name| pcsc::ReaderState::new(name, pcsc::State::UNAWARE)).collect();
65        if states.is_empty() {
66            return Ok(Vec::new());
67        }
68        // Unaware of every state: the service says each at once, without opening any key.
69        match context.get_status_change(Duration::ZERO, &mut states) {
70            Ok(()) | Err(pcsc::Error::Timeout) => {}
71            Err(pcsc::Error::NoService | pcsc::Error::ServiceStopped) => return Err(CardError::NoService),
72            Err(other) => return Err(CardError::Other(other.to_string())),
73        }
74        Ok(states.iter().filter(|s| s.event_state().contains(pcsc::State::PRESENT)).map(|s| s.name().to_string_lossy().into_owned()).collect())
75    }
76
77    fn visit(&self, visit: &mut dyn FnMut(&mut dyn Card) -> Visit) -> Result<Visited, CardError> {
78        let context = establish()?;
79        let mut visited = Visited::default();
80        for reader in readers_of(&context)? {
81            let connected = match context.connect(&reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY) {
82                Ok(card) => card,
83                // Another program holds it for itself: GnuPG's scdaemon, most often.
84                Err(pcsc::Error::SharingViolation) => {
85                    visited.busy += 1;
86                    continue;
87                }
88                // An empty reader, or one gone meanwhile.
89                Err(_) => continue,
90            };
91            let Ok(backend) = PcscBackend::from_pcsc_card(&reader, pcsc::ShareMode::Shared, connected) else { continue };
92            // Not an OpenPGP card (another kind of card in the reader): left alone.
93            let Ok(mut opened) = openpgp_card::Card::<Open>::new(backend) else { continue };
94            let Ok(transaction) = opened.transaction() else { continue };
95            let mut card = PcscCard { tx: transaction, reader: reader.to_string_lossy().into_owned(), pin_taken: false, touch: (false, false) };
96            visited.opened += 1;
97            let next = visit(&mut card);
98            // Closed: the transaction ends, then the connection, which resets the key.
99            drop(card);
100            drop(opened);
101            if next == Visit::Done {
102                break;
103            }
104        }
105        Ok(visited)
106    }
107}
108
109/// What a key's user interaction flag says of its touch (DO D6 for signing,
110/// D7 for decrypting, D8 for authenticating: its first byte, the touch
111/// policy). A card without the flag asks for none; a flag that does not read
112/// is a touch maybe asked: said "If your key asks for a touch…", a wrong guess
113/// costing nothing.
114fn touch_of(flag: Result<Option<UserInteractionFlag>, openpgp_card::Error>) -> Touch {
115    match flag {
116        Ok(Some(flag)) => Touch::from_flag(u8::from(flag.touch_policy())),
117        Ok(None) => Touch::Off,
118        Err(_) => Touch::Unknown,
119    }
120}
121
122/// One OpenPGP card, open in one transaction.
123struct PcscCard<'a> {
124    tx: openpgp_card::Card<Transaction<'a>>,
125    reader: String,
126    /// The PIN was taken in this transaction: a refusal after it is the touch's.
127    pin_taken: bool,
128    /// Whether the signing key and the decryption key ask for a touch.
129    touch: (bool, bool),
130}
131
132impl PcscCard<'_> {
133    /// One key slot, as the card describes it; None when it holds no key.
134    fn slot(&mut self, key: KeyType, fingerprint: Option<String>, created: u32) -> Option<SlotInfo> {
135        let fingerprint = fingerprint?;
136        let algorithm = self.tx.algorithm_attributes(key).map(|a| algorithm_name(&a)).unwrap_or_default();
137        let touch = touch_of(self.tx.user_interaction_flag(key));
138        let public = match self.tx.public_key_material(key) {
139            Ok(PublicKeyMaterial::R(rsa)) => Some(CardPublic::Rsa { n: rsa.n().to_vec(), e: rsa.v().to_vec() }),
140            Ok(PublicKeyMaterial::E(ecc)) => Some(CardPublic::Ecc { point: ecc.data().to_vec() }),
141            Err(_) => None,
142        };
143        Some(SlotInfo { fingerprint, created, algorithm, touch, public })
144    }
145
146    fn failed(&self, error: openpgp_card::Error, touch: bool) -> CardError {
147        card_error(error, self.pin_taken && touch)
148    }
149}
150
151impl Card for PcscCard<'_> {
152    fn info(&mut self) -> Result<CardInfo, CardError> {
153        let aid = self.tx.application_identifier().map_err(|e| card_error(e, false))?;
154        let fingerprints = self.tx.fingerprints().map_err(|e| card_error(e, false))?;
155        let times = self.tx.key_generation_times().ok();
156        let status = self.tx.pw_status_bytes().map_err(|e| card_error(e, false))?;
157        let created = |time: Option<&openpgp_card::ocard::data::KeyGenerationTime>| time.map_or(0, u32::from);
158        let (sign_time, decrypt_time) = times.as_ref().map_or((0, 0), |t| (created(t.signature()), created(t.decryption())));
159        let sign = self.slot(KeyType::Signing, fingerprints.signature().map(|f| f.to_hex().to_ascii_uppercase()), sign_time);
160        let decrypt = self.slot(KeyType::Decryption, fingerprints.decryption().map(|f| f.to_hex().to_ascii_uppercase()), decrypt_time);
161        self.touch = (sign.as_ref().is_some_and(|s| s.touch.asked()), decrypt.as_ref().is_some_and(|s| s.touch.asked()));
162        Ok(CardInfo {
163            ident: aid.ident(),
164            manufacturer: aid.manufacturer(),
165            serial: aid.serial(),
166            holder: self.tx.cardholder_name().unwrap_or_default(),
167            url: self.tx.url().unwrap_or_default(),
168            sign,
169            decrypt,
170            pin_tries: status.err_count_pw1(),
171            reset_tries: status.err_count_rc(),
172            admin_tries: status.err_count_pw3(),
173            pin_each_signature: status.pw1_cds_valid_once(),
174            signatures: self.tx.digital_signature_count().unwrap_or(0),
175            reader: self.reader.clone(),
176        })
177    }
178
179    fn verify(&mut self, pin: &Password, mode: PinMode) -> Result<(), CardError> {
180        // The PIN as the protocol takes it: a SecretString, wiped when dropped.
181        let pin = pin.map(|p| std::str::from_utf8(p).map(secrecy::SecretString::from)).map_err(|_| CardError::Other("the PIN is not text".into()))?;
182        let verified = match mode {
183            PinMode::Sign => self.tx.verify_user_signing_pin(pin),
184            PinMode::Decrypt => self.tx.verify_user_pin(pin),
185        };
186        verified.map_err(|e| card_error(e, false))?;
187        self.pin_taken = true;
188        Ok(())
189    }
190
191    fn sign(&mut self, input: ToSign<'_>) -> Result<Vec<u8>, CardError> {
192        let (algorithm, digest) = match input {
193            ToSign::Digest(digest) => (SigningAlgo::ECC, digest),
194            ToSign::Rsa { hash, digest } => {
195                let hash = match hash {
196                    sioul_core::securitykey::HashAlgorithm::SHA256 => HashAlgo::SHA256,
197                    sioul_core::securitykey::HashAlgorithm::SHA384 => HashAlgo::SHA384,
198                    sioul_core::securitykey::HashAlgorithm::SHA512 => HashAlgo::SHA512,
199                    other => return Err(CardError::Algorithm(format!("RSA, {other}"))),
200                };
201                (SigningAlgo::RSA(hash), digest)
202            }
203        };
204        let touch = self.touch.0;
205        self.tx.card().signature_for_hash(algorithm, digest).map_err(|e| self.failed(e, touch))
206    }
207
208    fn decipher(&mut self, input: ToDecipher<'_>) -> Result<Protected, CardError> {
209        let cryptogram = match input {
210            ToDecipher::Rsa(ciphertext) => Cryptogram::RSA(ciphertext),
211            ToDecipher::Ecdh(point) => Cryptogram::ECDH(point),
212        };
213        let touch = self.touch.1;
214        // What the key gives back (a session key, a shared secret) goes into protected memory at once.
215        self.tx.card().decipher(cryptogram).map(Protected::from).map_err(|e| self.failed(e, touch))
216    }
217}
218
219/// "Ed25519", "Curve25519", "RSA 4096", "NIST P-256": as GnuPG and YubiKey Manager name them.
220fn algorithm_name(attributes: &AlgorithmAttributes) -> String {
221    match attributes {
222        AlgorithmAttributes::Rsa(rsa) => format!("RSA {}", rsa.len_n()),
223        AlgorithmAttributes::Ecc(ecc) => match ecc.curve() {
224            Curve::Ed25519 => "Ed25519".into(),
225            Curve::Curve25519 => "Curve25519".into(),
226            Curve::NistP256r1 => "NIST P-256".into(),
227            Curve::NistP384r1 => "NIST P-384".into(),
228            Curve::NistP521r1 => "NIST P-521".into(),
229            Curve::BrainpoolP256r1 => "brainpoolP256r1".into(),
230            Curve::BrainpoolP384r1 => "brainpoolP384r1".into(),
231            Curve::BrainpoolP512r1 => "brainpoolP512r1".into(),
232            Curve::Secp256k1 => "secp256k1".into(),
233            Curve::Ed448 => "Ed448".into(),
234            Curve::X448 => "X448".into(),
235            Curve::Unknown(_) => String::new(),
236        },
237        AlgorithmAttributes::Unknown(_) => String::new(),
238    }
239}
240
241/// What the card or the reader said, as Sioul says it. `touch_after_pin`: the
242/// key took the PIN in this transaction and asks for a touch, so that its
243/// refusal now is the touch's.
244fn card_error(error: openpgp_card::Error, touch_after_pin: bool) -> CardError {
245    match error {
246        openpgp_card::Error::CardStatus(StatusBytes::PasswordNotChecked(left)) => CardError::WrongPin { left },
247        openpgp_card::Error::CardStatus(StatusBytes::AuthenticationMethodBlocked) => CardError::Blocked,
248        openpgp_card::Error::CardStatus(StatusBytes::SecurityRelatedIssues) => CardError::TouchMissed,
249        openpgp_card::Error::CardStatus(StatusBytes::SecurityStatusNotSatisfied) if touch_after_pin => CardError::TouchMissed,
250        openpgp_card::Error::Smartcard(SmartcardError::Error(said)) if ["RemovedCard", "NoSmartcard", "ReaderUnavailable", "UnknownReader"].iter().any(|w| said.contains(w)) => CardError::Removed,
251        openpgp_card::Error::Smartcard(SmartcardError::ContextError(_)) => CardError::NoService,
252        other => CardError::Other(other.to_string()),
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn the_cards_words_become_sioul_s() {
262        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::PasswordNotChecked(2)), false), CardError::WrongPin { left: 2 });
263        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::PasswordNotChecked(0)), false), CardError::WrongPin { left: 0 });
264        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::AuthenticationMethodBlocked), false), CardError::Blocked);
265        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::SecurityRelatedIssues), false), CardError::TouchMissed);
266        // 6982 is a touch missed only just after the PIN was taken, for a key that asks for one.
267        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::SecurityStatusNotSatisfied), true), CardError::TouchMissed);
268        assert!(matches!(card_error(openpgp_card::Error::CardStatus(StatusBytes::SecurityStatusNotSatisfied), false), CardError::Other(_)));
269        assert_eq!(card_error(openpgp_card::Error::Smartcard(SmartcardError::Error("Transmit failed: RemovedCard".into())), false), CardError::Removed);
270        assert_eq!(card_error(openpgp_card::Error::Smartcard(SmartcardError::ContextError("no service".into())), false), CardError::NoService);
271    }
272
273    /// Reads the security keys plugged in, without their PIN: nothing is signed,
274    /// nothing is opened, no PIN is given, so no try is spent. Never in a test
275    /// run: the owner runs it himself, his key plugged in:
276    /// `cargo test --release -p sioul-sync -- --ignored --nocapture a_real_key_reads_without_its_pin`.
277    /// The touch settings, from user interaction flags made of invented bytes
278    /// (policy, then features: 0x20 a button), as a card's DOs D6 to D8 give
279    /// them; never a real card.
280    #[test]
281    fn the_touch_settings_read_from_the_flags() {
282        let flag = |bytes: &[u8]| UserInteractionFlag::try_from(bytes.to_vec());
283        assert_eq!(touch_of(flag(&[0x00, 0x20]).map(Some)), Touch::Off);
284        assert_eq!(touch_of(flag(&[0x01, 0x20]).map(Some)), Touch::On, "a YubiKey with touch on: UIF Sign=on");
285        assert_eq!(touch_of(flag(&[0x02, 0x20]).map(Some)), Touch::Fixed);
286        assert_eq!(touch_of(flag(&[0x03, 0x20]).map(Some)), Touch::Cached);
287        assert_eq!(touch_of(flag(&[0x04, 0x20]).map(Some)), Touch::CachedFixed);
288        assert_eq!(touch_of(flag(&[0x7f, 0x20]).map(Some)), Touch::Unknown, "a setting Sioul does not know: asked");
289        // Bytes that are no flag: not read, so said as maybe.
290        assert_eq!(touch_of(flag(&[0x01]).map(Some)), Touch::Unknown);
291        // A card without the flag (before version 3): no touch.
292        assert_eq!(touch_of(Ok(None)), Touch::Off);
293        let tr = sioul_core::i18n::Translator::new("en");
294        assert!(touch_of(flag(&[0x01, 0x20]).map(Some)).words(&tr).is_some_and(|w| w.contains("hold your finger") && w.contains("15 seconds")));
295        assert!(touch_of(flag(&[0x01]).map(Some)).words(&tr).is_some_and(|w| w.starts_with("If your key asks for a touch")));
296        assert_eq!(touch_of(Ok(None)).words(&tr), None);
297    }
298
299    #[test]
300    #[ignore = "reads a real security key: the owner runs it himself, his key plugged in"]
301    fn a_real_key_reads_without_its_pin() {
302        let tr = sioul_core::i18n::Translator::new("en");
303        match sioul_core::securitykey::cards(&PcscReaders) {
304            Ok(cards) => {
305                for card in cards {
306                    println!("{} ({}), holder {:?}, reader {:?}", card.label(&tr), card.ident, card.holder, card.reader);
307                    for (what, slot) in [("signs", &card.sign), ("decrypts", &card.decrypt)] {
308                        if let Some(slot) = slot {
309                            println!("  {what}: {} {}, made {}, touch {:?}, public part read: {}", slot.algorithm, slot.fingerprint, slot.created, slot.touch, slot.public.is_some());
310                        }
311                    }
312                    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);
313                }
314            }
315            Err(e) => println!("{}", e.sentence(&tr, sioul_core::securitykey::Purpose::Read)),
316        }
317    }
318}