mirror of
https://github.com/trussed-dev/fido-authenticator.git
synced 2026-06-20 04:16:16 -07:00
ctap2.1: §6.5.5 PIN management — code-point validation + reject same-PIN under forcePINChange + align setPin/getPinToken error codes
Fixes: https://github.com/trussed-dev/fido-authenticator/issues/43
This commit is contained in:
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `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)
|
||||
|
||||
|
||||
+73
-15
@@ -772,8 +772,11 @@ impl<UP: UserPresence, T: TrussedRequirements> 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
|
||||
@@ -862,8 +865,40 @@ impl<UP: UserPresence, T: TrussedRequirements> 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();
|
||||
}
|
||||
@@ -913,7 +948,12 @@ impl<UP: UserPresence, T: TrussedRequirements> 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
|
||||
@@ -993,7 +1033,12 @@ impl<UP: UserPresence, T: TrussedRequirements> 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
|
||||
@@ -1579,18 +1624,29 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
|
||||
.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());
|
||||
let min_pin_length = self.state.persistent.min_pin_length().into();
|
||||
if pin_length < min_pin_length || pin_length >= 64 {
|
||||
// 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);
|
||||
}
|
||||
|
||||
pin.resize_zero(pin_length).unwrap();
|
||||
// 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(stripped_len).unwrap();
|
||||
|
||||
Ok(pin)
|
||||
}
|
||||
@@ -1720,6 +1776,8 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
|
||||
//
|
||||
// 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
|
||||
@@ -1727,7 +1785,7 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
|
||||
if !self.state.persistent.pin_is_set() {
|
||||
return Err(Error::PinNotSet);
|
||||
} else {
|
||||
return Err(Error::PinAuthInvalid);
|
||||
return Err(Error::PinInvalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -487,10 +487,21 @@ impl PersistentState {
|
||||
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.7).
|
||||
// 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(())
|
||||
|
||||
+306
-2
@@ -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();
|
||||
@@ -1804,6 +1806,308 @@ fn test_always_uv_get_assertion_up_false_bypasses_uv_requirement() {
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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.
|
||||
|
||||
+14
-1
@@ -391,6 +391,12 @@ pub struct MakeCredential {
|
||||
pub extensions: Option<MakeCredentialExtensionsInput>,
|
||||
pub options: Option<MakeCredentialOptions>,
|
||||
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<Vec<u8>>,
|
||||
pub pin_protocol: Option<u8>,
|
||||
pub attestation_formats_preference: Option<Vec<&'static str>>,
|
||||
}
|
||||
@@ -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<MakeCredential> 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 {
|
||||
|
||||
Reference in New Issue
Block a user