Skip to main content

sioul_sync/securitykey/
phone.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A security key held against the phone (NFC) or plugged into its USB-C
5//! port, as Sioul's own backend of `openpgp-card`: Android has no smart card
6//! service, so the card's APDUs travel through a [`Link`] (the app's, over
7//! Android's `IsoDep`, or CCID over its USB host API), and the card itself
8//! is read, given its PIN and used by the same code as on a computer
9//! ([`super::ocard`]). docs/android.md, "Your security key".
10//!
11//! - **One visit.** The PIN is asked first, from what Sioul knows of the
12//!   key, then one visit reads it, checks it is the one known, gives its PIN
13//!   and signs or opens: one tap over NFC, while it is held; plugged in, the
14//!   same, its touch asked as on a computer ([`PhoneReaders::taps`],
15//!   [`PhoneReaders::by_cable`]).
16//! - **Either way.** A key plugged in comes first; else one held. A phone
17//!   without NFC, or with NFC off, still takes a key plugged in
18//!   ([`PhoneReaders::reach_note`]); Android's refusal to let Sioul use the
19//!   key plugged in, and another app holding it, are said as such.
20//! - **What the phone carries.** Each exchange at most what its NFC controller
21//!   takes; extended length only where the phone says it takes it
22//!   ([`limit`]): else short APDUs, which `openpgp-card` chains and whose
23//!   long answers it reads with GET RESPONSE.
24//! - **Nothing left unlocked.** At the end of each opening, the PIN's two
25//!   checks are undone (VERIFY with P1 = FF, OpenPGP card specification
26//!   3.4.1, §7.2.2), before the key is let go: no other app finds it
27//!   unlocked while it stays near the phone. Android then hands it to no
28//!   other app until it has left the field (the app's link).
29//! - **A key moved away** mid-way is said as such ([`CardError::Removed`]);
30//!   a card that is no OpenPGP card (a bank card), as such.
31//! - **Nothing is logged**, here or in the link: never an APDU's bytes.
32
33use super::ocard::{card_error, OpenCard};
34use card_backend::{CardBackend, CardCaps, CardTransaction, PinType, SmartcardError};
35use openpgp_card::state::Open;
36use sioul_core::securitykey::{Card, CardError, Readers, Visit, Visited};
37use std::sync::OnceLock;
38use std::time::Duration;
39
40/// Whether this phone can reach a key now.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum LinkState {
43    /// No NFC in this phone.
44    NoNfc,
45    /// NFC switched off.
46    Off,
47    /// Ready: a key held to the phone is read.
48    Ready,
49}
50
51/// A key held against the phone, as the phone carries it.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Held {
54    /// The longest exchange the phone carries (IsoDep's `getMaxTransceiveLength`).
55    pub max_transceive: usize,
56    /// Whether the phone carries extended length APDUs.
57    pub extended: bool,
58    /// Held against the phone, or plugged in.
59    pub via: Via,
60}
61
62/// Why an exchange failed.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum LinkError {
65    /// The key left the field.
66    Lost,
67    /// Anything else, as a code from the link: never the APDU's bytes.
68    Other(String),
69}
70
71/// How the key in reach came.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum Via {
74    /// Held against the phone.
75    #[default]
76    Nfc,
77    /// Plugged into its USB-C port.
78    Usb,
79}
80
81/// What the link reaches now.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Reach {
84    /// No key.
85    None,
86    /// A key, held or plugged in.
87    Held(Held),
88    /// A key plugged in that Android did not let Sioul use (its dialog refused).
89    Refused,
90    /// A key plugged in that another app holds.
91    Busy,
92}
93
94/// What carries APDUs between Sioul and a key held to the phone: the app's,
95/// over Android's NFC; a stand-in in tests.
96pub trait Link: Send + Sync {
97    /// Whether the phone can reach a key held against it now (NFC).
98    fn state(&self) -> LinkState;
99    /// Whether the phone takes a key plugged into its USB-C port (USB host).
100    fn usb_host(&self) -> bool;
101    /// The key in reach now: plugged in first, else held.
102    fn reach(&self) -> Reach;
103    /// One APDU to the key held; its answer, status words included.
104    fn transceive(&self, command: &[u8]) -> Result<Vec<u8>, LinkError>;
105    /// Waits at most `timeout` for a key to be held or plugged in.
106    fn wait(&self, timeout: Duration);
107    /// A key is wanted now, or no longer: while it is, the phone reads keys
108    /// in reader mode, ahead of other apps, and asks Android, once for each
109    /// time it is plugged in, to let Sioul use a key plugged in; no longer,
110    /// the key held is let go (Android hands it to no other app until it
111    /// leaves the field), reader mode ends, and the key plugged in is
112    /// powered off and given back.
113    fn want(&self, wanted: bool);
114}
115
116static LINK: OnceLock<&'static dyn Link> = OnceLock::new();
117
118/// The app's link to keys held to the phone, given once as it starts.
119pub fn set_link(link: &'static dyn Link) {
120    let _ = LINK.set(link);
121}
122
123/// The link given, if any.
124pub fn link() -> Option<&'static dyn Link> {
125    LINK.get().copied()
126}
127
128/// Keys held against the phone, behind `openpgp-card`.
129pub struct PhoneReaders {
130    link: &'static dyn Link,
131    /// How the last key used came.
132    last: std::sync::Mutex<Via>,
133}
134
135impl PhoneReaders {
136    /// Keys reached through `link`.
137    pub const fn new(link: &'static dyn Link) -> PhoneReaders {
138        PhoneReaders { link, last: std::sync::Mutex::new(Via::Nfc) }
139    }
140
141    /// The key in reach, or why none can be: Android refused the one
142    /// plugged in, another app holds it; no NFC (or NFC off) and no USB host.
143    fn reached(&self) -> Result<Option<Held>, CardError> {
144        match self.link.reach() {
145            Reach::Held(held) => Ok(Some(held)),
146            Reach::Refused => Err(CardError::UsbRefused),
147            Reach::Busy => Err(CardError::UsbBusy),
148            Reach::None if self.link.usb_host() => Ok(None),
149            Reach::None => match self.link.state() {
150                LinkState::NoNfc => Err(CardError::NoNfc),
151                LinkState::Off => Err(CardError::NfcOff),
152                LinkState::Ready => Ok(None),
153            },
154        }
155    }
156}
157
158/// The name a key held to the phone goes by: none, since it leaves the field
159/// after each use (the PIN memory never ties itself to it).
160pub const HELD: &str = "";
161
162impl Readers for PhoneReaders {
163    fn present(&self) -> Result<Vec<String>, CardError> {
164        Ok(self.reached()?.map(|_| vec![HELD.to_string()]).unwrap_or_default())
165    }
166
167    fn visit(&self, visit: &mut dyn FnMut(&mut dyn Card) -> Visit) -> Result<Visited, CardError> {
168        let Some(held) = self.reached()? else { return Ok(Visited::default()) };
169        if let Ok(mut last) = self.last.lock() {
170            *last = held.via;
171        }
172        let backend: Box<dyn CardBackend + Send + Sync> = Box::new(PhoneBackend { link: self.link, held, exchanges: std::sync::atomic::AtomicUsize::new(0) });
173        let opened = openpgp_card::Card::<Open>::new(backend).map_err(|e| match card_error(e, false) {
174            CardError::Removed => CardError::Removed,
175            // It answered, but not as an OpenPGP card: a bank card, a transport card.
176            _ => CardError::NotOpenPgp,
177        });
178        let result = opened.and_then(|mut opened| {
179            let transaction = opened.transaction().map_err(|e| card_error(e, false))?;
180            let mut card = OpenCard::new(transaction, HELD.to_string());
181            let _ = visit(&mut card);
182            Ok(Visited { opened: 1, busy: 0 })
183        });
184        unverify(self.link);
185        result
186    }
187
188    fn wait_change(&self, timeout: Duration) {
189        self.link.wait(timeout);
190    }
191
192    fn taps(&self) -> bool {
193        true
194    }
195
196    fn want(&self, wanted: bool) {
197        self.link.want(wanted);
198    }
199
200    fn by_cable(&self) -> bool {
201        match self.link.reach() {
202            Reach::Held(held) => held.via == Via::Usb,
203            _ => self.last.lock().is_ok_and(|last| *last == Via::Usb),
204        }
205    }
206
207    fn reach_note(&self) -> Option<CardError> {
208        if !self.link.usb_host() {
209            return None;
210        }
211        match self.link.state() {
212            LinkState::NoNfc => Some(CardError::NoNfc),
213            LinkState::Off => Some(CardError::NfcOff),
214            LinkState::Ready => None,
215        }
216    }
217}
218
219/// The PIN's two checks undone (VERIFY, P1 = FF, for PW1 in modes 81 and
220/// 82), so that the key stays unlocked for no one once Sioul is done with it.
221/// A key that does not take it answers so, and the end of the field ends its
222/// checks anyway.
223fn unverify(link: &dyn Link) {
224    for mode in [0x81, 0x82] {
225        let _ = link.transceive(&[0x00, 0x20, 0xFF, mode]);
226    }
227}
228
229/// What a phone carries in one exchange, for `openpgp-card`: extended length
230/// only where both the key and the phone take it, each exchange within the
231/// phone's frame; else short APDUs (255 bytes of data, 256 of answer).
232pub fn limit(caps: CardCaps, held: Held) -> CardCaps {
233    let extended = caps.ext_support() && held.extended;
234    // The header and the lengths of an extended APDU take nine bytes at most.
235    let room = u16::try_from(held.max_transceive.saturating_sub(9)).unwrap_or(u16::MAX);
236    let (command, answer) = if extended { (caps.max_cmd_bytes().min(room), caps.max_rsp_bytes().min(room)) } else { (caps.max_cmd_bytes().min(255), caps.max_rsp_bytes().min(256)) };
237    CardCaps::new(extended, caps.chaining_support(), command, answer, caps.pw1_max_len(), caps.pw3_max_len())
238}
239
240/// The most exchanges one visit makes: a visit takes a few dozen (an RSA
241/// key's chained ciphertext included); a card that answers "more bytes"
242/// (61xx) without end would keep `openpgp-card` reading, and growing its
243/// answer, until the key left (review of 23 October 2026, F5).
244pub const MOST_EXCHANGES: usize = 512;
245
246struct PhoneBackend {
247    link: &'static dyn Link,
248    held: Held,
249    /// Exchanges made in this visit.
250    exchanges: std::sync::atomic::AtomicUsize,
251}
252
253impl CardBackend for PhoneBackend {
254    fn limit_card_caps(&self, caps: CardCaps) -> CardCaps {
255        limit(caps, self.held)
256    }
257
258    fn transaction(&mut self, _reselect: Option<&[u8]>) -> Result<Box<dyn CardTransaction + Send + Sync + '_>, SmartcardError> {
259        Ok(Box::new(PhoneTransaction { link: self.link, exchanges: &self.exchanges }))
260    }
261}
262
263struct PhoneTransaction<'a> {
264    link: &'static dyn Link,
265    exchanges: &'a std::sync::atomic::AtomicUsize,
266}
267
268impl CardTransaction for PhoneTransaction<'_> {
269    fn transmit(&mut self, command: &[u8], _buf_size: usize) -> Result<Vec<u8>, SmartcardError> {
270        if self.exchanges.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= MOST_EXCHANGES {
271            return Err(SmartcardError::Error(format!("more than {MOST_EXCHANGES} exchanges in one visit")));
272        }
273        self.link.transceive(command).map_err(|e| match e {
274            // Said as a reader says a card taken out (`ocard::card_error`).
275            LinkError::Lost => SmartcardError::Error("RemovedCard: the key left the phone".into()),
276            LinkError::Other(code) => SmartcardError::Error(code),
277        })
278    }
279
280    fn feature_pinpad_verify(&self) -> bool {
281        false
282    }
283
284    fn feature_pinpad_modify(&self) -> bool {
285        false
286    }
287
288    fn pinpad_verify(&mut self, _pin: PinType, _card_caps: &Option<CardCaps>) -> Result<Vec<u8>, SmartcardError> {
289        Err(SmartcardError::Error("no PIN pad".into()))
290    }
291
292    fn pinpad_modify(&mut self, _pin: PinType, _card_caps: &Option<CardCaps>) -> Result<Vec<u8>, SmartcardError> {
293        Err(SmartcardError::Error("no PIN pad".into()))
294    }
295
296    fn was_reset(&self) -> bool {
297        false
298    }
299}
300
301#[cfg(test)]
302#[path = "phone_tests.rs"]
303mod tests;