ctap2.3: long-touch reset (§6.11.5, §7.7)

This commit is contained in:
Emanuele Cesena
2026-06-03 09:35:29 +02:00
committed by Robin Krahl
parent 4477192edb
commit 4bd3629b66
7 changed files with 590 additions and 33 deletions
+2 -1
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+27
View File
@@ -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()),
}
}
}