envseal 0.3.11

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Real CTAP2 / FIDO2 USB HID backend.
//!
//! Implements [`super::fido2::Fido2Authenticator`] against the
//! `ctap-hid-fido2` crate. Compiled only when the
//! `fido2-hardware` cargo feature is enabled — the default build
//! avoids pulling hidapi / libusb to keep installs lean for users
//! without security keys.
//!
//! # Wire-up
//!
//! ```ignore
//! use envseal::vault::fido2::Fido2Authenticator;
//! use envseal::vault::fido2_hardware::HwAuthenticator;
//! let mut auth = HwAuthenticator::discover()?;
//! let credential_id = auth.make_credential("envseal.local", "envseal vault")?;
//! let secret = auth.assert_with_hmac(&credential_id, &salt)?;
//! ```
//!
//! `HwAuthenticator::discover()` iterates over plugged-in USB HID
//! FIDO2 devices and returns the first one that successfully
//! responds to a `get_info` probe. If no device is present (or
//! the user pulls the key mid-flow) the relevant trait method
//! returns [`crate::error::Error::Fido2AssertionFailed`] with a
//! helpful message.
//!
//! # PIN / User-Verification policy
//!
//! envseal **refuses to enroll** an authenticator that has neither a
//! `clientPin` set nor a built-in user-verification capability
//! (biometric, on-device PIN pad). Without that second factor a
//! *stolen* security key would unlock the vault with only a touch —
//! that is exactly the threat the FIDO2 layer is supposed to close.
//!
//! At enroll time we call `get_info()` and inspect the CTAP2
//! `options` map: a key with at least one of `clientPin=true` (a PIN
//! is configured on the device) or `uv=true` (the device performs
//! user verification internally, e.g. fingerprint) passes; anything
//! else is rejected with an actionable message that tells the user
//! how to set a PIN via their vendor tooling.
//!
//! Once enrolled, every assertion goes through the device's default
//! policy: we no longer pass `.without_pin_and_uv()`, so if the
//! device requires UV, UV is enforced. The CTF-decisive property —
//! "stolen authenticator alone cannot unlock" — therefore becomes a
//! cryptographic guarantee, not a UX convention.
//!
//! # Known limitation
//!
//! Software-driven PIN *entry* (typing the PIN through envseal's GUI
//! into the authenticator) is not yet wired. Authenticators that
//! enforce a PIN (vs. on-device UV) will surface a PIN-required
//! error during assertion; the user must either use the device's own
//! tooling to type the PIN once per session, or wait for the GUI
//! PIN-entry feature.

#![cfg(feature = "fido2-hardware")]

use ctap_hid_fido2::fidokey::get_assertion::get_assertion_params::{
    Extension as AssertExtension, GetAssertionArgsBuilder,
};
use ctap_hid_fido2::fidokey::get_info::InfoOption;
use ctap_hid_fido2::fidokey::make_credential::make_credential_params::{
    Extension as CredExtension, MakeCredentialArgsBuilder,
};
use ctap_hid_fido2::{FidoKeyHid, FidoKeyHidFactory, LibCfg};

use super::fido2::{Fido2Authenticator, HMAC_SALT_LEN};
use crate::error::Error;

/// Real-hardware FIDO2 authenticator. Holds an open `FidoKeyHid`
/// handle to the discovered device for the lifetime of an
/// enrollment / unlock batch.
pub struct HwAuthenticator {
    device: FidoKeyHid,
}

impl HwAuthenticator {
    /// Discover the first FIDO2 device on the bus and open it.
    ///
    /// Errors map to [`Error::Fido2AssertionFailed`] so the caller
    /// can present a single "FIDO2 problem" path to the user
    /// without having to pattern-match on backend-specific
    /// errors.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Fido2AssertionFailed`] when no device is
    /// found, when the device fails to enumerate, or when the
    /// underlying `ctap-hid-fido2` factory returns an error.
    pub fn discover() -> Result<Self, Error> {
        let cfg = LibCfg::init();
        let device = FidoKeyHidFactory::create(&cfg).map_err(|e| {
            Error::Fido2AssertionFailed(format!(
                "no FIDO2 authenticator found on USB HID bus: {e}. \
                 plug in your security key and retry."
            ))
        })?;
        Ok(Self { device })
    }
}

/// Random 32-byte challenge accompanying every CTAP2 request. The
/// CTAP2 protocol uses the challenge in the attestation signature;
/// envseal does not consume the signature directly (we only care
/// about the credential id and the hmac-secret output) so a fresh
/// random per call is sufficient.
fn fresh_challenge() -> Vec<u8> {
    use rand::RngCore;
    let mut buf = vec![0u8; 32];
    rand::rngs::OsRng.fill_bytes(&mut buf);
    buf
}

