mirror of
https://github.com/trussed-dev/fido-authenticator.git
synced 2026-06-20 04:16:16 -07:00
ctap2.3: long-touch reset (§6.11.5, §7.7)
This commit is contained in:
+2
-1
@@ -6,7 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Unreleased
|
||||
|
||||
-
|
||||
- Add `long_touch_for_reset` to `Config` and, if set, implement the `enableLongTouchForReset` subcommand for `config` and always require a long touch for a reset.
|
||||
- Add `fido2_up_timeout` to `Config`.
|
||||
|
||||
## [v0.4.0-rc.3](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.4.0-rc.3) (2026-06-01)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ fuzz_target!(|requests: Vec<Request<'_>>| {
|
||||
ccid_transport: false,
|
||||
firmware_version: Some(0.into()),
|
||||
credential_id_version: None,
|
||||
long_touch_for_reset: true,
|
||||
fido2_up_timeout: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+43
-14
@@ -28,7 +28,7 @@ use trussed_core::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
constants::{self, MAX_RESIDENT_CREDENTIALS_GUESSTIMATE},
|
||||
constants::MAX_RESIDENT_CREDENTIALS_GUESSTIMATE,
|
||||
credential::{self, Credential, FullCredential, Key, StrippedCredential},
|
||||
format_hex, state, Result, SigningAlgorithm, TrussedRequirements, UserPresence,
|
||||
};
|
||||
@@ -163,13 +163,18 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
response.min_pin_length = Some(self.state.persistent.min_pin_length());
|
||||
response.force_pin_change = Some(self.state.persistent.force_pin_change());
|
||||
response.max_rpids_for_set_min_pin_length = Some(MAX_MIN_PIN_LENGTH_RP_IDS);
|
||||
response.long_touch_for_reset = Some(self.config.long_touch_for_reset);
|
||||
response.attestation_formats = Some(attestation_formats);
|
||||
// CTAP 2.3 §6.4 0x1F: supported authenticatorConfig sub-command IDs.
|
||||
// 0x02 toggleAlwaysUv (CTAP 2.1 §6.11.2)
|
||||
// 0x03 setMinPINLength (CTAP 2.1 §6.11.3)
|
||||
// 0x02 toggleAlwaysUv (CTAP 2.3 §6.11.2)
|
||||
// 0x03 setMinPINLength (CTAP 2.3 §6.11.4)
|
||||
// 0x04 enableLongTouchForReset (CTAP 2.3 §6.11.5)
|
||||
let mut cfg_cmds = Vec::new();
|
||||
cfg_cmds.push(0x02).unwrap();
|
||||
cfg_cmds.push(0x03).unwrap();
|
||||
if self.config.long_touch_for_reset {
|
||||
cfg_cmds.push(0x04).unwrap();
|
||||
}
|
||||
response.authenticator_config_commands = Some(cfg_cmds);
|
||||
response
|
||||
}
|
||||
@@ -216,6 +221,10 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
parameters: &ctap2::make_credential::Request,
|
||||
response: &mut ctap2::make_credential::Response,
|
||||
) -> Result<()> {
|
||||
// CTAP 2.1 §6.1.1.2: rp.id must be present and non-empty.
|
||||
if parameters.rp.id.is_empty() {
|
||||
return Err(Error::MissingParameter);
|
||||
}
|
||||
let rp_id_hash = self.hash(parameters.rp.id.as_ref());
|
||||
|
||||
// 1-4.
|
||||
@@ -254,7 +263,7 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
{
|
||||
info_now!("Excluded!");
|
||||
self.up
|
||||
.user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?;
|
||||
.user_present(&mut self.trussed, self.config.fido2_up_timeout())?;
|
||||
return Err(Error::CredentialExcluded);
|
||||
}
|
||||
}
|
||||
@@ -382,7 +391,7 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
|
||||
// 10. get UP, if denied error OperationDenied
|
||||
self.up
|
||||
.user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?;
|
||||
.user_present(&mut self.trussed, self.config.fido2_up_timeout())?;
|
||||
|
||||
// 11. generate credential keypair
|
||||
let location = match rk_requested {
|
||||
@@ -635,11 +644,18 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
#[cfg(not(feature = "disable-reset-time-window"))]
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
// 2. check for user presence
|
||||
// denied -> OperationDenied
|
||||
// timeout -> UserActionTimeout
|
||||
self.up
|
||||
.user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?;
|
||||
// 2. check for user presence (denied -> OperationDenied, timeout ->
|
||||
// UserActionTimeout). Short touch by default (pre-2.3 behavior);
|
||||
// when `long_touch_for_reset` is enabled we require a continuous
|
||||
// ≥5 s "long touch" (CTAP 2.3 §6.6 / §7.7) via `Level::Strong`.
|
||||
// The button/hardware backend is untouched.
|
||||
if self.config.long_touch_for_reset {
|
||||
self.up
|
||||
.user_present_strong(&mut self.trussed, self.config.fido2_up_timeout())?;
|
||||
} else {
|
||||
self.up
|
||||
.user_present(&mut self.trussed, self.config.fido2_up_timeout())?;
|
||||
}
|
||||
|
||||
// Delete resident keys
|
||||
syscall!(self.trussed.delete_all(Location::Internal));
|
||||
@@ -663,7 +679,7 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
|
||||
fn selection(&mut self) -> Result<()> {
|
||||
self.up
|
||||
.user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)
|
||||
.user_present(&mut self.trussed, self.config.fido2_up_timeout())
|
||||
}
|
||||
|
||||
// https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorConfig
|
||||
@@ -682,10 +698,10 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
|
||||
// 2. If the authenticator does not support the subcommand being
|
||||
// invoked, per subCommand's value, return CTAP1_ERR_INVALID_PARAMETER.
|
||||
// EnableLongTouchForReset lands with the long-touch reset commit.
|
||||
// EnterpriseAttestation / VendorPrototype are not supported.
|
||||
match request.sub_command {
|
||||
Subcommand::SetMinPINLength | Subcommand::ToggleAlwaysUv => {}
|
||||
Subcommand::EnableLongTouchForReset if self.config.long_touch_for_reset => {}
|
||||
_ => return Err(Error::InvalidParameter),
|
||||
}
|
||||
|
||||
@@ -762,6 +778,15 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
match request.sub_command {
|
||||
Subcommand::SetMinPINLength => self.config_set_min_pin_length(request),
|
||||
Subcommand::ToggleAlwaysUv => self.state.persistent.toggle_always_uv(&mut self.trussed),
|
||||
// CTAP 2.3 §6.11.5: governed by the `long_touch_for_reset` config
|
||||
// option. Acknowledge when enabled; otherwise it is unavailable.
|
||||
Subcommand::EnableLongTouchForReset => {
|
||||
if self.config.long_touch_for_reset {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::InvalidParameter)
|
||||
}
|
||||
}
|
||||
// Step 2 filtered every other variant. `Subcommand` is
|
||||
// `#[non_exhaustive]` so the catch-all is still required.
|
||||
_ => Err(Error::InvalidParameter),
|
||||
@@ -1214,6 +1239,10 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
) -> Result<()> {
|
||||
debug_now!("remaining stack size: {} bytes", msp() - 0x2000_0000);
|
||||
|
||||
// CTAP 2.1 §6.2.1.2: rpId must be present and non-empty.
|
||||
if parameters.rp_id.is_empty() {
|
||||
return Err(Error::MissingParameter);
|
||||
}
|
||||
let rp_id_hash = self.hash(parameters.rp_id.as_ref());
|
||||
|
||||
// 1-4.
|
||||
@@ -1278,7 +1307,7 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
|
||||
if !self.skip_up_check() {
|
||||
info_now!("asking for up");
|
||||
self.up
|
||||
.user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?;
|
||||
.user_present(&mut self.trussed, self.config.fido2_up_timeout())?;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
@@ -1843,7 +1872,7 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
|
||||
if let Some(pin_auth) = pin_auth {
|
||||
if pin_auth.is_empty() {
|
||||
self.up
|
||||
.user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?;
|
||||
.user_present(&mut self.trussed, self.config.fido2_up_timeout())?;
|
||||
if !self.state.persistent.pin_is_set() {
|
||||
return Err(Error::PinNotSet);
|
||||
} else {
|
||||
|
||||
+48
@@ -139,6 +139,20 @@ pub struct Config {
|
||||
/// To avoid invalidating existing credentials, this value is only used if the state is clean,
|
||||
/// i. e. on the first start or after a reset. Otherwise, `V1` is used.
|
||||
pub credential_id_version: Option<credential::CredentialIdVersion>,
|
||||
/// Whether `authenticatorReset` requires a long touch (a continuous ≥5 s
|
||||
/// press) rather than a normal short touch (CTAP 2.3 §6.6 / §7.7).
|
||||
///
|
||||
/// Recommended value: `true`. When `true`, reset requires
|
||||
/// [`UserPresence::user_present_strong`] and `authenticatorGetInfo`
|
||||
/// advertises `longTouchForReset = true`. A runner whose button backend
|
||||
/// cannot detect a long press must explicitly opt out by setting this to
|
||||
/// `false`, which restores the normal short-touch presence check. The
|
||||
/// button/hardware backend is untouched either way.
|
||||
pub long_touch_for_reset: bool,
|
||||
/// The timeout for user presence requests in FIDO2/CTAP2 in milliseconds.
|
||||
///
|
||||
/// The default is [`constants::FIDO2_UP_TIMEOUT`][]. The spec requires at least 10 s.
|
||||
pub fido2_up_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -152,12 +166,18 @@ impl Config {
|
||||
ccid_transport: false,
|
||||
firmware_version: None,
|
||||
credential_id_version: None,
|
||||
long_touch_for_reset: false,
|
||||
fido2_up_timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_large_blobs(&self) -> bool {
|
||||
self.large_blobs.is_some()
|
||||
}
|
||||
|
||||
pub fn fido2_up_timeout(&self) -> u32 {
|
||||
self.fido2_up_timeout.unwrap_or(constants::FIDO2_UP_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
/// This struct makes it possible to define the firmware version based on the credential ID format
|
||||
@@ -353,6 +373,18 @@ pub trait UserPresence: Copy {
|
||||
trussed: &mut T,
|
||||
timeout_milliseconds: u32,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Strong user-presence check (CTAP 2.3 §7.7 long-touch reset). Default
|
||||
/// falls back to a normal user-presence check; runners that can detect a
|
||||
/// continuous ≥5 s touch should override this and ask trussed for
|
||||
/// `consent::Level::Strong`.
|
||||
fn user_present_strong<T: TrussedRequirements>(
|
||||
self,
|
||||
trussed: &mut T,
|
||||
timeout_milliseconds: u32,
|
||||
) -> Result<()> {
|
||||
self.user_present(trussed, timeout_milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
#[deprecated(note = "use `Silent` directly`")]
|
||||
@@ -390,6 +422,22 @@ impl UserPresence for Conforming {
|
||||
_ => Error::OperationDenied,
|
||||
})
|
||||
}
|
||||
|
||||
fn user_present_strong<T: TrussedRequirements>(
|
||||
self,
|
||||
trussed: &mut T,
|
||||
timeout_milliseconds: u32,
|
||||
) -> Result<()> {
|
||||
use trussed_core::types::consent::Level;
|
||||
let result =
|
||||
syscall!(trussed.confirm_user_present_with_level(Level::Strong, timeout_milliseconds))
|
||||
.result;
|
||||
result.map_err(|err| match err {
|
||||
trussed_core::types::consent::Error::TimedOut => Error::UserActionTimeout,
|
||||
trussed_core::types::consent::Error::Interrupted => Error::KeepaliveCancel,
|
||||
_ => Error::OperationDenied,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<UP, T> Authenticator<UP, T>
|
||||
|
||||
+399
-1
@@ -12,6 +12,7 @@ use std::{
|
||||
use ciborium::Value;
|
||||
use hex_literal::hex;
|
||||
use rand::RngCore as _;
|
||||
use trussed::platform::consent::Level;
|
||||
|
||||
use fs::list_fs;
|
||||
use virt::{Ctap2, Ctap2Error, Options};
|
||||
@@ -20,7 +21,8 @@ use webauthn::{
|
||||
ClientPin, CredentialManagement, CredentialManagementParams, Exhaustive, GetAssertion,
|
||||
GetAssertionExtensionsInput, GetAssertionOptions, GetInfo, GetNextAssertion, HmacSecretInput,
|
||||
KeyAgreementKey, MakeCredential, MakeCredentialExtensionsInput, MakeCredentialOptions,
|
||||
PinToken, PubKeyCredDescriptor, PubKeyCredParam, PublicKey, Rp, SharedSecret, Test, User,
|
||||
PinToken, PubKeyCredDescriptor, PubKeyCredParam, PublicKey, Reset, Rp, SharedSecret, Test,
|
||||
User,
|
||||
};
|
||||
|
||||
macro_rules! run_tests {
|
||||
@@ -2015,6 +2017,232 @@ fn test_always_uv_get_assertion_up_false_bypasses_uv_requirement() {
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// authenticatorReset clears all §6.7 feature flags
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// CTAP 2.1 §6.7: `authenticatorReset` MUST reset every feature listed under
|
||||
/// "Resets those features that are denoted as being subject to reset" — in
|
||||
/// particular Always Require User Verification, Set Minimum PIN Length
|
||||
/// (including `minPinLength`, `minPinLengthRPIDs`, and the `forcePINChange`
|
||||
/// flag), and Enterprise Attestation. Set up the device with all of these
|
||||
/// dirtied, then Reset, then assert defaults.
|
||||
#[test]
|
||||
fn test_reset_clears_section_6_7_feature_flags() {
|
||||
let key_agreement_key = KeyAgreementKey::generate();
|
||||
let pin = b"12345678"; // 8 chars so we can tighten the floor to 6
|
||||
let rp_id_for_min_pin = "rp.example.com";
|
||||
let options = Options {
|
||||
user_presence: Level::Strong,
|
||||
..Default::default()
|
||||
};
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
// 1) Set PIN.
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap();
|
||||
|
||||
// 2) Toggle alwaysUv on.
|
||||
let pin_token = get_pin_token(
|
||||
&device,
|
||||
&key_agreement_key,
|
||||
&shared_secret,
|
||||
pin,
|
||||
PERM_AUTHENTICATOR_CONFIGURATION,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut tog = AuthenticatorConfig::new(0x02);
|
||||
tog.pin_protocol = Some(2);
|
||||
tog.pin_auth = Some(pin_token.authenticate(&tog.pin_uv_auth_data()));
|
||||
device.exec(tog).unwrap();
|
||||
|
||||
// 3) Tighten minPINLength to 6, set RP-IDs allowlist, and request
|
||||
// forceChangePin.
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
let pin_token = get_pin_token(
|
||||
&device,
|
||||
&key_agreement_key,
|
||||
&shared_secret,
|
||||
pin,
|
||||
PERM_AUTHENTICATOR_CONFIGURATION,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let params = AuthenticatorConfigParams {
|
||||
new_min_pin_length: Some(6),
|
||||
min_pin_length_rp_ids: Some(vec![rp_id_for_min_pin.to_owned()]),
|
||||
force_change_pin: Some(true),
|
||||
};
|
||||
set_min_pin_length(&device, &pin_token, params).unwrap();
|
||||
|
||||
// Sanity-check: every dirty bit is visible before reset.
|
||||
let reply = device.exec(GetInfo).unwrap();
|
||||
let opts = reply.options.clone().unwrap();
|
||||
assert_eq!(opts.get("alwaysUv"), Some(&Value::from(true)));
|
||||
assert_eq!(opts.get("clientPin"), Some(&Value::from(true)));
|
||||
assert_eq!(reply.force_pin_change, Some(true));
|
||||
assert_eq!(reply.min_pin_length, Some(6));
|
||||
|
||||
// 4) Reset.
|
||||
device.exec(Reset).unwrap();
|
||||
|
||||
// 5) All §6.7 flags back to defaults.
|
||||
let reply = device.exec(GetInfo).unwrap();
|
||||
let opts = reply.options.unwrap();
|
||||
// alwaysUv default = false; couples makeCredUvNotRqd back to true.
|
||||
assert_eq!(opts.get("alwaysUv"), Some(&Value::from(false)));
|
||||
assert_eq!(opts.get("makeCredUvNotRqd"), Some(&Value::from(true)));
|
||||
// PIN cleared.
|
||||
assert_eq!(opts.get("clientPin"), Some(&Value::from(false)));
|
||||
// forcePINChange cleared.
|
||||
assert_eq!(reply.force_pin_change, Some(false));
|
||||
// minPINLength back to the spec floor (default 4).
|
||||
assert_eq!(reply.min_pin_length, Some(4));
|
||||
})
|
||||
}
|
||||
|
||||
/// CTAP 2.1 §6.6 authenticatorReset — comprehensive companion to
|
||||
/// `test_reset_clears_section_6_7_feature_flags`. Verifies the
|
||||
/// non-§6.7 reset effects:
|
||||
///
|
||||
/// - Resident credentials erased (GA with the prior credential id →
|
||||
/// `CTAP2_ERR_NO_CREDENTIALS`).
|
||||
/// - clientPin flag flipped back to false in `authenticatorGetInfo`.
|
||||
/// - PIN retry counter restored to its maximum (8 here, the
|
||||
/// factory state).
|
||||
/// - `pinUvAuthToken` invalidated — a token grabbed before reset cannot
|
||||
/// be used after.
|
||||
///
|
||||
/// Not covered here (different setup): U2F credentials erased (CTAP1
|
||||
/// flow, exercised by `tests/ctap1.rs`), large-blob array reset (needs
|
||||
/// `large_blobs::Config` wired into `Options`).
|
||||
#[test]
|
||||
fn test_reset_clears_all_state() {
|
||||
let key_agreement_key = KeyAgreementKey::generate();
|
||||
let pin = b"123456";
|
||||
let wrong_pin = b"000000";
|
||||
let rp_id = "example.com";
|
||||
let options = Options {
|
||||
user_presence: Level::Strong,
|
||||
..Default::default()
|
||||
};
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
// ----- Setup: RK + PIN + pinUvAuthToken + dirty retry counter -----
|
||||
|
||||
// 1. Create a resident credential (no PIN yet, MC needs no pin_auth).
|
||||
let client_data_hash = vec![0u8; 32];
|
||||
let mut mc = MakeCredential::new(
|
||||
client_data_hash.clone(),
|
||||
Rp::new(rp_id),
|
||||
User::new(vec![1; 16]),
|
||||
vec![PubKeyCredParam::new("public-key", -7)],
|
||||
);
|
||||
mc.options = Some(MakeCredentialOptions::default().rk(true));
|
||||
let mc_reply = device.exec(mc).unwrap();
|
||||
let credential = mc_reply.auth_data.credential.unwrap();
|
||||
|
||||
// 2. Set a PIN.
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap();
|
||||
// clientPin = true after setPin.
|
||||
assert_eq!(
|
||||
device
|
||||
.exec(GetInfo)
|
||||
.unwrap()
|
||||
.options
|
||||
.unwrap()
|
||||
.get("clientPin"),
|
||||
Some(&Value::from(true))
|
||||
);
|
||||
|
||||
// 3. Decrement the PIN retry counter with one wrong attempt.
|
||||
let bad_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
let _ = get_pin_token(
|
||||
&device,
|
||||
&key_agreement_key,
|
||||
&bad_secret,
|
||||
wrong_pin,
|
||||
PERM_AUTHENTICATOR_CONFIGURATION,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
get_pin_retries(&device),
|
||||
7,
|
||||
"retries should have decreased by one after a wrong PIN"
|
||||
);
|
||||
|
||||
// 4. Obtain a pinUvAuthToken (mc permission = 0x01, scoped to rp_id).
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
let pin_token_before_reset = get_pin_token(
|
||||
&device,
|
||||
&key_agreement_key,
|
||||
&shared_secret,
|
||||
pin,
|
||||
0x01,
|
||||
Some(rp_id.to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// ----- Reset -----
|
||||
device.exec(Reset).unwrap();
|
||||
|
||||
// ----- Assertions -----
|
||||
|
||||
// (a) PIN cleared.
|
||||
assert_eq!(
|
||||
device
|
||||
.exec(GetInfo)
|
||||
.unwrap()
|
||||
.options
|
||||
.unwrap()
|
||||
.get("clientPin"),
|
||||
Some(&Value::from(false))
|
||||
);
|
||||
|
||||
// (b) PIN retries restored to the maximum (8 in this build).
|
||||
assert_eq!(
|
||||
get_pin_retries(&device),
|
||||
8,
|
||||
"PIN retries must be restored to the post-reset default"
|
||||
);
|
||||
|
||||
// (c) Discoverable credential erased — GA with the prior credential
|
||||
// id should not find it.
|
||||
let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash.clone());
|
||||
ga.allow_list = Some(vec![PubKeyCredDescriptor::new(
|
||||
"public-key",
|
||||
credential.id.clone(),
|
||||
)]);
|
||||
let result = device.exec(ga);
|
||||
assert_eq!(
|
||||
result.err(),
|
||||
Some(Ctap2Error(0x2E)), // CTAP2_ERR_NO_CREDENTIALS
|
||||
"RK must be erased by reset"
|
||||
);
|
||||
|
||||
// (d) The previously-obtained pinUvAuthToken is invalidated by the
|
||||
// reset (the device-side token-state and pin_token_key are
|
||||
// regenerated). To verify, we have to first re-close the
|
||||
// §6.11 step-4 gate by setting a new PIN — in factory-default
|
||||
// state the gate is open and the token would simply be
|
||||
// ignored. After setPin, presenting the *old* token must fail
|
||||
// verification with CTAP2_ERR_PIN_AUTH_INVALID (0x33).
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap();
|
||||
|
||||
let mut cfg = AuthenticatorConfig::new(0x02); // ToggleAlwaysUv
|
||||
cfg.pin_protocol = Some(2);
|
||||
cfg.pin_auth = Some(pin_token_before_reset.authenticate(&cfg.pin_uv_auth_data()));
|
||||
let result = device.exec(cfg).err();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(Ctap2Error(0x33)),
|
||||
"stale pinUvAuthToken must not authenticate against the post-reset state, got {:?}",
|
||||
result
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// PIN length validation (issue #43): count Unicode code points, not bytes
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -3039,3 +3267,173 @@ fn test_signature_counter() {
|
||||
assert!(delta1 + delta2 + delta3 > 3);
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Long-touch reset (CTAP 2.3 §6.4 0x18, §6.11.5, §7.7)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// GetInfo advertises `longTouchForReset = true` (member 0x18). The runtime
|
||||
/// configuration is hard-wired on — `EnableLongTouchForReset` (below) is a
|
||||
/// no-op.
|
||||
#[test]
|
||||
fn test_long_touch_for_reset_advertised() {
|
||||
for long_touch_for_reset in [true, false] {
|
||||
let options = Options {
|
||||
long_touch_for_reset,
|
||||
..Default::default()
|
||||
};
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
let reply = device.exec(GetInfo).unwrap();
|
||||
assert_eq!(reply.long_touch_for_reset, Some(long_touch_for_reset));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_long_touch_for_reset_invalid_parameter() {
|
||||
let options = Options {
|
||||
long_touch_for_reset: false,
|
||||
..Default::default()
|
||||
};
|
||||
let key_agreement_key = KeyAgreementKey::generate();
|
||||
let pin = b"123456";
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap();
|
||||
let pin_token = get_pin_token(
|
||||
&device,
|
||||
&key_agreement_key,
|
||||
&shared_secret,
|
||||
pin,
|
||||
PERM_AUTHENTICATOR_CONFIGURATION,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// EnableLongTouchForReset = subcommand 0x04.
|
||||
let mut request = AuthenticatorConfig::new(0x04);
|
||||
request.pin_protocol = Some(2);
|
||||
request.pin_auth = Some(pin_token.authenticate(&request.pin_uv_auth_data()));
|
||||
let result = device.exec(request);
|
||||
assert_eq!(result.err(), Some(Ctap2Error(0x02)));
|
||||
})
|
||||
}
|
||||
|
||||
/// CTAP 2.3 §6.11.5: `EnableLongTouchForReset` subcommand. If the feature is enabled,
|
||||
/// the request must return `Ok(())` without changing state.
|
||||
#[test]
|
||||
fn test_enable_long_touch_for_reset_is_noop() {
|
||||
let options = Options {
|
||||
long_touch_for_reset: true,
|
||||
..Default::default()
|
||||
};
|
||||
let key_agreement_key = KeyAgreementKey::generate();
|
||||
let pin = b"123456";
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
let shared_secret = get_shared_secret(&device, &key_agreement_key);
|
||||
set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap();
|
||||
let pin_token = get_pin_token(
|
||||
&device,
|
||||
&key_agreement_key,
|
||||
&shared_secret,
|
||||
pin,
|
||||
PERM_AUTHENTICATOR_CONFIGURATION,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// EnableLongTouchForReset = subcommand 0x04.
|
||||
let mut request = AuthenticatorConfig::new(0x04);
|
||||
request.pin_protocol = Some(2);
|
||||
request.pin_auth = Some(pin_token.authenticate(&request.pin_uv_auth_data()));
|
||||
device.exec(request).unwrap();
|
||||
|
||||
// GetInfo still reports the flag set.
|
||||
let reply = device.exec(GetInfo).unwrap();
|
||||
assert_eq!(reply.long_touch_for_reset, Some(true));
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// authenticatorReset long-touch gating (CTAP 2.3 §6.6 / §7.7, Config option)
|
||||
// ============================================================================
|
||||
|
||||
/// With `long_touch_for_reset = true` (the recommended default), a normal
|
||||
/// short touch (`Level::Normal`) must NOT authorize `authenticatorReset`; the
|
||||
/// authenticator demands a long touch (`Level::Strong`). By default the test UI
|
||||
/// grants Normal but denies Strong, so Reset is rejected with
|
||||
/// `CTAP2_ERR_USER_ACTION_TIMEOUT` (0x2F).
|
||||
#[test]
|
||||
fn test_reset_long_touch_rejects_short_touch() {
|
||||
let options = Options {
|
||||
long_touch_for_reset: true,
|
||||
..Default::default()
|
||||
};
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
// `ResetReply` is a unit reply with no Debug/PartialEq, so map the Ok
|
||||
// arm to `()` before comparing.
|
||||
let result = device.exec(Reset).map(|_| ());
|
||||
assert_eq!(result, Err(Ctap2Error(0x2F)));
|
||||
})
|
||||
}
|
||||
|
||||
/// With `long_touch_for_reset = true` and a UI that grants `Level::Strong`,
|
||||
/// `authenticatorReset` succeeds.
|
||||
#[test]
|
||||
fn test_reset_long_touch_accepts_strong_touch() {
|
||||
let options = Options {
|
||||
long_touch_for_reset: true,
|
||||
user_presence: Level::Strong,
|
||||
..Default::default()
|
||||
};
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
device.exec(Reset).unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
/// With `long_touch_for_reset = false` (a runner that opted out), a normal
|
||||
/// short touch is sufficient and the strong check is never invoked. By default
|
||||
/// the test UI grants Normal and denies Strong, so success here proves `reset()` took the
|
||||
/// short-touch path rather than `user_present_strong`.
|
||||
#[test]
|
||||
fn test_reset_short_touch_accepts_short_touch() {
|
||||
let options = Options {
|
||||
long_touch_for_reset: false,
|
||||
..Default::default()
|
||||
};
|
||||
virt::run_ctap2_with_options(options, |device| {
|
||||
device.exec(Reset).unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Empty rp.id / rpId rejected (CTAP 2.1 §6.1.1.2 / §6.2.1.2)
|
||||
// ============================================================================
|
||||
|
||||
/// MakeCredential with an empty `rp.id` is rejected before any user presence
|
||||
/// check with `CTAP2_ERR_MISSING_PARAMETER` (0x14).
|
||||
#[test]
|
||||
fn test_make_credential_empty_rp_id_rejected() {
|
||||
let client_data_hash = &[0; 32];
|
||||
virt::run_ctap2(|device| {
|
||||
let user = User::new(b"id123")
|
||||
.name("john.doe")
|
||||
.display_name("John Doe");
|
||||
let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)];
|
||||
let request = MakeCredential::new(client_data_hash, Rp::new(""), user, pub_key_cred_params);
|
||||
let result = device.exec(request);
|
||||
assert_eq!(result, Err(Ctap2Error(0x14)));
|
||||
})
|
||||
}
|
||||
|
||||
/// GetAssertion with an empty `rpId` is rejected before any user presence
|
||||
/// check with `CTAP2_ERR_MISSING_PARAMETER` (0x14).
|
||||
#[test]
|
||||
fn test_get_assertion_empty_rp_id_rejected() {
|
||||
let client_data_hash = &[0; 32];
|
||||
virt::run_ctap2(|device| {
|
||||
let request = GetAssertion::new("", client_data_hash);
|
||||
let result = device.exec(request);
|
||||
assert_eq!(result, Err(Ctap2Error(0x14)));
|
||||
})
|
||||
}
|
||||
|
||||
+69
-17
@@ -27,7 +27,7 @@ use rand::{
|
||||
};
|
||||
use trussed::{
|
||||
backend::BackendId,
|
||||
platform::Platform as _,
|
||||
platform::{consent::Level, reboot::To, ui::Status, Platform as _, UserInterface},
|
||||
store::Store as _,
|
||||
virt::{self, StoreConfig},
|
||||
};
|
||||
@@ -64,21 +64,21 @@ where
|
||||
files.push((path!("fido/sec/00").into(), ATTESTATION_KEY.into()));
|
||||
with_client(
|
||||
&files,
|
||||
options.user_presence,
|
||||
|client| {
|
||||
let mut authenticator = Authenticator::new(
|
||||
client,
|
||||
Conforming {},
|
||||
Config {
|
||||
max_msg_size: 0,
|
||||
skip_up_timeout: None,
|
||||
max_resident_credential_count: options.max_resident_credential_count,
|
||||
large_blobs: None,
|
||||
nfc_transport: options.nfc_transport,
|
||||
ccid_transport: options.ccid_transport,
|
||||
firmware_version: Some(0.into()),
|
||||
credential_id_version: None,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
max_msg_size: 0,
|
||||
skip_up_timeout: None,
|
||||
max_resident_credential_count: options.max_resident_credential_count,
|
||||
large_blobs: None,
|
||||
nfc_transport: options.nfc_transport,
|
||||
ccid_transport: options.ccid_transport,
|
||||
firmware_version: Some(0.into()),
|
||||
credential_id_version: None,
|
||||
long_touch_for_reset: options.long_touch_for_reset,
|
||||
fido2_up_timeout: Some(100),
|
||||
};
|
||||
let mut authenticator = Authenticator::new(client, Conforming {}, config);
|
||||
|
||||
let channel = Channel::new();
|
||||
let (rq, rp) = channel.split().unwrap();
|
||||
@@ -132,15 +132,60 @@ where
|
||||
|
||||
pub type InspectFsFn = Box<dyn Fn(&dyn DynFilesystem)>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Options {
|
||||
pub files: Vec<(PathBuf, Vec<u8>)>,
|
||||
pub max_resident_credential_count: Option<u32>,
|
||||
pub nfc_transport: bool,
|
||||
pub ccid_transport: bool,
|
||||
/// The value to return from the `UserInterface` implementation for user presence checks
|
||||
/// (default: `Level::Normal`).
|
||||
pub user_presence: Level,
|
||||
/// Sets `Config::long_touch_for_reset` (default `true`).
|
||||
pub long_touch_for_reset: bool,
|
||||
pub inspect_ifs: Option<InspectFsFn>,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
files: Vec::new(),
|
||||
max_resident_credential_count: None,
|
||||
nfc_transport: false,
|
||||
ccid_transport: false,
|
||||
user_presence: Level::Normal,
|
||||
long_touch_for_reset: true,
|
||||
inspect_ifs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User interface that returns a static value for the user presence check.
|
||||
struct TestUI {
|
||||
user_presence: Level,
|
||||
}
|
||||
|
||||
impl UserInterface for TestUI {
|
||||
fn check_user_presence(&mut self) -> Level {
|
||||
self.user_presence
|
||||
}
|
||||
|
||||
fn set_status(&mut self, _status: Status) {}
|
||||
|
||||
fn refresh(&mut self) {}
|
||||
|
||||
fn uptime(&mut self) -> Duration {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn reboot(&mut self, _to: To) -> ! {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
fn wink(&mut self, _duration: Duration) {
|
||||
unimplemented!();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ctap2<'a>(ctaphid::Device<Device<'a>>);
|
||||
|
||||
impl Ctap2<'_> {
|
||||
@@ -268,12 +313,19 @@ impl HidDevice for Device<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_client<F, F2, T>(files: &[(PathBuf, Vec<u8>)], f: F, inspect_ifs: F2) -> T
|
||||
fn with_client<F, F2, T>(
|
||||
files: &[(PathBuf, Vec<u8>)],
|
||||
user_presence: Level,
|
||||
f: F,
|
||||
inspect_ifs: F2,
|
||||
) -> T
|
||||
where
|
||||
F: FnOnce(Client) -> T,
|
||||
F2: FnOnce(&dyn DynFilesystem),
|
||||
{
|
||||
virt::with_platform(StoreConfig::ram(), |mut platform| {
|
||||
let ui: Box<dyn UserInterface + Send + Sync + 'static> = Box::new(TestUI { user_presence });
|
||||
platform.user_interface().set_inner(ui);
|
||||
// virt always uses the same seed -- request some random bytes to reach a somewhat random
|
||||
// state
|
||||
let uniform = Uniform::from(0..64);
|
||||
|
||||
@@ -1013,6 +1013,30 @@ impl Request for GetInfo {
|
||||
type Reply = GetInfoReply;
|
||||
}
|
||||
|
||||
/// `authenticatorReset` (CTAP 2.1 §6.7), command 0x07. No body, no reply
|
||||
/// body — just a status byte.
|
||||
pub struct Reset;
|
||||
|
||||
impl From<Reset> for Value {
|
||||
fn from(_: Reset) -> Self {
|
||||
Self::Null
|
||||
}
|
||||
}
|
||||
|
||||
impl Request for Reset {
|
||||
const COMMAND: u8 = 0x07;
|
||||
|
||||
type Reply = ResetReply;
|
||||
}
|
||||
|
||||
pub struct ResetReply;
|
||||
|
||||
impl From<Value> for ResetReply {
|
||||
fn from(_: Value) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetInfoReply {
|
||||
pub versions: Vec<String>,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
@@ -1023,6 +1047,7 @@ pub struct GetInfoReply {
|
||||
pub force_pin_change: Option<bool>,
|
||||
pub min_pin_length: Option<u32>,
|
||||
pub attestation_formats: Option<Vec<String>>,
|
||||
pub long_touch_for_reset: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<Value> for GetInfoReply {
|
||||
@@ -1042,6 +1067,8 @@ impl From<Value> for GetInfoReply {
|
||||
// 0x0D: minPINLength (CTAP 2.1)
|
||||
min_pin_length: map.remove(&0x0D).map(|value| value.deserialized().unwrap()),
|
||||
attestation_formats: map.remove(&0x16).map(|value| value.deserialized().unwrap()),
|
||||
// 0x18: longTouchForReset (CTAP 2.3)
|
||||
long_touch_for_reset: map.remove(&0x18).map(|value| value.deserialized().unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user