diff --git a/CHANGELOG.md b/CHANGELOG.md index 037dd42..aab39ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Update to `ctap-types` v0.6.0-rc.1. +- Update to `ctap-types` v0.6.0-rc.2. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. -- Implement the `credBlob` extension. +- Implement these new extensions: + - `credBlob` + - `minPinLength` +- Implement the `alwaysUv` feature. +- Implement the `config` command with these subcommands: + - `toggleAlwaysUv` + - `setMinPINLength` - Load full credential from filesstem for getAssertion if an allow list is used with a discoverable credential. +- Use UTF-8 code points instead of bytes when checking the minimum length for PINs. ## [v0.3.0](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.3.0) (2026-03-25) diff --git a/Cargo.toml b/Cargo.toml index 0c3e1fb..4979073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ description = "FIDO authenticator Trussed app" apdu-app = { version = "0.2", optional = true } cbor-smol = "0.5" cosey = "0.4" -ctap-types = { version = "=0.6.0-rc.1", features = ["get-info-full", "large-blobs", "third-party-payment"] } +ctap-types = { version = "=0.6.0-rc.2", features = ["get-info-full", "large-blobs", "third-party-payment"] } ctaphid-app = { version = "0.2", optional = true } delog = "0.1" heapless = "0.9" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 86b47e9..7c822a0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" cargo-fuzz = true [dependencies] -ctap-types = { version = "=0.6.0-rc.1", features = ["arbitrary"] } +ctap-types = { version = "=0.6.0-rc.2", features = ["arbitrary"] } libfuzzer-sys = "0.4" trussed = { version = "0.1", features = ["certificate-client", "crypto-client", "filesystem-client", "management-client", "aes256-cbc", "ed255", "p256", "sha256"] } trussed-staging = { version = "0.4.0", features = ["chunked", "hkdf", "virt", "fs-info"] } diff --git a/src/ctap1.rs b/src/ctap1.rs index 50e4d0c..35092f3 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -33,6 +33,15 @@ impl Authenticator for crate::Authenti /// Also note that CTAP1 credentials should be assertable over CTAP2. I believe this is /// currently not the case. fn register(&mut self, reg: ®ister::Request) -> Result { + // CTAP 2.1 §7.2.4 step 2: when alwaysUv is enabled, U2F_REGISTER and + // U2F_AUTHENTICATE MUST immediately fail with SW_COMMAND_NOT_ALLOWED + // (0x6986). Our device has no built-in UV, so alwaysUv unconditionally + // disables CTAP1/U2F. The matching `getInfo` change (drop "U2F_V2" + // from `versions`) lives in `src/ctap2.rs`. + if self.state.persistent.always_uv() { + return Err(Error::CommandNotAllowedNoEf); + } + self.up .user_present(&mut self.trussed, constants::U2F_UP_TIMEOUT) .map_err(|_| Error::ConditionsOfUseNotSatisfied)?; @@ -149,6 +158,11 @@ impl Authenticator for crate::Authenti } fn authenticate(&mut self, auth: &authenticate::Request) -> Result { + // CTAP 2.1 §7.2.4 step 2: see `register` above. + if self.state.persistent.always_uv() { + return Err(Error::CommandNotAllowedNoEf); + } + let cred = Credential::try_from_bytes(self, auth.app_id, auth.key_handle); let user_presence_byte = match auth.control_byte { diff --git a/src/ctap2.rs b/src/ctap2.rs index 183d9bd..c366d9b 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -3,9 +3,11 @@ use credential_management::CredentialManagement; use ctap_types::{ ctap2::{ - self, client_pin::Permissions, AttestationFormatsPreference, AttestationStatement, - AttestationStatementFormat, Authenticator, NoneAttestationStatement, - PackedAttestationStatement, VendorOperation, + self, + client_pin::Permissions, + config::{MAX_MIN_PIN_LENGTH_RP_IDS, MAX_RP_ID_LENGTH}, + AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, + Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, heapless::{String, Vec}, heapless_bytes::Bytes, @@ -50,7 +52,14 @@ impl Authenticator for crate::Authenti debug_now!("remaining stack size: {} bytes", msp() - 0x2000_0000); let mut versions = Vec::new(); - versions.push(Version::U2fV2).unwrap(); + // CTAP 2.1 §7.2.4: when alwaysUv is enabled the authenticator MUST + // disable CTAP1/U2F unless it has a built-in UV method (we don't). + // Step 1 of that section says "U2F_V2 MUST NOT appear in versions". + // The matching dispatch-level reject (SW_COMMAND_NOT_ALLOWED on + // U2F_REGISTER / U2F_AUTHENTICATE) lives in `src/ctap1.rs`. + if !self.state.persistent.always_uv() { + versions.push(Version::U2fV2).unwrap(); + } versions.push(Version::Fido2_0).unwrap(); versions.push(Version::Fido2_1).unwrap(); @@ -61,6 +70,7 @@ impl Authenticator for crate::Authenti if self.config.supports_large_blobs() { extensions.push(Extension::LargeBlobKey).unwrap(); } + extensions.push(Extension::MinPinLength).unwrap(); extensions.push(Extension::ThirdPartyPayment).unwrap(); let mut pin_protocols = Vec::new(); @@ -79,7 +89,16 @@ impl Authenticator for crate::Authenti }; options.large_blobs = Some(self.config.supports_large_blobs()); options.pin_uv_auth_token = Some(true); - options.make_cred_uv_not_rqd = Some(true); + // CTAP 2.1 §6.11.2 toggleAlwaysUv: when alwaysUv is enabled, the + // authenticator MUST report makeCredUvNotRqd as false. We couple the + // two here so the toggle subcommand doesn't have to update two pieces + // of state. The authenticator's "default" for makeCredUvNotRqd (when + // alwaysUv is disabled) is true — non-discoverable MC without UV is + // allowed by this authenticator. + options.make_cred_uv_not_rqd = Some(!self.state.persistent.always_uv()); + options.authnr_cfg = Some(true); + options.set_min_pin_length = Some(true); + options.always_uv = Some(self.state.persistent.always_uv()); let mut transports = Vec::new(); if self.config.nfc_transport { @@ -124,6 +143,9 @@ impl Authenticator for crate::Authenti response.remaining_discoverable_credentials = remaining_discoverable_credentials.map(|count| count as usize); response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); + 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.attestation_formats = Some(attestation_formats); response } @@ -257,6 +279,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; @@ -295,6 +324,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); @@ -430,6 +472,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; } @@ -453,6 +496,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; @@ -463,6 +507,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 @@ -572,6 +617,108 @@ 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; + + // CTAP 2.1 §6.11 — authenticatorConfig algorithm. + + // 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. + // EnableLongTouchForReset lands with the long-touch reset commit. + // EnterpriseAttestation / VendorPrototype are not supported. + match request.sub_command { + Subcommand::SetMinPINLength | Subcommand::ToggleAlwaysUv => {} + _ => return Err(Error::InvalidParameter), + } + + // 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. + // We have no built-in UV, so "protected by some form of UV" + // reduces to clientPin being set. This bypass is the platform's + // exit hatch when alwaysUv was pre-flashed and no PIN has been + // configured yet — it lets the user clear alwaysUv without + // first being forced through PIN setup. + let toggle_always_uv_bypass = matches!(request.sub_command, Subcommand::ToggleAlwaysUv) + && !self.state.persistent.pin_is_set() + && self.state.persistent.always_uv(); + + // 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, so "protected by some form of UV" + // reduces to clientPin being set. In factory-default state + // (no PIN, alwaysUv off) 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 !toggle_always_uv_bypass + && (self.state.persistent.pin_is_set() || self.state.persistent.always_uv()) + { + // 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), + Subcommand::ToggleAlwaysUv => self.state.persistent.toggle_always_uv(&mut self.trussed), + // Step 2 filtered every other variant. `Subcommand` is + // `#[non_exhaustive]` so the catch-all is still required. + _ => Err(Error::InvalidParameter), + } + } + #[inline(never)] fn client_pin( &mut self, @@ -625,8 +772,11 @@ impl Authenticator for crate::Authenti let pin_protocol = pin_protocol?; // 2. is pin already set + // CTAP 2.1 §6.5.5.4 step 3: a setPin request against an + // already-provisioned authenticator returns PinAuthInvalid. + // (Older CTAP 2.0 implementations returned NotAllowed.) if self.state.persistent.pin_is_set() { - return Err(Error::NotAllowed); + return Err(Error::PinAuthInvalid); } // 3. generate shared secret @@ -715,8 +865,40 @@ impl Authenticator for crate::Authenti shared_secret.delete(&mut self.trussed); - // 9. store hashed PIN - self.hash_store_pin(&new_pin)?; + // 8b. CTAP 2.1 §6.5.5.6: "If the forcePINChange member ... is + // true and LEFT(SHA-256(newPin), 16) is equal to its internal + // stored LEFT(SHA-256(curPin), 16) then authenticator returns + // CTAP2_ERR_PIN_POLICY_VIOLATION." We compute the new hash up + // front so the comparison is constant-time on a fixed-size + // array, and only return the error when force_pin_change is + // set — same-PIN change with the flag clear is allowed. + let new_pin_hash_32 = syscall!(self.trussed.hash_sha256(&new_pin)).hash; + let new_pin_hash: [u8; 16] = new_pin_hash_32[..16].try_into().unwrap(); + if self.state.persistent.force_pin_change() + && self.state.persistent.pin_hash() == Some(new_pin_hash) + { + return Err(Error::PinPolicyViolation); + } + + // 9. store hashed PIN + PINCodePointLength + // (CTAP 2.1 §6.5.5.5 — "Save the PIN with derived hash + // and PINCodePointLength"). `new_pin` was UTF-8-validated + // in `decrypt_pin_check_length` above, so the from_utf8 + // is infallible here; the unwrap_or is defensive. + let new_pin_code_point_length = core::str::from_utf8(&new_pin) + .map(|s| s.chars().count()) + .unwrap_or(new_pin.len()) as u8; + self.state.persistent.set_pin_hash( + &mut self.trussed, + new_pin_hash, + new_pin_code_point_length, + )?; + + // CTAP 2.1 §6.5.5.6 step 9: clear forcePINChange after a + // successful changePin. + self.state + .persistent + .set_force_pin_change(&mut self.trussed, false)?; self.pin_protocol(pin_protocol).reset_pin_tokens(); } @@ -766,7 +948,12 @@ impl Authenticator for crate::Authenti // 10. Reset PIN retries self.state.reset_retries(&mut self.trussed)?; - // 11. Check forcePINChange -- skipped + // 11. CTAP 2.1 §6.5.5.7.1 step 11: while forcePINChange is + // set, getPinToken returns PIN_INVALID until a successful + // changePin clears the flag. + if self.state.persistent.force_pin_change() { + return Err(Error::PinInvalid); + } // 12. Reset all PIN tokens // 13. Call beginUsingPinUvAuthToken @@ -809,13 +996,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); } @@ -843,7 +1033,12 @@ impl Authenticator for crate::Authenti // 10. Reset PIN retries self.state.reset_retries(&mut self.trussed)?; - // 11. Check forcePINChange -- skipped + // 11. CTAP 2.1 §6.5.5.7.3 step 11: while forcePINChange is + // set, this variant returns PIN_POLICY_VIOLATION (distinct + // from getPinToken's PIN_INVALID; see §6.5.5.7.1). + if self.state.persistent.force_pin_change() { + return Err(Error::PinPolicyViolation); + } // 12. Reset all PIN tokens // 13. Call beginUsingPinUvAuthToken @@ -982,7 +1177,18 @@ impl Authenticator for crate::Authenti ) { Ok(b) => b, Err(Error::PinRequired) => { - // UV is optional for get_assertion + // UV is optional for `getAssertion` by default — pin_prechecks + // raises PinRequired for the "RK + clientPin set + no pin_auth" + // case, and the spec lets GA proceed without UV. The + // alwaysUv branch (CTAP 2.1 §6.2.2 step 5) is already + // enforced inside pin_prechecks — it inspects the + // permissions parameter and the request's `up` option to + // honour the "up must be true" condition, so any + // alwaysUv-driven `PinRequired` reaching us here has + // already been adjudicated and we just propagate it. + if self.state.persistent.always_uv() { + return Err(Error::PinRequired); + } false } Err(err) => return Err(err), @@ -1083,6 +1289,99 @@ 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)?; + + // 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); + } + + // 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); + } + // 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 { + 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) + .map_err(|_| Error::KeyStoreFull)?; + } + + // 2.8. Authenticator returns CTAP2_OK. + Ok(()) + } + fn parse_pin_protocol(&self, version: impl TryInto) -> Result { if let Ok(version) = version.try_into() { for pin_protocol in self.pin_protocols() { @@ -1170,8 +1469,16 @@ impl crate::Authenticator { continue; } - // If this is an RK, we still need to load it from the filesystem to have access - // to all metadata + // CTAP 2.1 §6.2.3 — for resident credentials referenced + // via allowList, the response must include the `user` + // field. Modern versions of this app encrypt only a + // Stripped credential into `credential_id`, which omits + // user data. For RKs we recover the FullCredential from + // disk by hashing the credential_id. If the RK file is + // missing or corrupt the credential is treated as + // unusable — skip it and try the next allow-list entry + // (the Stripped form lacks the data the platform expects + // for an RK match). if let Credential::Stripped(stripped) = &credential { if matches!(stripped.key, Key::ResidentKey(_)) { let credential_id_hash = self.hash(credential_id.id); @@ -1294,9 +1601,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(()) @@ -1317,17 +1632,29 @@ impl crate::Authenticator { .decrypt(&mut self.trussed, pin_enc) .ok_or(Error::Other)?; - // // temp - // let pin_length = pin.iter().position(|&b| b == b'\0').unwrap_or(pin.len()); - // info_now!("pin.len() = {}, pin_length = {}, = {:?}", - // pin.len(), pin_length, &pin); - // chop off null bytes - let pin_length = pin.iter().position(|&b| b == b'\0').unwrap_or(pin.len()); - if !(4..64).contains(&pin_length) { + // CTAP 2.1 §6.5.5.5 / §6.5.5.6: "The authenticator drops all + // **trailing** 0x00 bytes from paddedNewPin to produce newPin." + // Embedded nulls stay (they will fail UTF-8 validation if invalid). + let stripped_len = pin.iter().rposition(|&b| b != 0).map_or(0, |last| last + 1); + + // CTAP 2.1 §6.5.5.3: "Maximum PIN Length: 63 bytes." + if stripped_len > ctap2::client_pin::MAX_PIN_LENGTH { + return Err(Error::PinPolicyViolation); + } + + // Issue #43: minimum PIN length is measured in **Unicode code points**, + // not bytes. UTF-8-decode the stripped bytes and count `chars()`. A + // platform that sends non-UTF-8 bytes violates the spec; we reject + // with the same PIN_POLICY_VIOLATION code we use for length issues. + let s = + core::str::from_utf8(&pin[..stripped_len]).map_err(|_| Error::PinPolicyViolation)?; + let code_points = s.chars().count(); + let min_pin_length = usize::from(self.state.persistent.min_pin_length()); + if code_points < min_pin_length { return Err(Error::PinPolicyViolation); } - pin.resize_zero(pin_length).unwrap(); + pin.resize_zero(stripped_len).unwrap(); Ok(pin) } @@ -1428,11 +1755,37 @@ impl crate::Authenticator { permissions: Permissions, rp_id: &str, ) -> Result { + // 0. CTAP 2.1 §6.5.5.7 / §6.4.0x0C: while `forcePINChange` is set the + // authenticator MUST refuse every PIN-protected operation until the + // platform calls `clientPin.changePIN`. + if self.state.persistent.force_pin_change() { + return Err(Error::PinPolicyViolation); + } + + // 0b. CTAP 2.1 §6.1.2 step 6 / §6.2.2 step 5: when alwaysUv is + // enabled, both MC and GA must reject a missing pinUvAuthParam with + // CTAP2_ERR_PUAT_REQUIRED (wire 0x36 — `Error::PinRequired` is + // ctap-types' legacy name). Subtle difference: §6.2.2 step 5 only + // applies when the "up" option is true (the default), so an + // explicit `up=false` GA (a silent pre-flight check) bypasses the + // alwaysUv UV requirement per spec. MC has no such carve-out — + // `up=Some(false)` is rejected upstream with INVALID_OPTION, so we + // only need to skip the up check for non-GA permissions. + if self.state.persistent.always_uv() && pin_auth.is_none() { + let is_ga = permissions == Permissions::GET_ASSERTION; + let up_true = !is_ga || options.as_ref().and_then(|o| o.up).unwrap_or(true); + if up_true { + return Err(Error::PinRequired); + } + } + // 1. pinAuth zero length -> wait for user touch, then // return PinNotSet if not set, PinInvalid if set // // the idea is for multi-authnr scenario where platform // wants to enforce PIN and needs to figure out which authnrs support PIN + // (CTAP 2.1 §6.5.5.7 step 2 — was upstream PR #56; the older + // CTAP 2.0 reading was `PinAuthInvalid` for the "pin set" case.) if let Some(pin_auth) = pin_auth { if pin_auth.is_empty() { self.up @@ -1440,7 +1793,7 @@ impl crate::Authenticator { if !self.state.persistent.pin_is_set() { return Err(Error::PinNotSet); } else { - return Err(Error::PinAuthInvalid); + return Err(Error::PinInvalid); } } } diff --git a/src/state.rs b/src/state.rs index 9e85855..3223000 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,10 @@ pub mod migrate; use core::num::NonZeroU32; use ctap_types::{ - ctap2::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,10 +271,40 @@ 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, timestamp: u32, + + /// Configured minimum PIN length (CTAP 2.1 `setMinPINLength`, §6.11.4 + /// subcmd 0x03). `0` means "no override; use the spec default of 4". + #[serde(default)] + min_pin_length: u8, + + /// 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, 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 + /// successfully calls `clientPin.changePIN`. + #[serde(default)] + force_pin_change: bool, + + /// `alwaysUv` (CTAP 2.1 §6.11.3). When `true`, every MakeCredential and + /// GetAssertion must carry a valid `pinUvAuthParam`; ops without UV are + /// rejected with `PinRequired`. + #[serde(default)] + always_uv: bool, } impl PersistentState { @@ -327,6 +360,11 @@ impl PersistentState { self.consecutive_pin_mismatches = 0; self.pin_hash = None; self.timestamp = 0; + // CTAP 2.1 §6.7 authenticatorReset: "Always Require User Verification" + // is explicitly listed as a feature that MUST be reset. Other §6.7 + // feature flags (min_pin_length / min_pin_length_rp_ids / + // force_pin_change) are left for a follow-up — see AUDIT.md. + self.always_uv = false; self.save(trussed) } @@ -435,15 +473,106 @@ 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<()> { + // Idempotent: if the same hash is being written and forcePINChange is + // already clear, skip the flash write. Also — and more importantly — + // if the platform "changes" the PIN to the same value, we must not + // clear `force_pin_change` (the user hasn't actually complied with + // the change request). The spec-mandated reject for "forcePINChange + // + new==old" lives in the changePIN handler; this check is a belt- + // and-braces against any other caller path. + if self.pin_hash == Some(pin_hash) { + return Ok(()); + } 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.6 / + // §6.5.5.7). + self.force_pin_change = false; self.save(trussed)?; Ok(()) } + + /// 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, DEFAULT_MIN_PIN_LENGTH) + } + + pub fn set_min_pin_length( + &mut self, + trussed: &mut T, + new_value: u8, + ) -> Result<()> { + // Spec: setMinPINLength may only raise the value, never lower it. + let cur = self.min_pin_length(); + if new_value < cur { + return Err(Error::PinPolicyViolation); + } + + 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] { + &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>, + ) -> Result<()> { + self.min_pin_length_rp_ids = rp_ids; + self.save(trussed)?; + Ok(()) + } + + 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(()) + } + + pub fn always_uv(&self) -> bool { + self.always_uv + } + + pub fn toggle_always_uv(&mut self, trussed: &mut T) -> Result<()> { + self.always_uv = !self.always_uv; + self.save(trussed) + } } impl RuntimeState { diff --git a/tests/basic.rs b/tests/basic.rs index c711d15..c7cb266 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] @@ -105,8 +105,10 @@ fn test_set_pin() { let shared_secret = get_shared_secret(&device, &key_agreement_key); let result = set_pin(&device, &key_agreement_key, &shared_secret, b"123456"); - // TODO: review error code - assert_eq!(result, Err(Ctap2Error(0x30))); + // CTAP 2.1 §6.5.5.4: "If a PIN has already been set, authenticator + // returns CTAP2_ERR_PIN_AUTH_INVALID error." (0x33). Previously this + // expected 0x30 (NotAllowed), the CTAP 2.0 reading. + assert_eq!(result, Err(Ctap2Error(0x33))); let reply = device.exec(GetInfo).unwrap(); let options = reply.options.unwrap(); @@ -755,6 +757,7 @@ impl From for MakeCredentialExtensionsI } else { None }, + min_pin_length: None, } } } @@ -1167,3 +1170,1299 @@ 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))); + }) +} + +// ---------------------------------------------------------------------------- +// alwaysUv + toggleAlwaysUv (CTAP 2.1 §6.4, §6.11.2, §6.1.2 / §6.2.2) +// ---------------------------------------------------------------------------- + +fn toggle_always_uv(device: &Ctap2, pin_token: &PinToken) -> Result<(), Ctap2Error> { + let mut request = AuthenticatorConfig::new(0x02); // ToggleAlwaysUv + request.pin_protocol = Some(2); + request.pin_auth = Some(pin_token.authenticate(&request.pin_uv_auth_data())); + device.exec(request).map(|_| ()) +} + +/// GetInfo on a fresh device advertises `alwaysUv=false` and the coupled +/// `makeCredUvNotRqd=true` (CTAP 2.1 §6.4 + §6.11.2 coupling). +#[test] +fn test_always_uv_default() { + virt::run_ctap2(|device| { + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + assert_eq!(options.get("alwaysUv"), Some(&Value::from(false))); + assert_eq!(options.get("makeCredUvNotRqd"), Some(&Value::from(true))); + }) +} + +/// toggleAlwaysUv flips `alwaysUv` true and forces `makeCredUvNotRqd` false +/// in the same GetInfo (CTAP 2.1 §6.11.2 mandates the coupling). A second +/// toggle restores both. +#[test] +fn test_always_uv_toggle_couples_make_cred_uv_not_rqd() { + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + assert_eq!(options.get("alwaysUv"), Some(&Value::from(true))); + assert_eq!(options.get("makeCredUvNotRqd"), Some(&Value::from(false))); + + // Toggle OFF. + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + assert_eq!(options.get("alwaysUv"), Some(&Value::from(false))); + assert_eq!(options.get("makeCredUvNotRqd"), Some(&Value::from(true))); + }) +} + +/// With `alwaysUv` enabled, `makeCredential` without a `pinUvAuthParam` MUST +/// be rejected with CTAP2_ERR_PUAT_REQUIRED (0x36) per CTAP 2.1 §6.1.2. +#[test] +fn test_always_uv_make_credential_without_pin_auth_is_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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + let request = MakeCredential::new( + vec![0; 32], + Rp::new("example.com"), + User::new(vec![1; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x36))); + }) +} + +/// Same as above but for `getAssertion` (CTAP 2.1 §6.2.2). +/// +/// Setup: make an RK (no PIN yet, so MC needs no pin_auth), then set the PIN, +/// then toggle alwaysUv. Now GA without pin_auth should be rejected. This +/// order avoids the more complex pin-auth-on-MC path. +#[test] +fn test_always_uv_get_assertion_without_pin_auth_is_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + let rp_id = "example.com"; + virt::run_ctap2(|device| { + // Make an RK first (no PIN set; MC needs no pin_auth in this state). + 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)); + device.exec(mc).unwrap(); + + // Set the PIN and enable alwaysUv. + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // GA without pin_auth must fail with PUAT_REQUIRED. + let ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + let result = device.exec(ga); + assert_eq!(result.err(), Some(Ctap2Error(0x36))); + }) +} + +/// CTAP 2.1 §7.2.4 step 1: when `alwaysUv` is enabled the authenticator +/// MUST NOT include `"U2F_V2"` in its `getInfo.versions` array (it is +/// effectively required to disable CTAP1/U2F because we don't ship a +/// built-in UV method). Verify the version is present pre-toggle and +/// removed post-toggle. +#[test] +fn test_always_uv_u2f_v2_dropped_from_versions() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Pre-toggle: U2F_V2 must be advertised. + let reply = device.exec(GetInfo).unwrap(); + assert!( + reply.versions.contains(&"U2F_V2".to_owned()), + "fresh device must advertise U2F_V2, got versions={:?}", + reply.versions + ); + + // Enable alwaysUv. + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // Post-toggle: U2F_V2 MUST be absent. + let reply = device.exec(GetInfo).unwrap(); + assert!( + !reply.versions.contains(&"U2F_V2".to_owned()), + "U2F_V2 must be removed once alwaysUv is true, got versions={:?}", + reply.versions + ); + // The CTAP2 versions must still be present. + assert!(reply.versions.contains(&"FIDO_2_0".to_owned())); + assert!(reply.versions.contains(&"FIDO_2_1".to_owned())); + }) +} + +/// CTAP 2.1 §7.2.4 step 2: when alwaysUv is enabled, U2F_REGISTER MUST +/// fail with SW_COMMAND_NOT_ALLOWED (0x6986). +#[test] +fn test_always_uv_u2f_register_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Enable alwaysUv. + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // U2F_REGISTER APDU (extended length): + // CLA=00 INS=01 P1=00 P2=00 | extended Lc=00 0040 | 64-byte data + // | extended Le=0000 + let mut apdu: Vec = vec![0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x40]; + apdu.extend_from_slice(&[0u8; 64]); // challenge ‖ app_id (zeros are fine — we reject before parsing) + apdu.extend_from_slice(&[0x00, 0x00]); + let status = device + .ctap1(&apdu) + .expect_err("U2F_REGISTER must fail when alwaysUv is enabled"); + assert_eq!( + status, 0x6986, + "expected SW_COMMAND_NOT_ALLOWED, got {:#x}", + status + ); + }) +} + +/// CTAP 2.1 §7.2.4 step 2: same for U2F_AUTHENTICATE. +#[test] +fn test_always_uv_u2f_authenticate_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Enable alwaysUv. + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // U2F_AUTHENTICATE APDU (extended length, control byte = 0x03 + // EnforceUserPresenceAndSign): + // CLA=00 INS=02 P1=03 P2=00 | extended Lc=00 0041 | + // challenge(32) ‖ app_id(32) ‖ kh_len(1=0) | extended Le=0000 + let mut apdu: Vec = vec![0x00, 0x02, 0x03, 0x00, 0x00, 0x00, 0x41]; + apdu.extend_from_slice(&[0u8; 64]); // challenge ‖ app_id + apdu.push(0x00); // kh_len = 0 (no keyhandle); we reject before reading it + apdu.extend_from_slice(&[0x00, 0x00]); + let status = device + .ctap1(&apdu) + .expect_err("U2F_AUTHENTICATE must fail when alwaysUv is enabled"); + assert_eq!( + status, 0x6986, + "expected SW_COMMAND_NOT_ALLOWED, got {:#x}", + status + ); + }) +} + +/// CTAP 2.1 §6.2.2 step 5 carve-out: when `alwaysUv=true` and the +/// platform sends `up=Some(false)` (a silent pre-flight check), the +/// alwaysUv UV requirement is bypassed per the spec ("If the alwaysUv +/// option ID is present and true and the 'up' option is present and +/// true then …"). Verify that GA with `up=false` does not get a +/// PUAT_REQUIRED back even though no pinUvAuthParam is sent. +#[test] +fn test_always_uv_get_assertion_up_false_bypasses_uv_requirement() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + let rp_id = "example.com"; + virt::run_ctap2(|device| { + // Make an RK first (no PIN yet so 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(); + + // Set PIN and enable alwaysUv. + 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(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // GA with up=Some(false) and no pin_auth — alwaysUv check must + // be bypassed (spec §6.2.2 step 5 only applies when up=true). + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.options = Some(GetAssertionOptions { + up: Some(false), + uv: None, + }); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let result = device.exec(ga); + assert!( + result.is_ok(), + "up=false GA must bypass alwaysUv UV requirement, got {:?}", + result.err() + ); + }) +} + +// ---------------------------------------------------------------------------- +// PIN length validation (issue #43): count Unicode code points, not bytes +// ---------------------------------------------------------------------------- + +/// Multi-byte UTF-8 PIN with FEWER code points than minimum is rejected. +/// "héé" = 5 bytes (h + é + é where é = 0xC3 0xA9), 3 code points. Default +/// minimum is 4 → PIN_POLICY_VIOLATION. +#[test] +fn test_set_pin_short_codepoints_multibyte_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // "héé" — 5 bytes, 3 code points. Pre-fix this would have passed + // because the BYTE length (5) >= 4. The fixed code rejects it. + let pin = "héé".as_bytes(); + assert_eq!(pin.len(), 5); + assert_eq!( + pin.iter().filter(|&&b| !(0x80..0xC0).contains(&b)).count(), + 3 + ); + let result = set_pin(&device, &key_agreement_key, &shared_secret, pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// Multi-byte UTF-8 PIN with enough code points is accepted. +/// "héllo" = 6 bytes, 5 code points. Default minimum is 4 → succeeds. +#[test] +fn test_set_pin_codepoints_multibyte_accepted() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let pin = "héllo".as_bytes(); + assert_eq!(pin.len(), 6); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + }) +} + +/// ASCII PIN at the lower bound: 4 bytes = 4 code points. Accepted. +#[test] +fn test_set_pin_four_byte_ascii_accepted() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, b"abcd").unwrap(); + }) +} + +/// 3-byte ASCII PIN (3 code points) is rejected. +#[test] +fn test_set_pin_three_byte_ascii_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = set_pin(&device, &key_agreement_key, &shared_secret, b"abc"); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// Invalid UTF-8 in the PIN bytes is rejected (PIN_POLICY_VIOLATION). Platforms +/// MUST send Normalized UTF-8 per CTAP 2.1 §6.5.5.5; bytes that don't decode +/// fail the PIN policy check. +#[test] +fn test_set_pin_invalid_utf8_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // 0xC3 is a UTF-8 lead byte that must be followed by a continuation + // byte in [0x80, 0xBF]. Trailing it with 'x' makes the sequence + // invalid UTF-8. + let pin = b"abc\xC3x"; + let result = set_pin(&device, &key_agreement_key, &shared_secret, pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// All-zero `paddedNewPin` strips to length 0 → 0 code points → reject. +/// Verifies the empty-PIN edge case (no leading bytes, no trailing +/// non-zero) is properly rejected against the spec floor of 4 cp. +#[test] +fn test_set_pin_empty_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = set_pin(&device, &key_agreement_key, &shared_secret, b""); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.5.5.5 — UTF-8 representation of newPin MUST NOT exceed 63 +/// bytes. A 63-byte ASCII PIN (63 code points) sits exactly at the spec +/// boundary and MUST be accepted. +#[test] +fn test_set_pin_at_byte_limit_accepted() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // 63 ASCII chars → 63 bytes → 63 code points. + let pin = vec![b'a'; 63]; + set_pin(&device, &key_agreement_key, &shared_secret, &pin).unwrap(); + }) +} + +/// CTAP 2.1 §6.5.5.5: a 64-byte non-zero PIN fills `paddedNewPin` +/// completely with no trailing 0x00 — the stripped length stays at 64 +/// which exceeds the spec's 63-byte UTF-8 cap, so the authenticator +/// MUST reject with PIN_POLICY_VIOLATION. +#[test] +fn test_set_pin_over_byte_limit_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // 64 ASCII chars → 64 bytes → no padding room left. + let pin = vec![b'a'; 64]; + let result = set_pin(&device, &key_agreement_key, &shared_secret, &pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.5.5.6 (changePIN) shares the same PIN-length validation +/// pipeline as setPIN (§6.5.5.5). Verify the code-point check applies +/// equally: a 3-byte ASCII new PIN under changePIN must be rejected +/// with PIN_POLICY_VIOLATION. +#[test] +fn test_change_pin_short_codepoints_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let old_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, old_pin).unwrap(); + + // Attempt to change to a 3-cp PIN. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = change_pin(&device, &key_agreement_key, &shared_secret, old_pin, b"abc"); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// Multi-byte UTF-8 new PIN with too few code points is also rejected +/// by changePIN (parallel to the setPin multi-byte test). "héé" = +/// 5 bytes, 3 code points → reject. +#[test] +fn test_change_pin_short_codepoints_multibyte_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let old_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, old_pin).unwrap(); + + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let new_pin = "héé".as_bytes(); + assert_eq!(new_pin.len(), 5); + let result = change_pin( + &device, + &key_agreement_key, + &shared_secret, + old_pin, + new_pin, + ); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.5.5.6 changePIN: "If the forcePINChange member ... is true +/// and LEFT(SHA-256(newPin), 16) is equal to its internal stored +/// LEFT(SHA-256(curPin), 16) then authenticator returns +/// CTAP2_ERR_PIN_POLICY_VIOLATION." This blocks the trivial "rotate to the +/// same PIN" loophole when the platform is forcing a change. +#[test] +fn test_change_pin_same_pin_with_force_change_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Setup: set PIN, then mark forcePINChange via setMinPINLength. + 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 { + force_change_pin: Some(true), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(true)); + + // Try to "change" to the same PIN — must be rejected. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = change_pin(&device, &key_agreement_key, &shared_secret, pin, pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + + // forcePINChange should still be true after the rejection. + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(true)); + }) +} + +/// Counterpart: when forcePINChange is **not** set, a same-PIN changePIN +/// silently succeeds (spec doesn't reject this case, only when the flag is +/// set). This documents the current behaviour and locks it in. +#[test] +fn test_change_pin_same_pin_without_force_change_allowed() { + 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 is false by default. + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(false)); + + let shared_secret = get_shared_secret(&device, &key_agreement_key); + change_pin(&device, &key_agreement_key, &shared_secret, pin, pin).unwrap(); + + // Still false. + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(false)); + }) +} + +/// Successful changePIN to a NEW pin while forcePINChange is set must clear +/// the flag (CTAP 2.1 §6.5.5.6: "Authenticator sets the value of the +/// forcePINChange member ... to false"). +#[test] +fn test_change_pin_to_new_pin_clears_force_change() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin1 = b"123456"; + let pin2 = b"654321"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin1).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin1, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(true)); + + let shared_secret = get_shared_secret(&device, &key_agreement_key); + change_pin(&device, &key_agreement_key, &shared_secret, pin1, pin2).unwrap(); + + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(false)); + }) +} + +/// CTAP 2.1 §6.1.2 step 1 (and §6.5.5.7 step 2): when the platform sends +/// a **zero-length** `pinUvAuthParam` (the CTAP 2.0 "is PIN supported?" +/// probe), the authenticator MUST request UP and then return +/// `CTAP2_ERR_PIN_INVALID` (0x31) if a PIN is set. The pre-audit code +/// returned `PIN_AUTH_INVALID` (0x33), the CTAP 2.0 reading. +#[test] +fn test_make_credential_zero_length_pin_auth_returns_0x31_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 mc = MakeCredential::new( + vec![0u8; 32], + Rp::new("example.com"), + User::new(vec![1u8; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + // Zero-length pinUvAuthParam — the §6.1.2 step 1 probe. + mc.pin_auth_raw = Some(Vec::new()); + mc.pin_protocol = Some(2); + let result = device.exec(mc); + assert_eq!(result.err(), Some(Ctap2Error(0x31))); + }) +} + +/// Same probe, but with no PIN set on the device. CTAP 2.1 §6.1.2 step 1.3: +/// "return CTAP2_ERR_PIN_NOT_SET" (0x35). +#[test] +fn test_make_credential_zero_length_pin_auth_returns_0x35_when_pin_not_set() { + virt::run_ctap2(|device| { + let mut mc = MakeCredential::new( + vec![0u8; 32], + Rp::new("example.com"), + User::new(vec![1u8; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.pin_auth_raw = Some(Vec::new()); + mc.pin_protocol = Some(2); + let result = device.exec(mc); + assert_eq!(result.err(), Some(Ctap2Error(0x35))); + }) +} + +/// 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" + ); + }) +} + +// ---------------------------------------------------------------------------- +// RK + allowList: user field in GA response (CTAP 2.1 §6.2.3) +// ---------------------------------------------------------------------------- + +/// CTAP 2.1 §6.2.3: when `getAssertion` is called with an `allowList` and the +/// matched credential is a resident key, the authenticator's response must +/// include the `user` field. Modern versions of this app stash only a +/// `Stripped` credential into `credential_id`, so the user field must be +/// recovered from the on-disk RK record. +#[test] +fn test_get_assertion_with_allow_list_rk_returns_user() { + let rp_id = "example.com"; + let user_id = b"alice-id-1234567"; + let user_name = "alice@example.com"; + virt::run_ctap2(|device| { + // Make an RK with a populated user struct. + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(user_id.to_vec()).name(user_name), + 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(); + + // GA with allowList of just that credential — RK with allowList is + // the audited code path. + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + + let user_value = ga_reply.user.expect("user field missing in GA response"); + let user_map: std::collections::BTreeMap = + user_value.deserialized().unwrap(); + // id is the required field. name is optional and may be stripped by + // the authenticator depending on UV state; the audit fix is about + // presence of the `user` map itself. + assert_eq!( + user_map.get("id").unwrap(), + &ciborium::Value::from(user_id.as_slice()) + ); + }) +} + +/// 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)); + }) +} + +/// CTAP 2.1 §6.2.2 step 12: "User identifiable information (name, displayName, +/// icon) inside user MUST NOT be returned if UV is not done by the +/// authenticator." Verify that when GA runs without `pin_auth` (no UV), the +/// `user` map contains only `id` — name/displayName/icon are stripped. +#[test] +fn test_get_assertion_with_allow_list_rk_no_uv_strips_pii() { + let rp_id = "example.com"; + let user_id = b"alice-id-1234567"; + virt::run_ctap2(|device| { + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(user_id.to_vec()) + .name("alice@example.com") + .display_name("Alice In Wonderland"), + 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(); + + // GA without pin_auth → uv_performed = false → PII must be stripped. + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + + let user_value = ga_reply.user.expect("user field missing"); + let user_map: std::collections::BTreeMap = + user_value.deserialized().unwrap(); + assert_eq!( + user_map.get("id").unwrap(), + &ciborium::Value::from(user_id.as_slice()) + ); + assert!(!user_map.contains_key("name"), "name leaked without UV"); + assert!( + !user_map.contains_key("displayName"), + "displayName leaked without UV" + ); + assert!(!user_map.contains_key("icon"), "icon leaked without UV"); + }) +} + +/// CTAP 2.1 §6.2.3: the `user` response field is for resident credentials +/// only. A GA over an allow-list entry pointing at a non-RK credential +/// MUST NOT include `user`. +#[test] +fn test_get_assertion_with_allow_list_non_rk_no_user_field() { + let rp_id = "example.com"; + virt::run_ctap2(|device| { + // Make a NON-discoverable credential (rk=false). + let client_data_hash = vec![0u8; 32]; + let mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(b"bob-id".to_vec()).name("bob@example.com"), + vec![PubKeyCredParam::new("public-key", -7)], + ); + // No rk(true) — defaults to non-resident. + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + + assert!( + ga_reply.user.is_none(), + "non-RK credential must not return user, got {:?}", + ga_reply.user + ); + }) +} diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index 8a2b54e..586d7b6 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -140,6 +140,23 @@ pub struct Options { pub struct Ctap2<'a>(ctaphid::Device>); impl Ctap2<'_> { + /// Send a raw CTAP1 (U2F) APDU and return the response body / SW + /// status. Used by tests that need to verify CTAP1-level behaviour + /// from within a CTAP2 test setup (e.g. the `alwaysUv` § 7.2.4 + /// "disable U2F" path, where toggling `alwaysUv` requires CTAP2 but + /// the side effect is observable on the U2F dispatch). + pub fn ctap1(&self, apdu: &[u8]) -> Result, u16> { + let mut response = self.0.ctap1(apdu).unwrap(); + let low = response.pop().unwrap(); + let high = response.pop().unwrap(); + let status = u16::from_be_bytes([high, low]); + if status == 0x9000 { + Ok(response) + } else { + Err(status) + } + } + pub fn exec(&self, request: R) -> Result { let operation = Operation::try_from(R::COMMAND) .map(|op| format!("{op:?}")) diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index 99604bb..f90e8e4 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -391,6 +391,12 @@ pub struct MakeCredential { pub extensions: Option, pub options: Option, pub pin_auth: Option<[u8; 32]>, + /// Variable-length override for `pin_auth`, used by tests that exercise + /// the zero-length `pinUvAuthParam` path (CTAP 2.1 §6.1.2 step 1 + + /// §6.5.5.7 step 2 — "CTAP 2.0 backwards-compat" — where the platform + /// sends a 0-byte param to probe whether the authenticator has a PIN). + /// When `Some(_)`, this field is serialised instead of `pin_auth`. + pub pin_auth_raw: Option>, pub pin_protocol: Option, pub attestation_formats_preference: Option>, } @@ -410,6 +416,7 @@ impl MakeCredential { extensions: None, options: None, pin_auth: None, + pin_auth_raw: None, pin_protocol: None, attestation_formats_preference: None, } @@ -436,7 +443,13 @@ impl From for Value { if let Some(options) = request.options { map.push(7, options); } - if let Some(pin_auth) = request.pin_auth { + // `pin_auth_raw` takes precedence over the fixed-size `pin_auth` — + // tests that need a variable-length (e.g. zero-length) pinUvAuthParam + // use the raw field. The mutual-exclusion is a soft contract; the + // serializer just prefers raw when both are set. + if let Some(pin_auth_raw) = request.pin_auth_raw.as_ref() { + map.push(8, pin_auth_raw.as_slice()); + } else if let Some(pin_auth) = request.pin_auth { map.push(8, pin_auth.as_slice()); } if let Some(pin_protocol) = request.pin_protocol { @@ -458,6 +471,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 +479,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 +495,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); } @@ -776,6 +798,7 @@ pub struct GetAssertionReply { pub credential: PubKeyCredDescriptor, pub auth_data: AuthData, pub signature: Vec, + pub user: Option, pub number_of_credentials: Option, } @@ -786,6 +809,9 @@ impl From for GetAssertionReply { credential: map.remove(&0x01).unwrap().into(), auth_data: map.remove(&0x02).unwrap().into(), signature: map.remove(&0x03).unwrap().into_bytes().unwrap(), + // 0x04: user (CTAP 2.1 §6.2.3 — included for RKs by both + // no-allowList and allowList paths) + user: map.remove(&0x04), number_of_credentials: map.remove(&0x05).map(|value| value.deserialized().unwrap()), } } @@ -942,6 +968,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 +981,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 +1094,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() + } +}