#![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;
pub struct HwAuthenticator {
device: FidoKeyHid,
}
impl HwAuthenticator {
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 })
}
}
fn fresh_challenge() -> Vec<u8> {
use rand::RngCore;
let mut buf = vec![0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut buf);
buf
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecondFactor {
Pin,
BuiltInUv,
PinAndUv,
}
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(),
)),
}
}
fn collect_pin_with_retries(device: &FidoKeyHid) -> Result<zeroize::Zeroizing<String>, Error> {
const MIN_SAFE_RETRIES: u32 = 2;
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 {
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)?;
match device.get_pin_token(pin.as_str()) {
Ok(_token) => {
return Ok(pin);
}
Err(e) => {
let msg = format!("{e}");
last_error = Some(msg.clone());
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();
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."
))
})?;
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(),
))
}
}