Problem
CTAP2 PIN/UV handling ignores three authenticator policies from authenticatorGetInfo. The code is in libwebauthn/src/webauthn/pin_uv_auth_token.rs and libwebauthn/src/pin/mod.rs.
1. noMcGaPermissionsWithClientPin is never read. In user_verification_helper, the GetPinToken and GetPinUvAuthTokenUsingPinWithPermissions arms always request ctap2_request.permissions() (mc = MAKE_CREDENTIAL | GET_ASSERTION, ga = GET_ASSERTION). Per CTAP 2.1, when this option is true a clientPin token may not carry mc/ga, so those must come from built-in UV (getPinUvAuthTokenUsingUvWithPermissions). Requesting them on the PIN path makes the authenticator reject the request.
2. force_pin_change is never acted on. The field (get_info.rs, 0x0C) is parsed but only read in an example. Per CTAP 2.1, while it is true getPinToken and getPinUvAuthTokenUsingPinWithPermissions return CTAP2_ERR_PIN_POLICY_VIOLATION until the PIN is changed. The flow should detect it and drive a change-PIN flow first.
3. PINs are not NFC-normalized and min length is checked in bytes. In change_pin_internal the guard new_pin.len() < min_pin_length compares UTF-8 bytes against minPINLength, which getInfo (0x0D) defines in Unicode code points. Neither the new PIN (change_pin_internal) nor the collected PIN (obtain_pin) is normalized. CTAP 2.1 requires the PIN as UTF-8 in Normalization Form C.
Why it matters
Biometric devices that gate registration and assertion behind UV advertise noMcGaPermissionsWithClientPin. On a fallback from blocked UV, the current code still mints a clientPin mc/ga token and fails with a generic error instead of routing to UV. A device that needs a PIN change fails every ceremony without guiding the user. Without NFC, the same PIN entered as composed vs decomposed characters hashes differently and mismatches other clients. A short multi-byte PIN also passes the local guard and is rejected later by the authenticator.
Checklist
Problem
CTAP2 PIN/UV handling ignores three authenticator policies from
authenticatorGetInfo. The code is inlibwebauthn/src/webauthn/pin_uv_auth_token.rsandlibwebauthn/src/pin/mod.rs.1.
noMcGaPermissionsWithClientPinis never read. Inuser_verification_helper, theGetPinTokenandGetPinUvAuthTokenUsingPinWithPermissionsarms always requestctap2_request.permissions()(mc =MAKE_CREDENTIAL | GET_ASSERTION, ga =GET_ASSERTION). Per CTAP 2.1, when this option is true a clientPin token may not carry mc/ga, so those must come from built-in UV (getPinUvAuthTokenUsingUvWithPermissions). Requesting them on the PIN path makes the authenticator reject the request.2.
force_pin_changeis never acted on. The field (get_info.rs, 0x0C) is parsed but only read in an example. Per CTAP 2.1, while it is truegetPinTokenandgetPinUvAuthTokenUsingPinWithPermissionsreturnCTAP2_ERR_PIN_POLICY_VIOLATIONuntil the PIN is changed. The flow should detect it and drive a change-PIN flow first.3. PINs are not NFC-normalized and min length is checked in bytes. In
change_pin_internalthe guardnew_pin.len() < min_pin_lengthcompares UTF-8 bytes againstminPINLength, which getInfo (0x0D) defines in Unicode code points. Neither the new PIN (change_pin_internal) nor the collected PIN (obtain_pin) is normalized. CTAP 2.1 requires the PIN as UTF-8 in Normalization Form C.Why it matters
Biometric devices that gate registration and assertion behind UV advertise
noMcGaPermissionsWithClientPin. On a fallback from blocked UV, the current code still mints a clientPin mc/ga token and fails with a generic error instead of routing to UV. A device that needs a PIN change fails every ceremony without guiding the user. Without NFC, the same PIN entered as composed vs decomposed characters hashes differently and mismatches other clients. A short multi-byte PIN also passes the local guard and is rejected later by the authenticator.Checklist
noMcGaPermissionsWithClientPinis enabled, drop the mc/ga bits from the PIN request and route mc/ga to built-in UV, or return a clear error when UV is unavailable. Use the existingoption_enabled(...)helper onCtap2GetInfoResponse.force_pin_change == Some(true)and drive a change-PIN flow first via the existingPinManagement/UvUpdatemachinery.obtain_pin) and new PIN (change_pin_internal) before hashing or encrypting, using a crate likeunicode-normalization.new_pin.chars().count()), keeping the 63-byte maximum on the UTF-8 length.noMcGaPermissionsWithClientPin, aforcePINChangechange flow, NFC normalization of collected and new PINs, and the code-point min-length boundary.