Skip to main content

sioul_sync/securitykey/
ocard.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! An OpenPGP card open in one transaction, as `openpgp-card` 0.7 speaks to
5//! it (Heiko Schaefer's, MIT OR Apache-2.0), whatever carries its APDUs: the
6//! system's smart card service on a computer ([`super::smartcard`]), NFC on a
7//! phone ([`super::phone`]). One reading of what the key says, one mapping of
8//! its status words, one signing and opening path on every device.
9//!
10//! - **Status words in words** ([`CardError`]): 63Cx a wrong PIN with x tries
11//!   left, 6983 a locked PIN, 6600 (the specification's "security-related
12//!   issues", kept for the touch button) a touch missed, and so is 6982 just
13//!   after the PIN was taken, when the key asks for a touch; a card taken out
14//!   while it worked (a reader's "RemovedCard", a phone's key moved away), the
15//!   key removed.
16//! - **Nothing is logged here.** `openpgp-card` writes whole APDUs at the
17//!   "trace" level, a VERIFY's PIN included, and the card's answers (session
18//!   keys): Sioul installs no logger, and caps the level at "info" as each
19//!   program starts ([`super::cap_logging`]).
20
21use card_backend::SmartcardError;
22use openpgp_card::ocard::algorithm::{AlgorithmAttributes, Curve};
23use openpgp_card::ocard::crypto::{Cryptogram, HashAlgo, PublicKeyMaterial, SigningAlgo};
24use openpgp_card::ocard::data::UserInteractionFlag;
25use openpgp_card::ocard::{KeyType, StatusBytes};
26use openpgp_card::state::Transaction;
27use sioul_core::securitykey::{Card, CardError, CardInfo, CardPublic, Password, PinMode, Protected, SlotInfo, ToDecipher, ToSign, Touch};
28
29/// What a key's user interaction flag says of its touch (DO D6 for signing,
30/// D7 for decrypting, D8 for authenticating: its first byte, the touch
31/// policy). A card without the flag asks for none; a flag that does not read
32/// is a touch maybe asked: said "If your key asks for a touch…", a wrong guess
33/// costing nothing.
34fn touch_of(flag: Result<Option<UserInteractionFlag>, openpgp_card::Error>) -> Touch {
35    match flag {
36        Ok(Some(flag)) => Touch::from_flag(u8::from(flag.touch_policy())),
37        Ok(None) => Touch::Off,
38        Err(_) => Touch::Unknown,
39    }
40}
41
42/// One OpenPGP card, open in one transaction, whatever carries it: PC/SC on
43/// a computer, NFC on a phone.
44pub(crate) struct OpenCard<'a> {
45    tx: openpgp_card::Card<Transaction<'a>>,
46    reader: String,
47    /// The PIN was taken in this transaction: a refusal after it is the touch's.
48    pin_taken: bool,
49    /// Whether the signing key and the decryption key ask for a touch.
50    touch: (bool, bool),
51}
52
53impl<'a> OpenCard<'a> {
54    /// The card open in `tx`, in the reader named `reader` ("" for a key held to a phone).
55    pub(crate) fn new(tx: openpgp_card::Card<Transaction<'a>>, reader: String) -> OpenCard<'a> {
56        OpenCard { tx, reader, pin_taken: false, touch: (false, false) }
57    }
58
59    /// One key slot, as the card describes it; None when it holds no key.
60    fn slot(&mut self, key: KeyType, fingerprint: Option<String>, created: u32) -> Option<SlotInfo> {
61        let fingerprint = fingerprint?;
62        let algorithm = self.tx.algorithm_attributes(key).map(|a| algorithm_name(&a)).unwrap_or_default();
63        let touch = touch_of(self.tx.user_interaction_flag(key));
64        let public = match self.tx.public_key_material(key) {
65            Ok(PublicKeyMaterial::R(rsa)) => Some(CardPublic::Rsa { n: rsa.n().to_vec(), e: rsa.v().to_vec() }),
66            Ok(PublicKeyMaterial::E(ecc)) => Some(CardPublic::Ecc { point: ecc.data().to_vec() }),
67            Err(_) => None,
68        };
69        Some(SlotInfo { fingerprint, created, algorithm, touch, public })
70    }
71
72    fn failed(&self, error: openpgp_card::Error, touch: bool) -> CardError {
73        card_error(error, self.pin_taken && touch)
74    }
75}
76
77impl Card for OpenCard<'_> {
78    fn info(&mut self) -> Result<CardInfo, CardError> {
79        let aid = self.tx.application_identifier().map_err(|e| card_error(e, false))?;
80        let fingerprints = self.tx.fingerprints().map_err(|e| card_error(e, false))?;
81        let times = self.tx.key_generation_times().ok();
82        let status = self.tx.pw_status_bytes().map_err(|e| card_error(e, false))?;
83        let created = |time: Option<&openpgp_card::ocard::data::KeyGenerationTime>| time.map_or(0, u32::from);
84        let (sign_time, decrypt_time) = times.as_ref().map_or((0, 0), |t| (created(t.signature()), created(t.decryption())));
85        let sign = self.slot(KeyType::Signing, fingerprints.signature().map(|f| f.to_hex().to_ascii_uppercase()), sign_time);
86        let decrypt = self.slot(KeyType::Decryption, fingerprints.decryption().map(|f| f.to_hex().to_ascii_uppercase()), decrypt_time);
87        self.touch = (sign.as_ref().is_some_and(|s| s.touch.asked()), decrypt.as_ref().is_some_and(|s| s.touch.asked()));
88        Ok(CardInfo {
89            ident: aid.ident(),
90            manufacturer: aid.manufacturer(),
91            serial: aid.serial(),
92            holder: self.tx.cardholder_name().unwrap_or_default(),
93            url: self.tx.url().unwrap_or_default(),
94            sign,
95            decrypt,
96            pin_tries: status.err_count_pw1(),
97            reset_tries: status.err_count_rc(),
98            admin_tries: status.err_count_pw3(),
99            pin_each_signature: status.pw1_cds_valid_once(),
100            signatures: self.tx.digital_signature_count().unwrap_or(0),
101            reader: self.reader.clone(),
102        })
103    }
104
105    fn verify(&mut self, pin: &Password, mode: PinMode) -> Result<(), CardError> {
106        // The PIN as the protocol takes it: a SecretString, wiped when dropped.
107        let pin = pin.map(|p| std::str::from_utf8(p).map(secrecy::SecretString::from)).map_err(|_| CardError::Other("the PIN is not text".into()))?;
108        let verified = match mode {
109            PinMode::Sign => self.tx.verify_user_signing_pin(pin),
110            PinMode::Decrypt => self.tx.verify_user_pin(pin),
111        };
112        verified.map_err(|e| card_error(e, false))?;
113        self.pin_taken = true;
114        Ok(())
115    }
116
117    fn sign(&mut self, input: ToSign<'_>) -> Result<Vec<u8>, CardError> {
118        let (algorithm, digest) = match input {
119            ToSign::Digest(digest) => (SigningAlgo::ECC, digest),
120            ToSign::Rsa { hash, digest } => {
121                let hash = match hash {
122                    sioul_core::securitykey::HashAlgorithm::SHA256 => HashAlgo::SHA256,
123                    sioul_core::securitykey::HashAlgorithm::SHA384 => HashAlgo::SHA384,
124                    sioul_core::securitykey::HashAlgorithm::SHA512 => HashAlgo::SHA512,
125                    other => return Err(CardError::Algorithm(format!("RSA, {other}"))),
126                };
127                (SigningAlgo::RSA(hash), digest)
128            }
129        };
130        let touch = self.touch.0;
131        self.tx.card().signature_for_hash(algorithm, digest).map_err(|e| self.failed(e, touch))
132    }
133
134    fn decipher(&mut self, input: ToDecipher<'_>) -> Result<Protected, CardError> {
135        let cryptogram = match input {
136            ToDecipher::Rsa(ciphertext) => Cryptogram::RSA(ciphertext),
137            ToDecipher::Ecdh(point) => Cryptogram::ECDH(point),
138        };
139        let touch = self.touch.1;
140        // What the key gives back (a session key, a shared secret) goes into protected memory at once.
141        self.tx.card().decipher(cryptogram).map(Protected::from).map_err(|e| self.failed(e, touch))
142    }
143}
144
145/// "Ed25519", "Curve25519", "RSA 4096", "NIST P-256": as GnuPG and YubiKey Manager name them.
146fn algorithm_name(attributes: &AlgorithmAttributes) -> String {
147    match attributes {
148        AlgorithmAttributes::Rsa(rsa) => format!("RSA {}", rsa.len_n()),
149        AlgorithmAttributes::Ecc(ecc) => match ecc.curve() {
150            Curve::Ed25519 => "Ed25519".into(),
151            Curve::Curve25519 => "Curve25519".into(),
152            Curve::NistP256r1 => "NIST P-256".into(),
153            Curve::NistP384r1 => "NIST P-384".into(),
154            Curve::NistP521r1 => "NIST P-521".into(),
155            Curve::BrainpoolP256r1 => "brainpoolP256r1".into(),
156            Curve::BrainpoolP384r1 => "brainpoolP384r1".into(),
157            Curve::BrainpoolP512r1 => "brainpoolP512r1".into(),
158            Curve::Secp256k1 => "secp256k1".into(),
159            Curve::Ed448 => "Ed448".into(),
160            Curve::X448 => "X448".into(),
161            Curve::Unknown(_) => String::new(),
162        },
163        AlgorithmAttributes::Unknown(_) => String::new(),
164    }
165}
166
167/// What the card or the reader said, as Sioul says it. `touch_after_pin`: the
168/// key took the PIN in this transaction and asks for a touch, so that its
169/// refusal now is the touch's.
170pub(crate) fn card_error(error: openpgp_card::Error, touch_after_pin: bool) -> CardError {
171    match error {
172        openpgp_card::Error::CardStatus(StatusBytes::PasswordNotChecked(left)) => CardError::WrongPin { left },
173        openpgp_card::Error::CardStatus(StatusBytes::AuthenticationMethodBlocked) => CardError::Blocked,
174        openpgp_card::Error::CardStatus(StatusBytes::SecurityRelatedIssues) => CardError::TouchMissed,
175        openpgp_card::Error::CardStatus(StatusBytes::SecurityStatusNotSatisfied) if touch_after_pin => CardError::TouchMissed,
176        openpgp_card::Error::Smartcard(SmartcardError::Error(said)) if ["RemovedCard", "NoSmartcard", "ReaderUnavailable", "UnknownReader"].iter().any(|w| said.contains(w)) => CardError::Removed,
177        openpgp_card::Error::Smartcard(SmartcardError::ContextError(_)) => CardError::NoService,
178        other => CardError::Other(other.to_string()),
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn the_cards_words_become_sioul_s() {
188        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::PasswordNotChecked(2)), false), CardError::WrongPin { left: 2 });
189        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::PasswordNotChecked(0)), false), CardError::WrongPin { left: 0 });
190        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::AuthenticationMethodBlocked), false), CardError::Blocked);
191        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::SecurityRelatedIssues), false), CardError::TouchMissed);
192        // 6982 is a touch missed only just after the PIN was taken, for a key that asks for one.
193        assert_eq!(card_error(openpgp_card::Error::CardStatus(StatusBytes::SecurityStatusNotSatisfied), true), CardError::TouchMissed);
194        assert!(matches!(card_error(openpgp_card::Error::CardStatus(StatusBytes::SecurityStatusNotSatisfied), false), CardError::Other(_)));
195        assert_eq!(card_error(openpgp_card::Error::Smartcard(SmartcardError::Error("Transmit failed: RemovedCard".into())), false), CardError::Removed);
196        assert_eq!(card_error(openpgp_card::Error::Smartcard(SmartcardError::ContextError("no service".into())), false), CardError::NoService);
197    }
198
199    /// Reads the security keys plugged in, without their PIN: nothing is signed,
200    /// nothing is opened, no PIN is given, so no try is spent. Never in a test
201    /// run: the owner runs it himself, his key plugged in:
202    /// `cargo test --release -p sioul-sync -- --ignored --nocapture a_real_key_reads_without_its_pin`.
203    /// The touch settings, from user interaction flags made of invented bytes
204    /// (policy, then features: 0x20 a button), as a card's DOs D6 to D8 give
205    /// them; never a real card.
206    #[test]
207    fn the_touch_settings_read_from_the_flags() {
208        let flag = |bytes: &[u8]| UserInteractionFlag::try_from(bytes.to_vec());
209        assert_eq!(touch_of(flag(&[0x00, 0x20]).map(Some)), Touch::Off);
210        assert_eq!(touch_of(flag(&[0x01, 0x20]).map(Some)), Touch::On, "a YubiKey with touch on: UIF Sign=on");
211        assert_eq!(touch_of(flag(&[0x02, 0x20]).map(Some)), Touch::Fixed);
212        assert_eq!(touch_of(flag(&[0x03, 0x20]).map(Some)), Touch::Cached);
213        assert_eq!(touch_of(flag(&[0x04, 0x20]).map(Some)), Touch::CachedFixed);
214        assert_eq!(touch_of(flag(&[0x7f, 0x20]).map(Some)), Touch::Unknown, "a setting Sioul does not know: asked");
215        // Bytes that are no flag: not read, so said as maybe.
216        assert_eq!(touch_of(flag(&[0x01]).map(Some)), Touch::Unknown);
217        // A card without the flag (before version 3): no touch.
218        assert_eq!(touch_of(Ok(None)), Touch::Off);
219        let tr = sioul_core::i18n::Translator::new("en");
220        assert!(touch_of(flag(&[0x01, 0x20]).map(Some)).words(&tr).is_some_and(|w| w.contains("hold your finger") && w.contains("15 seconds")));
221        assert!(touch_of(flag(&[0x01]).map(Some)).words(&tr).is_some_and(|w| w.starts_with("If your key asks for a touch")));
222        assert_eq!(touch_of(Ok(None)).words(&tr), None);
223    }
224}