Skip to main content

yb_core/
context.rs

1// SPDX-FileCopyrightText: 2025 - 2026 Frederic Ruget <fred@atlant.is> <fred@s3ns.io> (GitHub: @douzebis)
2// SPDX-FileCopyrightText: 2025 - 2026 Thales Cloud Sécurisé
3//
4// SPDX-License-Identifier: MIT
5
6//! Runtime context shared across all CLI commands.
7
8#[cfg(feature = "virtual-piv")]
9use crate::piv::VirtualPiv;
10use crate::{
11    auxiliaries,
12    piv::{hardware::HardwarePiv, DeviceInfo, PivBackend},
13};
14use anyhow::{bail, Context as _, Result};
15use p256::PublicKey;
16use std::cell::RefCell;
17use std::sync::Arc;
18use zeroize::Zeroizing;
19
20/// Output-control flags passed to `Context::new`.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct OutputOptions {
23    pub debug: bool,
24    pub quiet: bool,
25}
26
27/// Callback type for interactive device selection.
28///
29/// Returns the selected device together with an optional flash handle.  When
30/// the picker has been flashing the LED of the chosen device, it may pass that
31/// handle through so the flash continues uninterrupted into the next prompt.
32pub type DevicePicker = Box<
33    dyn Fn(
34        &Arc<dyn PivBackend>,
35        &[DeviceInfo],
36    ) -> Result<Option<(DeviceInfo, Option<Box<dyn crate::piv::FlashHandle>>)>>,
37>;
38
39/// Plain configuration options for `Context::new`.
40#[derive(Debug, Default)]
41pub struct ContextOptions {
42    pub serial: Option<u32>,
43    pub reader: Option<String>,
44    pub management_key: Option<String>,
45    pub pin: Option<String>,
46    pub allow_defaults: bool,
47}
48
49pub struct Context {
50    pub reader: String,
51    pub serial: u32,
52    pub management_key: Option<String>,
53    /// Which factory-default credentials were still active at startup.
54    pub defaults: auxiliaries::DefaultCredentials,
55    /// Cached PIN.  Starts as `None` when no non-interactive source provided
56    /// one; populated on the first call to `require_pin()`.
57    /// Wrapped in `Zeroizing` so the bytes are overwritten on drop.
58    pin: RefCell<Option<Zeroizing<String>>>,
59    /// Called by `require_pin()` when `pin` is still `None`.
60    /// Returns `Some(pin)` if it can supply one, `None` otherwise.
61    pin_fn: Box<dyn Fn() -> Result<Option<String>>>,
62    pub piv: Arc<dyn PivBackend>,
63    pub debug: bool,
64    pub quiet: bool,
65    pub pin_protected: bool,
66    /// Optional flash handle passed in from the interactive device picker.
67    /// Kept alive so the LED continues to flash into the next prompt.
68    /// Consumed by [`Context::take_flash`].
69    pub flash_handle: Option<Box<dyn crate::piv::FlashHandle>>,
70}
71
72impl Context {
73    /// Build a Context from global CLI options, selecting the device.
74    ///
75    /// `pin` is the PIN resolved from non-interactive sources (env var, stdin,
76    /// deprecated flag).  `pin_fn` is called by `require_pin()` the first time
77    /// a PIN is needed and `pin` is still `None` — typically a TTY prompt
78    /// closure supplied by the application layer.
79    pub fn new(
80        opts: ContextOptions,
81        pin_fn: Box<dyn Fn() -> Result<Option<String>>>,
82        device_picker: DevicePicker,
83        output: OutputOptions,
84    ) -> Result<Self> {
85        let debug = output.debug;
86        let quiet = output.quiet;
87        #[cfg(feature = "virtual-piv")]
88        let piv: Arc<dyn PivBackend> = if let Ok(path) = std::env::var("YB_FIXTURE") {
89            Arc::new(VirtualPiv::from_fixture(std::path::Path::new(&path))?)
90        } else {
91            Arc::new(HardwarePiv::new())
92        };
93        #[cfg(not(feature = "virtual-piv"))]
94        let piv: Arc<dyn PivBackend> = Arc::new(HardwarePiv::new());
95
96        let devices = piv.list_devices().context("listing YubiKey devices")?;
97
98        let (device, selected_reader, flash_handle) = select_device(
99            &devices,
100            opts.serial.as_ref(),
101            opts.reader.as_deref(),
102            &piv,
103            &*device_picker,
104        )?;
105
106        // Check for default credentials unless skipped by environment.
107        let defaults = if std::env::var("YB_SKIP_DEFAULT_CHECK").is_err() {
108            auxiliaries::check_for_default_credentials(
109                &selected_reader,
110                piv.as_ref(),
111                opts.allow_defaults,
112            )?
113        } else {
114            auxiliaries::DefaultCredentials::default()
115        };
116
117        // When --allow-defaults is set and credentials are still at factory
118        // defaults, inject them automatically so the user is not prompted.
119        let management_key = opts.management_key.or_else(|| {
120            if defaults.management_key {
121                Some(auxiliaries::DEFAULT_MANAGEMENT_KEY.to_owned())
122            } else {
123                None
124            }
125        });
126        let pin = opts.pin.or_else(|| {
127            if defaults.pin {
128                Some(auxiliaries::DEFAULT_PIN.to_owned())
129            } else {
130                None
131            }
132        });
133
134        let (pin_protected, pin_derived) =
135            auxiliaries::detect_pin_protected_mode(&selected_reader, piv.as_ref())
136                .unwrap_or((false, false));
137
138        reject_pin_derived(pin_derived)?;
139
140        Ok(Self {
141            reader: selected_reader,
142            serial: device.serial,
143            management_key,
144            defaults,
145            pin: RefCell::new(pin.map(Zeroizing::new)),
146            pin_fn,
147            piv,
148            debug,
149            quiet,
150            pin_protected,
151            flash_handle,
152        })
153    }
154
155    /// Build a `Context` from an explicit PIV backend.
156    ///
157    /// Use this when you have a `VirtualPiv` (for tests) or any other
158    /// custom `PivBackend` implementation.  The backend must expose exactly
159    /// one device; if it exposes none or more than one, an error is returned.
160    ///
161    /// Default-credential and PIN-derived-key checks are skipped (the caller
162    /// controls the backend and is assumed to have configured it correctly).
163    pub fn with_backend(
164        backend: Arc<dyn PivBackend>,
165        pin: Option<String>,
166        debug: bool,
167    ) -> Result<Self> {
168        let devices = backend
169            .list_devices()
170            .context("listing devices in backend")?;
171        let device = match devices.as_slice() {
172            [] => bail!("no device found in backend"),
173            [d] => d.clone(),
174            _ => bail!("multiple devices in backend — use Context::new with --serial"),
175        };
176        let reader = device.reader.clone();
177
178        let (pin_protected, pin_derived) =
179            auxiliaries::detect_pin_protected_mode(&reader, backend.as_ref())
180                .unwrap_or((false, false));
181
182        reject_pin_derived(pin_derived)?;
183
184        Ok(Self {
185            reader,
186            serial: device.serial,
187            management_key: None,
188            defaults: auxiliaries::DefaultCredentials::default(),
189            pin: RefCell::new(pin.map(Zeroizing::new)),
190            pin_fn: Box::new(|| Ok(None)),
191            piv: backend,
192            debug,
193            quiet: false,
194            pin_protected,
195            flash_handle: None,
196        })
197    }
198
199    /// Return the PIN, invoking `pin_fn` on first call if not yet resolved.
200    ///
201    /// Resolution order:
202    /// 1. Already-cached PIN (from a non-interactive source or a prior call).
203    /// 2. `pin_fn()` — supplied by the caller of `Context::new`; typically a
204    ///    TTY prompt closure in the application layer.  The result is cached so
205    ///    subsequent calls never invoke `pin_fn` again.
206    /// 3. `None` — the caller must decide whether to error.
207    pub fn require_pin(&self) -> Result<Option<String>> {
208        if self.pin.borrow().is_some() {
209            return Ok(self.pin.borrow().as_ref().map(|z| z.as_str().to_owned()));
210        }
211        let resolved = (self.pin_fn)()?;
212        *self.pin.borrow_mut() = resolved.as_deref().map(|s| Zeroizing::new(s.to_owned()));
213        Ok(resolved)
214    }
215
216    /// Return the management key to use for write operations.
217    ///
218    /// Priority: explicit --key > PIN-protected retrieval > None (use default).
219    pub fn management_key_for_write(&self) -> Result<Option<String>> {
220        if let Some(ref k) = self.management_key {
221            return Ok(Some(k.clone()));
222        }
223        if self.pin_protected {
224            let pin = self.require_pin()?.ok_or_else(|| {
225                anyhow::anyhow!("PIN required to retrieve PIN-protected management key")
226            })?;
227            let key = auxiliaries::get_pin_protected_management_key(
228                &self.reader,
229                self.piv.as_ref(),
230                &pin,
231            )?;
232            return Ok(Some(key));
233        }
234        Ok(None)
235    }
236
237    /// Take the flash handle passed in from the interactive device picker.
238    ///
239    /// Returns `Some(handle)` the first time it is called (if the picker
240    /// provided one), and `None` on all subsequent calls.  The LED stops
241    /// flashing when the returned handle is dropped.
242    pub fn take_flash(&mut self) -> Option<Box<dyn crate::piv::FlashHandle>> {
243        self.flash_handle.take()
244    }
245
246    /// Retrieve the YubiKey's public key from the given PIV slot.
247    pub fn get_public_key(&self, slot: u8) -> Result<PublicKey> {
248        let cert_der = self
249            .piv
250            .read_certificate(&self.reader, slot)
251            .with_context(|| format!("reading certificate from slot 0x{slot:02x}"))?;
252        parse_ec_public_key_from_cert_der(&cert_der)
253    }
254}
255
256fn reject_pin_derived(pin_derived: bool) -> Result<()> {
257    if pin_derived {
258        bail!(
259            "PIN-derived management key mode is deprecated and not supported. \
260             Please migrate to PIN-protected mode."
261        );
262    }
263    Ok(())
264}
265
266// ---------------------------------------------------------------------------
267// Device selection
268// ---------------------------------------------------------------------------
269
270#[allow(clippy::type_complexity)]
271fn select_device(
272    devices: &[DeviceInfo],
273    serial: Option<&u32>,
274    reader: Option<&str>,
275    piv: &Arc<dyn PivBackend>,
276    device_picker: &dyn Fn(
277        &Arc<dyn PivBackend>,
278        &[DeviceInfo],
279    ) -> Result<
280        Option<(DeviceInfo, Option<Box<dyn crate::piv::FlashHandle>>)>,
281    >,
282) -> Result<(DeviceInfo, String, Option<Box<dyn crate::piv::FlashHandle>>)> {
283    if let Some(s) = serial {
284        let dev = devices
285            .iter()
286            .find(|d| &d.serial == s)
287            .ok_or_else(|| anyhow::anyhow!("no YubiKey with serial {s} found"))?;
288        return Ok((dev.clone(), dev.reader.clone(), None));
289    }
290
291    if let Some(r) = reader {
292        let dev = devices
293            .iter()
294            .find(|d| d.reader == r)
295            .ok_or_else(|| anyhow::anyhow!("no device on reader '{r}'"))?;
296        return Ok((dev.clone(), r.to_owned(), None));
297    }
298
299    match devices.len() {
300        0 => bail!("no YubiKey found"),
301        1 => Ok((devices[0].clone(), devices[0].reader.clone(), None)),
302        _ => {
303            // Multiple devices: invoke the picker (interactive or fallback).
304            match device_picker(piv, devices)? {
305                Some((dev, flash)) => {
306                    let reader = dev.reader.clone();
307                    Ok((dev, reader, flash))
308                }
309                None => bail!("device selection cancelled"),
310            }
311        }
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Certificate / public-key parsing
317// ---------------------------------------------------------------------------
318
319/// Extract the EC P-256 public key from a DER-encoded X.509 certificate.
320pub fn parse_ec_public_key_from_cert_der(cert_der: &[u8]) -> Result<PublicKey> {
321    use der::Decode;
322    use p256::elliptic_curve::sec1::FromEncodedPoint;
323    use x509_cert::Certificate;
324
325    let cert = Certificate::from_der(cert_der).context("parsing DER certificate")?;
326
327    // SubjectPublicKeyInfo → raw bit string → uncompressed EC point.
328    let spki = &cert.tbs_certificate.subject_public_key_info;
329    let point_bytes = spki.subject_public_key.as_bytes().ok_or_else(|| {
330        anyhow::anyhow!("certificate SubjectPublicKeyInfo: unexpected bit-string encoding")
331    })?;
332
333    let encoded = p256::EncodedPoint::from_bytes(point_bytes)
334        .map_err(|e| anyhow::anyhow!("parsing EC point from SPKI: {e}"))?;
335    let pk: Option<p256::PublicKey> = p256::PublicKey::from_encoded_point(&encoded).into();
336    pk.ok_or_else(|| anyhow::anyhow!("EC point in certificate is not on P-256 curve"))
337}