/// Outcome of probing the authenticator's second-factor capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecondFactor {
    /// `clientPin` is configured on the device. We must collect a PIN
    /// from the user and pass it into `ctap-hid-fido2`.
    Pin,
    /// The authenticator performs user verification internally
    /// (built-in fingerprint reader, on-device PIN pad, etc). The
    /// device handles UV in hardware; we pass nothing.
    BuiltInUv,
    /// Both are configured. Prefer PIN — it's the path
    /// `ctap-hid-fido2`'s argument builder supports cleanly today;
    /// the device will still gate on its UV policy when applicable.
    PinAndUv,
}

/// Probe the authenticator's CTAP2 `options` map and decide which
/// second-factor path to drive.
///
/// Returns [`Error::Fido2AssertionFailed`] when neither `clientPin`
/// is set nor a built-in UV capability is present — that is the
/// "stolen-key-touches-vault" failure mode we refuse to enable. The
/// error message points the user at the vendor tooling needed to
/// configure a PIN before retrying.
fn probe_second_factor(device: &FidoKeyHid) -> Result<SecondFactor, Error> {
    let pin_set = device
        .enable_info_option(&InfoOption::ClientPin)
        .map_err(|e| {
            Error::Fido2AssertionFailed(format!(
                "could not query authenticator clientPin option: {e}"
            ))
        })?
        .unwrap_or(false);
    let uv_supported = device
        .enable_info_option(&InfoOption::Uv)
        .map_err(|e| {
            Error::Fido2AssertionFailed(format!("could not query authenticator uv option: {e}"))
        })?
        .unwrap_or(false);
    match (pin_set, uv_supported) {
        (true, true) => Ok(SecondFactor::PinAndUv),
        (true, false) => Ok(SecondFactor::Pin),
        (false, true) => Ok(SecondFactor::BuiltInUv),
        (false, false) => Err(Error::Fido2AssertionFailed(
            "this authenticator has no PIN configured and no built-in user \
             verification (biometric / on-device PIN pad). a stolen key would \
             unlock your vault with only a touch — set a PIN on the key first:\n\n\
             \tYubiKey:   ykman fido access change-pin\n\
             \tSolo 2:    solo2 admin set-pin\n\
             \tNitrokey:  nitropy fido2 set-pin\n\n\
             then retry `envseal security fido2-enroll`."
                .to_string(),
        )),
    }
}

/// Pull the user's PIN through the GUI prompt, retrying up to the
/// device's remaining attempt budget. Each wrong PIN is surfaced
/// with the up-to-date retry counter (CTAP2 decrements it on every
/// failed attempt, including across processes).
///
/// Refuses to even prompt when the device reports `<= 1` retries
/// left — burning that final attempt would brick the authenticator.
/// The user must use vendor tooling to reset PIN attempts.
fn collect_pin_with_retries(device: &FidoKeyHid) -> Result<zeroize::Zeroizing<String>, Error> {
    /// Hard floor for refusing to prompt. `clientPin/getRetries` of
    /// `<= 1` means one wrong typo permanently locks the device.
    const MIN_SAFE_RETRIES: u32 = 2;
    /// Per-session prompt cap so a user fat-fingering the PIN can
    /// recover, but we never burn more than this many attempts in
    /// one envseal invocation even if the device has more available.
    const MAX_SESSION_ATTEMPTS: u32 = 3;

    let initial_retries = device
        .get_pin_retries()
        .map_err(|e| Error::Fido2AssertionFailed(format!("get_pin_retries failed: {e}")))?;
    let initial_retries = u32::try_from(initial_retries).unwrap_or(0);

    if initial_retries < MIN_SAFE_RETRIES {
        return Err(Error::Fido2AssertionFailed(format!(
            "your authenticator has only {initial_retries} PIN retries remaining; refusing \
             to prompt because a typo would permanently lock the key. unblock with:\n\n\
             \tYubiKey:   ykman fido reset (DESTROYS all FIDO credentials on the key)\n\
             \tSolo 2:    solo2 admin reset (same)\n\n\
             after resetting, re-enroll with `envseal security fido2-enroll`."
        )));
    }

    let mut last_error: Option<String> = None;
    for attempt in 1..=MAX_SESSION_ATTEMPTS {
        // Refresh retries-left on every loop in case another process
        // burned attempts in the meantime. The display value is the
        // freshly-queried number.
        let live_retries =
            u32::try_from(device.get_pin_retries().map_err(|e| {
                Error::Fido2AssertionFailed(format!("get_pin_retries failed: {e}"))
            })?)
            .unwrap_or(0);
        if live_retries < MIN_SAFE_RETRIES {
            return Err(Error::Fido2AssertionFailed(format!(
                "authenticator dropped to {live_retries} PIN retries; aborting before \
                 we burn the last one. {}",
                last_error.as_deref().unwrap_or("")
            )));
        }
        let pin = crate::gui::request_fido2_pin(live_retries, attempt)?;
        // Probe the PIN with a cheap PIN-token round-trip via
        // get_pin_token. If it fails with "wrong PIN", retry; any
        // other error escalates immediately.
        match device.get_pin_token(pin.as_str()) {
            Ok(_token) => {
                // We don't need the token — we only used it to
                // verify the PIN. ctap-hid-fido2 caches PIN-derived
                // material per-device-handle, so subsequent calls
                // that take `.pin(...)` will not re-prompt the user.
                return Ok(pin);
            }
            Err(e) => {
                let msg = format!("{e}");
                last_error = Some(msg.clone());
                // Conservatively classify: every non-success drops
                // through to the next attempt unless we ran out.
                if attempt == MAX_SESSION_ATTEMPTS {
                    return Err(Error::Fido2AssertionFailed(format!(
                        "FIDO2 PIN rejected after {MAX_SESSION_ATTEMPTS} attempts: {msg}"
                    )));
                }
            }
        }
    }
    Err(Error::Fido2AssertionFailed(
        "FIDO2 PIN entry exhausted without a token".to_string(),
    ))
}

