envseal 0.3.10

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 handling
//!
//! Authenticators that require a PIN for hmac-secret extension
//! use will see assertion failures here — PIN entry would need
//! to flow through the GUI passphrase modal, and that path is
//! the next slice of work. Authenticators in user-presence-only
//! mode (default for most YubiKeys) work today.

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

use ctap_hid_fido2::fidokey::get_assertion::get_assertion_params::{
    Extension as AssertExtension, GetAssertionArgsBuilder,
};
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
}

impl Fido2Authenticator for HwAuthenticator {
    fn make_credential(
        &mut self,
        relying_party_id: &str,
        _relying_party_name: &str,
    ) -> Result<Vec<u8>, Error> {
        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 args = MakeCredentialArgsBuilder::new(relying_party_id, &challenge)
            .extensions(&extensions)
            .without_pin_and_uv()
            .build();
        let attestation = self.device.make_credential_with_args(&args).map_err(|e| {
            Error::Fido2AssertionFailed(format!(
                "make_credential failed: {e}. \
                 if your authenticator requires a PIN, this build does not yet \
                 support PIN entry — fall back to a key without PIN, or wait for \
                 the next release."
            ))
        })?;
        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 challenge = fresh_challenge();
        let extensions = vec![AssertExtension::HmacSecret(Some(*salt))];
        let args = GetAssertionArgsBuilder::new(
            crate::vault::keychain::fido2_unlock::RELYING_PARTY_ID,
            &challenge,
        )
        .credential_id(credential_id)
        .extensions(&extensions)
        .without_pin_and_uv()
        .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 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(),
        ))
    }
}