yb_core/piv/mod.rs
1// SPDX-FileCopyrightText: 2025 - 2026 Frederic Ruget <fred@atlant.is> (GitHub: @douzebis)
2//
3// SPDX-License-Identifier: MIT
4
5//! PIV backend trait and implementations.
6
7pub mod emulated;
8pub mod hardware;
9pub mod mgmt;
10pub mod session;
11pub(crate) mod tlv;
12pub mod virtual_piv;
13
14pub use mgmt::MgmtAlgo;
15pub use virtual_piv::{Fault, VirtualPiv};
16
17use anyhow::Result;
18
19// ---------------------------------------------------------------------------
20// FlashHandle — returned by PivBackend::start_flash
21// ---------------------------------------------------------------------------
22
23/// Opaque handle returned by [`PivBackend::start_flash`].
24///
25/// Dropping the handle stops the flash loop on the device.
26pub trait FlashHandle: Send {}
27
28/// No-op flash handle used by backends that do not support LED flashing
29/// (e.g. `VirtualPiv`).
30pub struct NoopFlash;
31impl FlashHandle for NoopFlash {}
32
33// ---------------------------------------------------------------------------
34// DeviceInfo
35// ---------------------------------------------------------------------------
36
37/// Device info returned by list_devices.
38#[derive(Debug, Clone)]
39pub struct DeviceInfo {
40 pub serial: u32,
41 pub version: String,
42 pub reader: String,
43}
44
45/// The credential changed by [`PivBackend::change_reference`].
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PinRef {
48 Pin,
49 Puk,
50}
51
52impl PinRef {
53 /// Key reference (P2 of VERIFY / CHANGE REFERENCE DATA).
54 pub fn reference(self) -> u8 {
55 match self {
56 Self::Pin => 0x80,
57 Self::Puk => 0x81,
58 }
59 }
60}
61
62impl std::fmt::Display for PinRef {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(match self {
65 Self::Pin => "PIN",
66 Self::Puk => "PUK",
67 })
68 }
69}
70
71/// Abstract PIV backend. Both HardwarePiv and EmulatedPiv implement this.
72pub trait PivBackend: Send + Sync {
73 /// List connected PC/SC readers.
74 fn list_readers(&self) -> Result<Vec<String>>;
75
76 /// List connected YubiKey devices.
77 fn list_devices(&self) -> Result<Vec<DeviceInfo>>;
78
79 /// Read a PIV data object by its numeric ID.
80 fn read_object(&self, reader: &str, id: u32) -> Result<Vec<u8>>;
81
82 /// Write a PIV data object, authenticating with `management_key`.
83 ///
84 /// Empty `data` deletes the object. The management key is always
85 /// explicit: resolving it (e.g. from the PIN-protected PRINTED object)
86 /// is the caller's job — see `Context::management_key_for_write`.
87 fn write_object(&self, reader: &str, id: u32, data: &[u8], management_key: &str) -> Result<()>;
88
89 /// Authenticate with `management_key` and write nothing. Fails if the
90 /// card rejects the key.
91 fn authenticate_management_key(&self, reader: &str, management_key: &str) -> Result<()>;
92
93 /// Verify the user PIN. Returns Err if verification fails.
94 fn verify_pin(&self, reader: &str, pin: &str) -> Result<()>;
95
96 /// Send a raw APDU and return the response bytes (SW stripped).
97 /// Returns Err if the card returns a non-9000 status.
98 fn send_apdu(&self, reader: &str, apdu: &[u8]) -> Result<Vec<u8>>;
99
100 /// ECDH key agreement: given the peer's uncompressed P-256 point (65 bytes),
101 /// return the shared secret point (65 bytes).
102 fn ecdh(&self, reader: &str, slot: u8, peer_point: &[u8], pin: Option<&str>)
103 -> Result<Vec<u8>>;
104
105 /// ECDSA sign: compute SHA-256(`digest`) and sign it with the P-256 key in
106 /// `slot`. Returns the raw 64-byte signature `[r (32 bytes) || s (32 bytes)]`.
107 /// PIN is required; pass `None` only if already verified in a prior call.
108 fn ecdsa_sign(
109 &self,
110 reader: &str,
111 slot: u8,
112 digest: &[u8],
113 pin: Option<&str>,
114 ) -> Result<[u8; 64]> {
115 let _ = (reader, slot, digest, pin);
116 anyhow::bail!("ecdsa_sign not implemented for this backend")
117 }
118
119 /// Read the DER-encoded X.509 certificate from a PIV slot.
120 fn read_certificate(&self, reader: &str, slot: u8) -> Result<Vec<u8>>;
121
122 /// Generate an EC P-256 key pair in `slot`; return the public key as an
123 /// uncompressed point (65 bytes). Requires prior management key auth.
124 fn generate_key(&self, reader: &str, slot: u8, management_key: Option<&str>)
125 -> Result<Vec<u8>>;
126
127 /// Generate an EC P-256 key pair in `slot`, create a self-signed X.509
128 /// certificate with the given subject, and import it into the slot.
129 /// Returns the DER-encoded certificate.
130 fn generate_certificate(
131 &self,
132 reader: &str,
133 slot: u8,
134 subject: &str,
135 management_key: &str,
136 pin: Option<&str>,
137 ) -> Result<Vec<u8>>;
138
139 /// Read the raw content of the PRINTED object (0x5FC109) after verifying PIN.
140 ///
141 /// Implementations must perform PIN verification and object read in the same
142 /// session to prevent the card from resetting PIN-verified state between calls.
143 fn read_printed_object_with_pin(&self, reader: &str, pin: &str) -> Result<Vec<u8>>;
144
145 /// Return the algorithm of the card's current management key.
146 ///
147 /// The default implementation reports 3DES, for backends that do not
148 /// model the management key algorithm.
149 fn management_key_algorithm(&self, reader: &str) -> Result<MgmtAlgo> {
150 let _ = reader;
151 Ok(MgmtAlgo::Tdes)
152 }
153
154 /// Replace the management key.
155 ///
156 /// `old_key_hex` is the current management key. `new_key_hex` is the
157 /// replacement, which must be `algo.key_len()` bytes long; it is
158 /// installed with algorithm `algo`.
159 /// The implementation must authenticate with `old_key_hex` first, then
160 /// issue SET MANAGEMENT KEY to install `new_key_hex`.
161 fn set_management_key(
162 &self,
163 reader: &str,
164 old_key_hex: &str,
165 new_key_hex: &str,
166 algo: MgmtAlgo,
167 ) -> Result<()>;
168
169 /// Change the PIN or the PUK (CHANGE REFERENCE DATA). Both values are
170 /// at most 8 bytes; the card enforces its own rules on the new one.
171 fn change_reference(&self, reader: &str, which: PinRef, old: &str, new: &str) -> Result<()> {
172 let _ = (reader, which, old, new);
173 anyhow::bail!("changing the {which} is not implemented for this backend")
174 }
175
176 /// Return the size in bytes of a PIV data object, or `None` if the object
177 /// does not exist. Used by `scan_nvm` to measure NVM usage without writes.
178 /// The default implementation attempts `read_object` and maps "not found"
179 /// errors to `None`; hardware backends should override with an efficient
180 /// implementation that issues a single GET DATA without reading the payload.
181 fn object_size(&self, reader: &str, id: u32) -> Result<Option<usize>> {
182 match self.read_object(reader, id) {
183 Ok(data) => Ok(Some(data.len())),
184 Err(_) => Ok(None),
185 }
186 }
187
188 /// Persist state to a fixture file (no-op for hardware backends).
189 ///
190 /// `VirtualPiv` overrides this to serialize its in-memory state back to
191 /// disk, allowing subprocess tests to share state across process
192 /// boundaries via the `YB_FIXTURE` env var.
193 fn save_fixture(&self, _path: &std::path::Path) -> Result<()> {
194 Ok(())
195 }
196
197 /// Reload state from a fixture file (no-op for hardware backends): the
198 /// counterpart of [`save_fixture`](Self::save_fixture), for a process
199 /// that shares a simulated YubiKey with its child processes.
200 fn load_fixture(&self, _path: &std::path::Path) -> Result<()> {
201 Ok(())
202 }
203
204 /// Reset the PIV application to its factory state, as `ykman piv
205 /// reset` does: block the PIN and the PUK with wrong values, then
206 /// RESET (`00 FB 00 00`). Erases every PIV key, certificate and data
207 /// object; the PIN, PUK and management key return to their factory
208 /// values. OTP, FIDO2 and OATH are unaffected.
209 fn reset_piv(&self, reader: &str) -> Result<()> {
210 block_reference(|wrong| self.verify_pin(reader, wrong))?;
211 block_reference(|wrong| self.change_reference(reader, PinRef::Puk, wrong, wrong))?;
212 self.reset_blocked_piv(reader)
213 }
214
215 /// The RESET command itself; the card accepts it only once the PIN and
216 /// the PUK are blocked.
217 fn reset_blocked_piv(&self, reader: &str) -> Result<()> {
218 self.send_apdu(reader, &[0x00, 0xFB, 0x00, 0x00])
219 .map(|_| ())
220 }
221
222 /// Start flashing the LED on the device attached to `reader`.
223 ///
224 /// `on_ms` — how long the LED stays on per cycle (milliseconds).
225 /// `off_ms` — how long the LED stays off per cycle (milliseconds).
226 ///
227 /// Recommended values:
228 /// - Device selection: on=400, off=400 (calm 1.25 Hz, easy to follow)
229 /// - Destructive confirmation: on=200, off=400 (faster, conveys urgency)
230 ///
231 /// Returns a [`FlashHandle`]; the LED stops flashing when the handle is
232 /// dropped. The default implementation is a no-op so existing backends
233 /// are unaffected.
234 fn start_flash(&self, _reader: &str, _on_ms: u64, _off_ms: u64) -> Box<dyn FlashHandle> {
235 Box::new(NoopFlash)
236 }
237}
238
239/// The simulated YubiKey file named by `YB_FIXTURE`, in builds with the
240/// `test-fixture` feature only (spec 0034 G3). Without it, always `None`:
241/// the variable is ignored, and yb uses real YubiKeys.
242pub fn fixture_path() -> Option<std::path::PathBuf> {
243 fixture_path_from(std::env::var_os("YB_FIXTURE"))
244}
245
246/// [`fixture_path`], given the variable's value.
247fn fixture_path_from(var: Option<std::ffi::OsString>) -> Option<std::path::PathBuf> {
248 if cfg!(feature = "test-fixture") {
249 var.map(std::path::PathBuf::from)
250 } else {
251 None
252 }
253}
254
255/// The PIV backend yb uses: the simulated YubiKey of [`fixture_path`], if
256/// any, otherwise the real YubiKeys.
257pub fn open_backend() -> Result<std::sync::Arc<dyn PivBackend>> {
258 #[cfg(feature = "test-fixture")]
259 if let Some(path) = fixture_path() {
260 return Ok(std::sync::Arc::new(VirtualPiv::from_fixture(&path)?));
261 }
262 Ok(std::sync::Arc::new(hardware::HardwarePiv::new()))
263}
264
265/// Present wrong values until the card reports the PIN or PUK blocked
266/// (`SW 6983`). A "wrong" value that turns out to be the right one is
267/// replaced by the next.
268fn block_reference(mut attempt: impl FnMut(&str) -> Result<()>) -> Result<()> {
269 const WRONG: [&str; 3] = ["00000000", "99999999", "13579246"];
270 let mut wrong = 0;
271 // A PIV retry counter is at most 255.
272 for _ in 0..(256 + WRONG.len()) {
273 let Some(value) = WRONG.get(wrong) else { break };
274 match attempt(value) {
275 Ok(()) => wrong += 1,
276 Err(e) => match e.downcast_ref::<crate::errors::CardError>() {
277 Some(crate::errors::CardError::Status { sw: 0x6983, .. }) => return Ok(()),
278 Some(crate::errors::CardError::Status { sw, .. }) if sw & 0xFFF0 == 0x63C0 => {}
279 _ => return Err(e),
280 },
281 }
282 }
283 anyhow::bail!("could not block the PIN or PUK before the PIV reset")
284}
285
286#[cfg(test)]
287mod fixture_tests {
288 /// Spec 0034 G3: honored with the feature, ignored without it. Run
289 /// both ways: `cargo test -p yb-core` (without) and with
290 /// `--features test-fixture`.
291 #[test]
292 fn fixture_path_follows_the_feature() {
293 let got = super::fixture_path_from(Some("card.yaml".into()));
294 assert_eq!(got.is_some(), cfg!(feature = "test-fixture"));
295 assert_eq!(super::fixture_path_from(None), None);
296 }
297}