diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9aaef..5fc90bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/fuzz/fuzz_targets/ctap.rs b/fuzz/fuzz_targets/ctap.rs index 002b8de..5dbd59d 100644 --- a/fuzz/fuzz_targets/ctap.rs +++ b/fuzz/fuzz_targets/ctap.rs @@ -25,6 +25,8 @@ fuzz_target!(|requests: Vec>| { ccid_transport: false, firmware_version: Some(0.into()), credential_id_version: None, + long_touch_for_reset: true, + fido2_up_timeout: None, }, ); diff --git a/src/ctap2.rs b/src/ctap2.rs index d084079..a3e9da8 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -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 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 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 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 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 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 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 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 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 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 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 crate::Authenticator { 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 { diff --git a/src/lib.rs b/src/lib.rs index 65ad612..089c9bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, + /// 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, } 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( + 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( + 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 Authenticator diff --git a/tests/basic.rs b/tests/basic.rs index 387ccea..eee1835 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -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))); + }) +} diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index 48468ea..3f24735 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -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; -#[derive(Default)] pub struct Options { pub files: Vec<(PathBuf, Vec)>, pub max_resident_credential_count: Option, 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, } +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>); impl Ctap2<'_> { @@ -268,12 +313,19 @@ impl HidDevice for Device<'_> { } } -fn with_client(files: &[(PathBuf, Vec)], f: F, inspect_ifs: F2) -> T +fn with_client( + files: &[(PathBuf, Vec)], + 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 = 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); diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index 9b3d67c..5c8c969 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -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 for Value { + fn from(_: Reset) -> Self { + Self::Null + } +} + +impl Request for Reset { + const COMMAND: u8 = 0x07; + + type Reply = ResetReply; +} + +pub struct ResetReply; + +impl From for ResetReply { + fn from(_: Value) -> Self { + Self + } +} + pub struct GetInfoReply { pub versions: Vec, pub extensions: Option>, @@ -1023,6 +1047,7 @@ pub struct GetInfoReply { pub force_pin_change: Option, pub min_pin_length: Option, pub attestation_formats: Option>, + pub long_touch_for_reset: Option, } impl From for GetInfoReply { @@ -1042,6 +1067,8 @@ impl From 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()), } } }