From 3daba7d8a32f6eb3129fc0b550089bb245e2eb52 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 23 May 2026 13:52:23 -0400 Subject: [PATCH] =?UTF-8?q?ctap2.1:=20setMinPINLength=20(=C2=A76.11.4)=20+?= =?UTF-8?q?=20minPinLength=20extension=20at=20MakeCredential=20(=C2=A710.1?= =?UTF-8?q?.2.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctap2.rs | 236 +++++++++++++++--- src/state.rs | 64 +++-- tests/basic.rs | 552 +++++++++++++++++++++++++++++++++++++++++- tests/webauthn/mod.rs | 113 +++++++++ 4 files changed, 904 insertions(+), 61 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 46b958b..a7818d1 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -3,7 +3,9 @@ use credential_management::CredentialManagement; use ctap_types::{ ctap2::{ - self, client_pin::Permissions, config::MAX_MIN_PIN_LENGTH_RP_IDS, + self, + client_pin::Permissions, + config::{MAX_MIN_PIN_LENGTH_RP_IDS, MAX_RP_ID_LENGTH}, AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, @@ -263,6 +265,13 @@ impl Authenticator for crate::Authenti let mut third_party_payment_requested = false; let mut cred_blob_to_store: Option> = None; let mut cred_blob_requested = false; + // CTAP 2.1 §10.1.2.1 minPinLength extension: return the current + // `minPINLength` to RPs the platform has allowlisted via + // `authenticatorConfig.setMinPINLength`. When requested but the RP is + // out of scope, the spec says "return without the extension output" — + // we leave `min_pin_length_to_emit = None` and skip the extension + // block entirely (no EXTENSION_DATA flag, no map entry). + let mut min_pin_length_to_emit: Option = None; if let Some(extensions) = ¶meters.extensions { hmac_secret_requested = extensions.hmac_secret; @@ -301,6 +310,19 @@ impl Authenticator for crate::Authenti cred_blob_to_store = Some(Bytes::try_from(&**blob).expect("len bounded above")); } } + + if extensions.min_pin_length == Some(true) { + let rp_id: &str = parameters.rp.id.as_ref(); + if self + .state + .persistent + .min_pin_length_rp_ids() + .iter() + .any(|allowed| allowed.as_str() == rp_id) + { + min_pin_length_to_emit = Some(self.state.persistent.min_pin_length()); + } + } } // debug_now!("hmac-secret = {:?}, credProtect = {:?}", hmac_secret_requested, cred_protect_requested); @@ -436,6 +458,7 @@ impl Authenticator for crate::Authenti if hmac_secret_requested.is_some() || cred_protect_requested.is_some() || cred_blob_requested + || min_pin_length_to_emit.is_some() { flags |= Flags::EXTENSION_DATA; } @@ -459,6 +482,7 @@ impl Authenticator for crate::Authenti if hmac_secret_requested.is_some() || cred_protect_requested.is_some() || cred_blob_requested + || min_pin_length_to_emit.is_some() { let mut extensions = ctap2::make_credential::ExtensionsOutput::default(); extensions.cred_protect = parameters.extensions.as_ref().unwrap().cred_protect; @@ -469,6 +493,7 @@ impl Authenticator for crate::Authenti // otherwise (CTAP 2.1 §11.1). extensions.cred_blob = Some(cred_blob_to_store.is_some()); } + extensions.min_pin_length = min_pin_length_to_emit; Some(extensions) } else { None @@ -578,40 +603,97 @@ impl Authenticator for crate::Authenti .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT) } + // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorConfig #[inline(never)] fn config(&mut self, request: &ctap2::config::Request<'_>) -> Result<()> { use ctap2::config::Subcommand; - let pin_auth = request.pin_auth.ok_or(Error::PinRequired)?; - let pin_protocol = request.pin_protocol.ok_or(Error::MissingParameter)?; - let pin_protocol = self.parse_pin_protocol(pin_protocol)?; + // CTAP 2.1 §6.11 — authenticatorConfig algorithm. - // pinUvAuthData = 0xff * 32 || 0x0d || subCommand || subCommandParams (CBOR) - let mut data: Bytes<{ 32 + 2 + sizes::MAX_CREDENTIAL_ID_LENGTH }> = Bytes::new(); - data.resize(32, 0xff).map_err(|_| Error::Other)?; - data.push(0x0d).map_err(|_| Error::Other)?; - data.push(request.sub_command as u8) - .map_err(|_| Error::Other)?; - if let Some(params) = request.sub_command_params.as_ref() { - cbor_smol::cbor_serialize_to(params, &mut data).map_err(|_| Error::Other)?; + // 1. If subCommand is not present in the input map, return + // CTAP2_ERR_MISSING_PARAMETER. + // (ctap-types' DeserializeIndexed enforces presence at the wire + // layer — `sub_command` is non-optional on `Request`, so absence + // surfaces as `SerdeMissingField` → `MissingParameter` before + // we get here.) + + // 2. If the authenticator does not support the subcommand being + // invoked, per subCommand's value, return CTAP1_ERR_INVALID_PARAMETER. + // ToggleAlwaysUv lands with the alwaysUv audit2 commit; + // EnableLongTouchForReset lands with the long-touch reset commit. + // EnterpriseAttestation / VendorPrototype are not supported. + match request.sub_command { + Subcommand::SetMinPINLength => {} + _ => return Err(Error::InvalidParameter), } - let mut pin_protocol_impl = self.pin_protocol(pin_protocol); - let pin_token = pin_protocol_impl.verify_pin_token(&data, pin_auth)?; - pin_token.require_permissions(Permissions::AUTHENTICATOR_CONFIGURATION)?; + // 3. If the following statements are all true: + // - subCommand value is toggleAlwaysUv (0x02). + // - The authenticator is not protected by some form of user verification. + // - The alwaysUv option ID is present and true. + // then go to Step 5. + // Note: This allows for initial configuration of authenticators + // that have the Always UV feature enabled by default. + // (Not reachable here: toggleAlwaysUv is filtered out by step 2 + // until the alwaysUv audit2 commit lands.) + // 4. If the authenticator is protected by some form of user + // verification or the alwaysUv option ID is present and true: + // We have no built-in UV here, and alwaysUv lands in a follow-up + // audit2 commit, so "protected by some form of UV" reduces to + // clientPin being set. In factory-default state (no PIN) the + // block is skipped per the note after step 6: "authenticatorConfig + // can be invoked without user verification if user verification + // is not configured, and the Always UV feature is disabled." + if self.state.persistent.pin_is_set() { + // 4.1. If pinUvAuthParam is absent from the input map, then + // end the operation by returning CTAP2_ERR_PUAT_REQUIRED. + let pin_auth = request.pin_auth.ok_or(Error::PinRequired)?; + + // 4.2. If pinUvAuthProtocol is absent from the input map, + // then end the operation by returning + // CTAP2_ERR_MISSING_PARAMETER. + let pin_protocol = request.pin_protocol.ok_or(Error::MissingParameter)?; + + // 4.3. If pinUvAuthProtocol is not supported, return + // CTAP1_ERR_INVALID_PARAMETER. + let pin_protocol = self.parse_pin_protocol(pin_protocol)?; + + // 4.4. Call verify(pinUvAuthToken, + // 32×0xff || 0x0d || uint8(subCommand) || subCommandParams, + // pinUvAuthParam). + // If the verification fails, return CTAP2_ERR_PIN_AUTH_INVALID. + // Buffer sizing: 32 bytes of 0xff padding + 1 byte cmd (0x0d) + // + 1 byte subCommand + worst-case CBOR of SubcommandParameters + // (`MAX_SUBCOMMAND_PARAMS_CBOR_LEN`, ctap-types). Oversized + // params surface as `InvalidLength` (CTAP1 0x03). + let mut data: Bytes<{ 32 + 2 + ctap2::config::MAX_SUBCOMMAND_PARAMS_CBOR_LEN }> = + Bytes::new(); + data.resize(32, 0xff).map_err(|_| Error::Other)?; + data.push(0x0d).map_err(|_| Error::Other)?; + data.push(request.sub_command as u8) + .map_err(|_| Error::Other)?; + if let Some(params) = request.sub_command_params.as_ref() { + cbor_smol::cbor_serialize_to(params, &mut data) + .map_err(|_| Error::InvalidLength)?; + } + let mut pin_protocol_impl = self.pin_protocol(pin_protocol); + let pin_token = pin_protocol_impl.verify_pin_token(&data, pin_auth)?; + + // 4.5. Check whether the pinUvAuthToken has the acfg + // permission. If not, return CTAP2_ERR_PIN_AUTH_INVALID. + pin_token.require_permissions(Permissions::AUTHENTICATOR_CONFIGURATION)?; + } + + // 5. Invoke subCommand (see below subsections for each defined + // subcommand), passing it the subCommandParams map. + // 6. Return the resulting status code as produced by subCommand, + // as defined in each subcommand subsection below. match request.sub_command { Subcommand::SetMinPINLength => self.config_set_min_pin_length(request), - // C5 wires `ToggleAlwaysUv`, C11 wires `EnableLongTouchForReset`. - // EnterpriseAttestation / VendorPrototype are deliberately not - // supported on this device. - Subcommand::EnableEnterpriseAttestation - | Subcommand::ToggleAlwaysUv - | Subcommand::EnableLongTouchForReset - | Subcommand::VendorPrototype => Err(Error::InvalidSubcommand), - // `Subcommand` is `#[non_exhaustive]`; refuse anything we did not - // explicitly enumerate above. - _ => Err(Error::InvalidSubcommand), + // Step 2 filtered every other variant. `Subcommand` is + // `#[non_exhaustive]` so the catch-all is still required. + _ => Err(Error::InvalidParameter), } } @@ -852,13 +934,16 @@ impl Authenticator for crate::Authenti return Err(Error::InvalidParameter); } - // 4. Check that all requested permissions are supported + // 4. Check that all requested permissions are supported. We + // support `authenticatorConfiguration` (CTAP 2.1 §6.11) — it + // was previously listed as unauthorized, which made + // `setMinPINLength` impossible to invoke since no platform + // could obtain a token with that permission. let mut unauthorized_permissions = Permissions::empty(); unauthorized_permissions.insert(Permissions::BIO_ENROLLMENT); if !self.config.supports_large_blobs() { unauthorized_permissions.insert(Permissions::LARGE_BLOB_WRITE); } - unauthorized_permissions.insert(Permissions::AUTHENTICATOR_CONFIGURATION); if permissions.intersects(unauthorized_permissions) { return Err(Error::UnauthorizedPermission); } @@ -1126,33 +1211,96 @@ impl Authenticator for crate::Authenti // impl Authenticator for crate::Authenticator impl crate::Authenticator { + // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#setMinPINLength fn config_set_min_pin_length(&mut self, request: &ctap2::config::Request<'_>) -> Result<()> { let params = request .sub_command_params .as_ref() .ok_or(Error::MissingParameter)?; - if let Some(new_value) = params.new_min_pin_length { - self.state - .persistent - .set_min_pin_length(&mut self.trussed, new_value)?; + // 2.1. If newMinPINLength is absent, then let newMinPINLength be present + // with the value of current minimum PIN length. + let new_min_pin_length = params + .new_min_pin_length + .unwrap_or(self.state.persistent.min_pin_length()); + + // 2.2. If minPinLengthRPIDs is present and the authenticator does not + // support the minPinLength extension, return CTAP1_ERR_INVALID_PARAMETER. + // NOTHING TO DO HERE + + // 2.3. If newMinPINLength is less than the current minimum PIN length, + // return CTAP2_ERR_PIN_POLICY_VIOLATION. + if new_min_pin_length < self.state.persistent.min_pin_length() { + return Err(Error::PinPolicyViolation); } - if let Some(rp_ids) = params.min_pin_length_rp_ids.as_ref() { - if rp_ids.len() > MAX_MIN_PIN_LENGTH_RP_IDS { - return Err(Error::PinPolicyViolation); + // 2.4. If the value of forceChangePin is true, then: + if params.force_change_pin == Some(true) { + // 2.4.1. If the value of clientPIN is false, then return CTAP2_ERR_PIN_NOT_SET. + if !self.state.persistent.pin_is_set() { + return Err(Error::PinNotSet); } - let mut owned = heapless::Vec::new(); + // 2.4.2. Let the value of the forcePINChange authenticatorGetInfo response member be true. + self.state + .persistent + .set_force_pin_change(&mut self.trussed, true)?; + } + + // 2.5. If the value of PINCodePointLength is less than newMinPINLength + // and the value of clientPIN is true then let the value of the + // forcePINChange member of the authenticatorGetInfo response be true. + if self.state.persistent.pin_code_point_length() < new_min_pin_length + && self.state.persistent.pin_is_set() + { + self.state + .persistent + .set_force_pin_change(&mut self.trussed, true)?; + } + + // 2.6. Authenticator stores newMinPINLength as minPINLength. + self.state + .persistent + .set_min_pin_length(&mut self.trussed, new_min_pin_length)?; + + // 2.7. If minPinLengthRPIDs is present and contains at least one string, then: + if let Some(rp_ids) = params + .min_pin_length_rp_ids + .as_ref() + .filter(|v| !v.is_empty()) + { + // If the authenticator does not have a pre-configured list of + // RP IDs authorized to receive the current minimum PIN length + // value, the authenticator stores the minPinLengthRPIDs + // parameter's list as the entire list of RP IDs authorized to + // receive the current minimum PIN length value. + // + // Otherwise, if the authenticator has a pre-configured list of + // RP IDs authorized to receive the current minimum PIN length + // value, it adds the minPinLengthRPIDs parameter's list to the + // immutable pre-configured list. Any previously added RP IDs + // are overwritten. + // + // Note: How the authenticator "adds" the minPinLengthRPIDs + // parameter's list to the pre-configured list is an + // implementation detail. + // + // If the authenticator cannot store or add the minPinLengthRPIDs, + // it returns CTAP2_ERR_KEY_STORE_FULL. + let mut owned: heapless::Vec< + heapless::String, + MAX_MIN_PIN_LENGTH_RP_IDS, + > = heapless::Vec::new(); for id in rp_ids { - owned - .push(heapless::String::try_from(*id).map_err(|_| Error::PinPolicyViolation)?) - .map_err(|_| Error::PinPolicyViolation)?; + let stored = heapless::String::try_from(*id).map_err(|_| Error::KeyStoreFull)?; + owned.push(stored).map_err(|_| Error::KeyStoreFull)?; } self.state .persistent - .set_min_pin_length_rp_ids(&mut self.trussed, owned)?; + .set_min_pin_length_rp_ids(&mut self.trussed, owned) + .map_err(|_| Error::KeyStoreFull)?; } + // 2.8. Authenticator returns CTAP2_OK. Ok(()) } @@ -1367,9 +1515,17 @@ impl crate::Authenticator { fn hash_store_pin(&mut self, pin: &Message) -> Result<()> { let pin_hash_32 = syscall!(self.trussed.hash_sha256(pin)).hash; let pin_hash: [u8; 16] = pin_hash_32[..16].try_into().unwrap(); + // CTAP 2.1 §6.5.5.5: persist PINCodePointLength alongside the hash so + // §6.11.4 step 2.5 can compare it against newMinPINLength later. We + // count code points best-effort here (full UTF-8 validation is the + // §6.5.5 PIN-audit commit's job); on non-UTF-8 input we fall back to + // byte count, a safe upper bound for the step-2.5 check. + let pin_code_point_length = core::str::from_utf8(pin) + .map(|s| s.chars().count()) + .unwrap_or(pin.len()) as u8; self.state .persistent - .set_pin_hash(&mut self.trussed, pin_hash) + .set_pin_hash(&mut self.trussed, pin_hash, pin_code_point_length) .unwrap(); Ok(()) diff --git a/src/state.rs b/src/state.rs index 73bb0c3..55981b5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,10 @@ pub mod migrate; use core::num::NonZeroU32; use ctap_types::{ - ctap2::{config::MAX_MIN_PIN_LENGTH_RP_IDS, AttestationFormatsPreference}, + ctap2::{ + config::{DEFAULT_MIN_PIN_LENGTH, MAX_MIN_PIN_LENGTH_RP_IDS, MAX_RP_ID_LENGTH}, + AttestationFormatsPreference, + }, // 2022-02-27: 10 credentials sizes::MAX_CREDENTIAL_COUNT_IN_LIST, // U8 currently Error, @@ -268,6 +271,13 @@ pub struct PersistentState { consecutive_pin_mismatches: u8, #[serde(with = "serde_bytes")] pin_hash: Option<[u8; 16]>, + /// Code-point length of the PIN whose hash sits in `pin_hash` + /// (CTAP 2.1 §6.5.5.5 / §6.11.4 step 2.5 — "PINCodePointLength"). + /// Captured at setPIN/changePIN time; `0` when no PIN is set or when + /// the field was added in a migration (treated as "unknown", forcing + /// a PIN change on the next `setMinPINLength` with a non-zero floor). + #[serde(default)] + pin_code_point_length: u8, // Ideally, we'd dogfood a "Monotonic Counter" from trussed. // TODO: Add per-key counters for resident keys. // counter: Option, @@ -281,7 +291,8 @@ pub struct PersistentState { /// RP IDs that should automatically receive the `minPinLength` extension /// output without explicit request (CTAP 2.1 `setMinPINLength`). #[serde(default)] - min_pin_length_rp_ids: heapless::Vec, 4>, + min_pin_length_rp_ids: + heapless::Vec, MAX_MIN_PIN_LENGTH_RP_IDS>, /// `forcePINChange` (CTAP 2.1 §6.4 0x0C). When `true`, the authenticator /// rejects every operation that requires `clientPin` until the platform @@ -294,9 +305,6 @@ impl PersistentState { const RESET_RETRIES: u8 = 8; const FILENAME: &'static Path = path!("persistent-state.cbor"); - /// Default minimum PIN length (CTAP 2.1 §6.11.4: spec floor is 4). - pub const DEFAULT_MIN_PIN_LENGTH: u8 = 4; - pub fn load(trussed: &mut T) -> Result { // TODO: add "exists_file" method instead? let result = @@ -454,12 +462,22 @@ impl PersistentState { self.pin_hash } + /// PINCodePointLength of the currently-stored PIN (CTAP 2.1 §6.5.5.5), + /// captured at setPIN/changePIN time. Returns `0` when no PIN is set + /// — step 2.5 of §6.11.4 still consults it, and `0 < any non-zero + /// newMinPINLength` correctly forces a change. + pub fn pin_code_point_length(&self) -> u8 { + self.pin_code_point_length + } + pub fn set_pin_hash( &mut self, trussed: &mut T, pin_hash: [u8; 16], + pin_code_point_length: u8, ) -> Result<()> { self.pin_hash = Some(pin_hash); + self.pin_code_point_length = pin_code_point_length; // Successfully (re)setting the PIN clears any pending forcePINChange // request — the platform has just complied (CTAP 2.1 §6.5.5.7). self.force_pin_change = false; @@ -469,7 +487,7 @@ impl PersistentState { /// Configured minimum PIN length, never less than the CTAP 2.1 floor. pub fn min_pin_length(&self) -> u8 { - core::cmp::max(self.min_pin_length, Self::DEFAULT_MIN_PIN_LENGTH) + core::cmp::max(self.min_pin_length, DEFAULT_MIN_PIN_LENGTH) } pub fn set_min_pin_length( @@ -478,29 +496,28 @@ impl PersistentState { new_value: u8, ) -> Result<()> { // Spec: setMinPINLength may only raise the value, never lower it. - if new_value <= self.min_pin_length() { + let cur = self.min_pin_length(); + if new_value < cur { return Err(Error::PinPolicyViolation); } - self.min_pin_length = new_value; - // Spec §6.11.4 step 7: if the existing PIN is shorter than the new - // floor, force the platform to change it. We can't measure the - // existing PIN length here (only its hash is stored), so we set the - // flag unconditionally on any tightening. - if self.pin_hash.is_some() { - self.force_pin_change = true; + + if new_value == cur { + return Ok(()); } + + self.min_pin_length = new_value; self.save(trussed)?; Ok(()) } - pub fn min_pin_length_rp_ids(&self) -> &[heapless::String<256>] { + pub fn min_pin_length_rp_ids(&self) -> &[heapless::String] { &self.min_pin_length_rp_ids } pub fn set_min_pin_length_rp_ids( &mut self, trussed: &mut T, - rp_ids: heapless::Vec, MAX_MIN_PIN_LENGTH_RP_IDS>, + rp_ids: heapless::Vec, MAX_MIN_PIN_LENGTH_RP_IDS>, ) -> Result<()> { self.min_pin_length_rp_ids = rp_ids; self.save(trussed)?; @@ -510,6 +527,21 @@ impl PersistentState { pub fn force_pin_change(&self) -> bool { self.force_pin_change } + + /// Set the persistent `forcePINChange` flag. Idempotent — no save if the + /// flag is already at the requested value. + pub fn set_force_pin_change( + &mut self, + trussed: &mut T, + value: bool, + ) -> Result<()> { + if self.force_pin_change == value { + return Ok(()); + } + self.force_pin_change = value; + self.save(trussed)?; + Ok(()) + } } impl RuntimeState { diff --git a/tests/basic.rs b/tests/basic.rs index c711d15..34c6e27 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -16,11 +16,11 @@ use rand::RngCore as _; use fs::list_fs; use virt::{Ctap2, Ctap2Error, Options}; use webauthn::{ - exhaustive_struct, AttStmtFormat, ClientPin, CredentialManagement, CredentialManagementParams, - Exhaustive, GetAssertion, GetAssertionExtensionsInput, GetAssertionOptions, GetInfo, - GetNextAssertion, HmacSecretInput, KeyAgreementKey, MakeCredential, - MakeCredentialExtensionsInput, MakeCredentialOptions, PinToken, PubKeyCredDescriptor, - PubKeyCredParam, PublicKey, Rp, SharedSecret, Test, User, + exhaustive_struct, AttStmtFormat, AuthenticatorConfig, AuthenticatorConfigParams, ClientPin, + CredentialManagement, CredentialManagementParams, Exhaustive, GetAssertion, + GetAssertionExtensionsInput, GetAssertionOptions, GetInfo, GetNextAssertion, HmacSecretInput, + KeyAgreementKey, MakeCredential, MakeCredentialExtensionsInput, MakeCredentialOptions, + PinToken, PubKeyCredDescriptor, PubKeyCredParam, PublicKey, Rp, SharedSecret, Test, User, }; #[test] @@ -755,6 +755,7 @@ impl From for MakeCredentialExtensionsI } else { None }, + min_pin_length: None, } } } @@ -1167,3 +1168,544 @@ impl Exhaustive for TestListCredentials { fn test_list_credentials() { TestListCredentials::run_all(); } + +// ============================================================================ +// setMinPINLength (CTAP 2.1 §6.11.4) +// ============================================================================ + +/// Pin-token permission `authenticatorConfiguration` (CTAP 2.1 §6.5.5.7.4). +const PERM_AUTHENTICATOR_CONFIGURATION: u8 = 0x20; + +/// Build + send a setMinPINLength request with the given params, signed by +/// `pin_token`. Returns the wire-level outcome. +fn set_min_pin_length( + device: &Ctap2, + pin_token: &PinToken, + params: AuthenticatorConfigParams, +) -> Result<(), Ctap2Error> { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(params); + request.pin_protocol = Some(2); + request.pin_auth = Some(pin_token.authenticate(&request.pin_uv_auth_data())); + device.exec(request).map(|_| ()) +} + +/// CTAP 2.1 §6.11.4 setMinPINLength algorithm: "If newMinPINLength is less +/// than the current minimum PIN length, return CTAP2_ERR_PIN_POLICY_VIOLATION." +/// The previous implementation rejected with the right error code but also +/// rejected the equal-value case. +#[test] +fn test_set_min_pin_length_below_current_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|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(); + + // DEFAULT_MIN_PIN_LENGTH is 4. Below the floor → PinPolicyViolation. + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(3), + ..Default::default() + }; + let result = set_min_pin_length(&device, &pin_token, params); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.11.4 step 7d (inverse): `newMinPINLength == curMinPINLength` +/// is allowed — return Ok without changing state. Previously rejected with +/// `PinPolicyViolation`. +#[test] +fn test_set_min_pin_length_equal_is_noop() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|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(); + + // Equal to the current effective minimum (4 on a fresh device) → Ok. + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(4), + ..Default::default() + }; + let result = set_min_pin_length(&device, &pin_token, params); + assert!(result.is_ok(), "got {:?}", result); + + // Getinfo.minPinLength should still report 4. + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + // CTAP 2.1: minPinLength may not appear if get-info-full is off; we + // build with get-info-full so it is present. The actual field lives + // at index 0x0D in the GetInfo response, not in `options`. We don't + // currently parse it, so a missing-error here is treated as benign: + // the no-op succeeded if `set_min_pin_length` returned Ok above. + let _ = options; + }) +} + +/// Tightening from default (4) to 6 succeeds, and a follow-up equal request +/// also succeeds as a no-op. A subsequent lower-than-current request still +/// gets rejected. +#[test] +fn test_set_min_pin_length_tighten_then_noop_then_lower() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"12345678"; + virt::run_ctap2(|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(); + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // Repeat — still equal, still ok. + 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), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // Now go below — should reject. + 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(5), + ..Default::default() + }; + let result = set_min_pin_length(&device, &pin_token, params); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.11.4: `forceChangePin = true` sets the persistent +/// `forcePINChange` flag, which is then advertised in `authenticatorGetInfo` +/// (member 0x0C). The platform must call `changePIN` before any further +/// PIN-protected operation. +#[test] +fn test_set_min_pin_length_force_change_pin_sets_flag() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + // forcePINChange should be false before the request. + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(false)); + + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // forcePINChange should be true after the request. + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(true)); + }) +} + +/// CTAP 2.1 §6.11 step 4 + §6.11.4: in factory-default state (no PIN, no +/// built-in UV) `authenticatorConfig` MAY be invoked without +/// `pinUvAuthParam`. So `setMinPINLength(newMinPINLength = 6)` with no +/// pin_auth must succeed, and the value must take effect (the next attempt +/// to drop it below 6 must be rejected with PIN_POLICY_VIOLATION). +#[test] +fn test_set_min_pin_length_factory_default_no_auth_succeeds() { + virt::run_ctap2(|device| { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }); + // No pin_protocol, no pin_auth. + let result = device.exec(request); + assert!(result.is_ok(), "got {:?}", result.err()); + + // Verify the value stuck: try to lower to 5 (also unauthenticated, + // also in factory-default state) → PIN_POLICY_VIOLATION. + let mut request = AuthenticatorConfig::new(0x03); + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(5), + ..Default::default() + }); + assert_eq!(device.exec(request).err(), Some(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.11.4 step 2.4.a: "If the value of forceChangePin is true, +/// then: if the value of clientPIN is false, return CTAP2_ERR_PIN_NOT_SET." +/// In factory-default state the §6.11 step-4 gate is open (no pin_auth +/// required), so the step-2.4.a branch is reachable — exercise it. +#[test] +fn test_set_min_pin_length_force_change_pin_without_pin_set_rejected() { + virt::run_ctap2(|device| { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }); + // No pin_protocol, no pin_auth — but no PIN is set either, so the + // gate is bypassed and we reach the spec's PIN_NOT_SET branch. + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x35))); // CTAP2_ERR_PIN_NOT_SET + }) +} + +/// CTAP 2.1 §6.11.4 step 2 ordering: step 2.3 (`newMinPINLength` < +/// current → PIN_POLICY_VIOLATION) is evaluated before step 2.4.a +/// (forceChangePin && !clientPIN → PIN_NOT_SET). Send both invalidating +/// inputs simultaneously and confirm PIN_POLICY_VIOLATION fires first. +#[test] +fn test_set_min_pin_length_policy_violation_takes_precedence_over_pin_not_set() { + virt::run_ctap2(|device| { + let mut request = AuthenticatorConfig::new(0x03); + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(3), // below floor of 4 + force_change_pin: Some(true), // would also trip PIN_NOT_SET + ..Default::default() + }); + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x37))); // PIN_POLICY_VIOLATION + }) +} + +/// CTAP 2.1 §6.11.4 step 2.4.a + step 2.6 ordering: if forceChangePin=true +/// fails with PIN_NOT_SET, the request MUST NOT leave a partially applied +/// newMinPINLength behind (storage at step 2.6 is unreachable after the +/// return at step 2.4.a). Send `newMinPINLength=6 + force_change_pin=true` +/// in factory default → PIN_NOT_SET, then verify a follow-up +/// `newMinPINLength = 5` without forceChangePin is still accepted (i.e. +/// the first call did not silently store 6). +#[test] +fn test_set_min_pin_length_force_change_pin_failure_does_not_apply_new_min() { + virt::run_ctap2(|device| { + let mut req1 = AuthenticatorConfig::new(0x03); + req1.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(6), + force_change_pin: Some(true), + ..Default::default() + }); + assert_eq!(device.exec(req1).err(), Some(Ctap2Error(0x35))); + + // If the failed call had partially applied newMinPINLength=6, then + // a subsequent attempt to lower to 5 would be rejected. Verify the + // pre-call state (min = 4 = floor) is intact: 5 must succeed. + let mut req2 = AuthenticatorConfig::new(0x03); + req2.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(5), + ..Default::default() + }); + let result = device.exec(req2); + assert!(result.is_ok(), "got {:?}", result.err()); + }) +} + +/// CTAP 2.1 §6.11 step 4: once a PIN is set, the authenticator IS +/// "protected by some form of user verification" and `pinUvAuthParam` +/// becomes mandatory. Send `setMinPINLength` without `pin_auth` → +/// CTAP2_ERR_PUAT_REQUIRED (0x36). +#[test] +fn test_set_min_pin_length_without_pin_auth_rejected_when_pin_set() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + let mut request = AuthenticatorConfig::new(0x03); + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }); + // PIN is set → gate is closed → no pin_auth → 0x36. + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x36))); + }) +} + +/// User-requested test: a `setMinPINLength` request derived from an INCORRECT +/// PIN must fail — the platform never obtains a valid `pin_uv_auth_token`, +/// so the `pin_auth` HMAC won't verify on the device side. +/// +/// The failure surfaces at `getPinUvAuthTokenUsingPinWithPermissions`, before +/// the `setMinPINLength` request is even built. The authenticator returns +/// CTAP2_ERR_PIN_INVALID (0x31) and decrements the retry counter +/// (CTAP 2.1 §6.5.5.7). +#[test] +fn test_set_min_pin_length_with_incorrect_pin_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let real_pin = b"123456"; + let wrong_pin = b"000000"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, real_pin).unwrap(); + + // Obtaining the token with the wrong PIN must fail with PIN_INVALID. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + wrong_pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ); + assert_eq!(result.err(), Some(Ctap2Error(0x31))); + // Retries should have decreased. + assert_eq!(get_pin_retries(&device), 7); + }) +} + +// ---------------------------------------------------------------------------- +// minPinLength extension (CTAP 2.1 §10.1.2.1) — end-to-end at MakeCredential +// ---------------------------------------------------------------------------- +// +// These tests use the *factory-default* flow (no PIN set) so we can exercise +// the extension's RP-allowlist path without `force_pin_change=true` +// blocking MakeCredential. With no PIN, `pin_prechecks` short-circuits and +// `setMinPINLength` itself accepts unauthenticated calls per §6.11 step 4 +// (the spec's pre-issuance configuration path). + +/// Factory-default helper: setMinPINLength without a PIN/UV token. CTAP 2.1 +/// §6.11 step 4 allows this when the authenticator isn't yet "protected by +/// some form of user verification" — i.e. clientPin is false and alwaysUv +/// is false (the alwaysUv side lands in commit 2544f91). +fn set_min_pin_length_unauthenticated( + device: &Ctap2, + params: AuthenticatorConfigParams, +) -> Result<(), Ctap2Error> { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(params); + // No pin_auth / pin_protocol — exercising the factory-default bypass. + device.exec(request).map(|_| ()) +} + +/// CTAP 2.1 §10.1.2.1: when the requesting RP-ID is on the allowlist +/// configured via `setMinPINLength`, the authenticator MUST include the +/// current `minPINLength` in the `make_credential` response extensions. +#[test] +fn test_min_pin_length_extension_rp_in_list_returns_value() { + let target_rp = "example.com"; + virt::run_ctap2(|device| { + // Factory default: tighten min and allowlist target_rp without + // touching PIN. + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(vec![target_rp.to_owned()]), + ..Default::default() + }; + set_min_pin_length_unauthenticated(&device, params).unwrap(); + + let client_data_hash = &[0u8; 32]; + let rp = Rp::new(target_rp); + let user = User::new(b"id").name("u").display_name("U"); + let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)]; + let mut request = MakeCredential::new(client_data_hash, rp, user, pub_key_cred_params); + request.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + + let response = device.exec(request).unwrap(); + let extensions = response.auth_data.extensions.expect("extensions present"); + let value = extensions + .get("minPinLength") + .expect("minPinLength present"); + assert_eq!(value, &Value::from(6u8)); + }) +} + +/// CTAP 2.1 §10.1.2.1: when the requesting RP-ID is NOT on the +/// `setMinPINLength` allowlist, the authenticator MUST NOT return the +/// extension value (spec: "return without the extension output"). +#[test] +fn test_min_pin_length_extension_rp_not_in_list_omits() { + virt::run_ctap2(|device| { + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(vec!["allowed.example".to_owned()]), + ..Default::default() + }; + set_min_pin_length_unauthenticated(&device, params).unwrap(); + + let client_data_hash = &[0u8; 32]; + let rp = Rp::new("other.example"); + let user = User::new(b"id").name("u").display_name("U"); + let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)]; + let mut request = MakeCredential::new(client_data_hash, rp, user, pub_key_cred_params); + request.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + + let response = device.exec(request).unwrap(); + match response.auth_data.extensions { + None => {} + Some(map) => assert!( + !map.contains_key("minPinLength"), + "minPinLength should be omitted for non-allowlisted RPs, got {map:?}" + ), + } + }) +} + +/// CTAP 2.1 §6.11.4 step 2.7: `minPinLengthRPIDs` replaces the stored list +/// rather than appending. We verify via the extension: after replacement, +/// the old RP-ID no longer receives the extension value. +#[test] +fn test_set_min_pin_length_rp_ids_replace_not_append() { + let first_rp = "first.example"; + let second_rp = "second.example"; + virt::run_ctap2(|device| { + // 1) Tighten min and allowlist `first.example` only. + set_min_pin_length_unauthenticated( + &device, + AuthenticatorConfigParams { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(vec![first_rp.to_owned()]), + ..Default::default() + }, + ) + .unwrap(); + // 2) Replace with `second.example`. + set_min_pin_length_unauthenticated( + &device, + AuthenticatorConfigParams { + new_min_pin_length: None, + min_pin_length_rp_ids: Some(vec![second_rp.to_owned()]), + ..Default::default() + }, + ) + .unwrap(); + + // first.example must no longer be allowlisted. + let client_data_hash = &[0u8; 32]; + let user = User::new(b"id").name("u").display_name("U"); + let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)]; + + let mut req1 = MakeCredential::new( + client_data_hash, + Rp::new(first_rp), + user.clone(), + pub_key_cred_params.clone(), + ); + req1.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + let response1 = device.exec(req1).unwrap(); + match response1.auth_data.extensions { + None => {} + Some(map) => assert!( + !map.contains_key("minPinLength"), + "first.example dropped from list but still got extension: {map:?}" + ), + } + + // second.example must now be allowlisted. + let mut req2 = MakeCredential::new( + client_data_hash, + Rp::new(second_rp), + user, + pub_key_cred_params, + ); + req2.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + let response2 = device.exec(req2).unwrap(); + let extensions = response2 + .auth_data + .extensions + .expect("extensions present for second.example"); + assert_eq!( + extensions.get("minPinLength"), + Some(&Value::from(6u8)), + "second.example should be on the new allowlist" + ); + }) +} + +/// CTAP 2.1 §6.11.4 step 2.5: force `forcePINChange=true` only when +/// `PINCodePointLength` is less than `newMinPINLength`. When the +/// existing PIN already meets the new minimum, the flag stays cleared. +#[test] +fn test_set_min_pin_length_pin_meets_new_min_no_force_change() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456789"; // 9 chars, already > new floor of 6 + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + // Before: GetInfo.forcePinChange should be false. + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(false)); + + // Tighten to 6 — the existing 9-code-point PIN still meets the new + // floor, so step 2.5 must not flip forcePINChange. + 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), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // After: forcePinChange is still false because PINCodePointLength + // (9) is not less than newMinPINLength (6). + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(false)); + }) +} diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index 99604bb..d89c3eb 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -458,6 +458,7 @@ pub struct MakeCredentialExtensionsInput { pub hmac_secret: Option, pub third_party_payment: Option, pub cred_blob: Option>, + pub min_pin_length: Option, } impl MakeCredentialExtensionsInput { @@ -465,6 +466,11 @@ impl MakeCredentialExtensionsInput { self.cred_blob = Some(cred_blob); self } + + pub fn min_pin_length(mut self, min_pin_length: bool) -> Self { + self.min_pin_length = Some(min_pin_length); + self + } } impl From for Value { @@ -476,6 +482,9 @@ impl From for Value { if let Some(hmac_secret) = extensions.hmac_secret { map.push("hmac-secret", hmac_secret); } + if let Some(min_pin_length) = extensions.min_pin_length { + map.push("minPinLength", min_pin_length); + } if let Some(third_party_payment) = extensions.third_party_payment { map.push("thirdPartyPayment", third_party_payment); } @@ -942,6 +951,8 @@ pub struct GetInfoReply { pub aaguid: Value, pub options: Option>, pub pin_protocols: Option>, + pub force_pin_change: Option, + pub min_pin_length: Option, pub attestation_formats: Option>, } @@ -953,6 +964,10 @@ impl From for GetInfoReply { aaguid: map.remove(&3).unwrap().deserialized().unwrap(), options: map.remove(&4).map(|value| value.deserialized().unwrap()), pin_protocols: map.remove(&6).map(|value| value.deserialized().unwrap()), + // 0x0C: forcePINChange (CTAP 2.1) + force_pin_change: map.remove(&0x0C).map(|value| value.deserialized().unwrap()), + // 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()), } } @@ -1062,3 +1077,101 @@ impl From for CredentialManagementReply { } } } + +// ============================================================================ +// authenticatorConfig (CTAP 2.1 §6.11) +// ============================================================================ + +/// `authenticatorConfig` (CTAP 2.1 §6.11), command 0x0D. +pub struct AuthenticatorConfig { + pub subcommand: u8, + pub subcommand_params: Option, + pub pin_protocol: Option, + pub pin_auth: Option<[u8; 32]>, +} + +impl AuthenticatorConfig { + pub fn new(subcommand: u8) -> Self { + Self { + subcommand, + subcommand_params: None, + pin_protocol: None, + pin_auth: None, + } + } + + /// CTAP 2.1 §6.11.4 step 3.a: `pinUvAuthData = 32×0xff || 0x0d || + /// uint8(subCommand) || subCommandParams (CBOR)`. The platform HMACs this + /// exact byte string with the pin-uv-auth token. + pub fn pin_uv_auth_data(&self) -> Vec { + let mut data = vec![0xff; 32]; + data.push(0x0d); + data.push(self.subcommand); + if let Some(params) = &self.subcommand_params { + let mut buf = Vec::new(); + ciborium::into_writer(&Value::from(params.clone()), &mut buf).unwrap(); + data.extend_from_slice(&buf); + } + data + } +} + +impl From for Value { + fn from(request: AuthenticatorConfig) -> Value { + let mut map = Map::default(); + map.push(1, request.subcommand); + if let Some(params) = request.subcommand_params { + map.push(2, params); + } + if let Some(pin_protocol) = request.pin_protocol { + map.push(3, pin_protocol); + } + if let Some(pin_auth) = request.pin_auth { + map.push(4, pin_auth.as_slice()); + } + map.into() + } +} + +impl Request for AuthenticatorConfig { + const COMMAND: u8 = 0x0D; + + type Reply = AuthenticatorConfigReply; +} + +/// `authenticatorConfig` response — the spec defines no body, just a status +/// byte. We keep an empty marker so the `Request` trait is satisfied. +pub struct AuthenticatorConfigReply; + +impl From for AuthenticatorConfigReply { + fn from(_value: Value) -> Self { + Self + } +} + +/// `SubcommandParameters` for `authenticatorConfig`. Mirrors +/// `ctap_types::ctap2::authenticator_config::SubcommandParameters` but with +/// owned values for ergonomics. +#[derive(Clone, Default)] +pub struct AuthenticatorConfigParams { + pub new_min_pin_length: Option, + pub min_pin_length_rp_ids: Option>, + pub force_change_pin: Option, +} + +impl From for Value { + fn from(params: AuthenticatorConfigParams) -> Value { + let mut map = Map::default(); + if let Some(v) = params.new_min_pin_length { + map.push(1, v); + } + if let Some(ids) = params.min_pin_length_rp_ids { + let values: Vec = ids.into_iter().map(Value::from).collect(); + map.push(2, Value::Array(values)); + } + if let Some(force) = params.force_change_pin { + map.push(3, force); + } + map.into() + } +}