impl Fido2Authenticator for HwAuthenticator {
    fn make_credential(
        &mut self,
        relying_party_id: &str,
        _relying_party_name: &str,
    ) -> Result<Vec<u8>, Error> {
        let factor = probe_second_factor(&self.device)?;
        let pin = match factor {
            SecondFactor::Pin | SecondFactor::PinAndUv => {
                Some(collect_pin_with_retries(&self.device)?)
            }
            SecondFactor::BuiltInUv => None,
        };
        let challenge = fresh_challenge();
        // Enable the hmac-secret extension at registration time so
        // the authenticator provisions the per-credential internal
        // key. Without this the subsequent `assert_with_hmac` calls
        // would silently miss the extension and produce no output.
        // make_credential's HmacSecret variant is `Option<bool>` —
        // `Some(true)` enables the extension on the credential.
        let extensions = vec![CredExtension::HmacSecret(Some(true))];
        let mut builder =
            MakeCredentialArgsBuilder::new(relying_party_id, &challenge).extensions(&extensions);
        if let Some(ref p) = pin {
            builder = builder.pin(p.as_str());
        }
        let args = builder.build();
        let attestation = self.device.make_credential_with_args(&args).map_err(|e| {
            Error::Fido2AssertionFailed(format!(
                "make_credential failed: {e}. \
                 confirm your security key is plugged in and that you completed \
                 the touch / biometric / PIN within the device's timeout."
            ))
        })?;
        Ok(attestation.credential_descriptor.id)
    }

    fn assert_with_hmac(
        &self,
        credential_id: &[u8],
        salt: &[u8; HMAC_SALT_LEN],
    ) -> Result<[u8; HMAC_SALT_LEN], Error> {
        let factor = probe_second_factor(&self.device)?;
        let pin = match factor {
            SecondFactor::Pin | SecondFactor::PinAndUv => {
                Some(collect_pin_with_retries(&self.device)?)
            }
            SecondFactor::BuiltInUv => None,
        };
        let challenge = fresh_challenge();
        let extensions = vec![AssertExtension::HmacSecret(Some(*salt))];
        let mut builder = GetAssertionArgsBuilder::new(
            crate::vault::keychain::fido2_unlock::RELYING_PARTY_ID,
            &challenge,
        )
        .credential_id(credential_id)
        .extensions(&extensions);
        if let Some(ref p) = pin {
            builder = builder.pin(p.as_str());
        }
        let args = builder.build();

        let assertions = self.device.get_assertion_with_args(&args).map_err(|e| {
            Error::Fido2AssertionFailed(format!(
                "get_assertion_with_hmac failed: {e}. \
                 confirm the same security key used for enrollment is plugged in \
                 and that the touch / PIN was registered within the device's timeout."
            ))
        })?;

        // CTAP2 returns one assertion per credential; we asked for
        // exactly one. Find the hmac-secret extension in the
        // response and pull the 32-byte output out of it.
        let assertion = assertions.into_iter().next().ok_or_else(|| {
            Error::Fido2AssertionFailed(
                "authenticator returned no assertion — likely user-presence \
                 timeout (no touch within ~30s)"
                    .to_string(),
            )
        })?;

        for ext in &assertion.extensions {
            if let AssertExtension::HmacSecret(Some(out)) = ext {
                let mut result = [0u8; HMAC_SALT_LEN];
                result.copy_from_slice(out);
                return Ok(result);
            }
        }

        Err(Error::Fido2AssertionFailed(
            "authenticator did not include hmac-secret in its response — \
             your security key may not support the hmac-secret extension. \
             confirm with `ykman fido info` or equivalent."
                .to_string(),
        ))
    }
}