"""clientPIN: setting and changing the PIN, retries, and PIN tokens.""" import os import pytest from fido2.ctap import CtapError from fido2.ctap2 import Ctap2 from fido2.ctap2.pin import ClientPin, PinProtocolV1, PinProtocolV2 RP = {"id": "example.com", "name": "Example"} ALICE = {"id": b"alice-id", "name": "alice", "displayName": "Alice"} ES256 = [{"type": "public-key", "alg": -7}] PIN = "1234" MC_GA = ClientPin.PERMISSION.MAKE_CREDENTIAL | ClientPin.PERMISSION.GET_ASSERTION @pytest.fixture(params=[PinProtocolV1, PinProtocolV2], ids=["v1", "v2"]) def protocol(request): return request.param() def ctap_error(call): with pytest.raises(CtapError) as e: call() return e.value.code def with_pin(cp, token, client_data_hash): return {"pin_uv_param": cp.protocol.authenticate(token, client_data_hash), "pin_uv_protocol": cp.protocol.VERSION} def make(cp, token=None, rp=RP, rk=False): client_data_hash = os.urandom(32) pin = with_pin(cp, token, client_data_hash) if token else {} options = {"rk": True} if rk else None return cp.ctap.make_credential(client_data_hash, rp, ALICE, ES256, options=options, **pin) def assert_with(cp, token, rp_id=RP["id"], allow_list=None): client_data_hash = os.urandom(32) pin = with_pin(cp, token, client_data_hash) if token else {} return cp.ctap.get_assertion(rp_id, client_data_hash, allow_list=allow_list, **pin) def setup_pin(device, protocol): cp = ClientPin(Ctap2(device), protocol) cp.set_pin(PIN) return cp def test_set_pin(device, protocol): cp = setup_pin(device, protocol) assert Ctap2(device).get_info().options["clientPin"] is True assert cp.get_pin_retries()[0] == 8 def test_set_pin_only_once(device, protocol): cp = setup_pin(device, protocol) assert ctap_error(lambda: cp.set_pin("5678")) == CtapError.ERR.PIN_AUTH_INVALID def test_pin_needs_four_characters(device, protocol): cp = ClientPin(Ctap2(device), protocol) def set_raw(pin: str): key_agreement, shared = cp._get_shared_secret() new_pin_enc = cp.protocol.encrypt(shared, pin.encode().ljust(64, b"\0")) cp.ctap.client_pin( cp.protocol.VERSION, ClientPin.CMD.SET_PIN, key_agreement=key_agreement, new_pin_enc=new_pin_enc, pin_uv_param=cp.protocol.authenticate(shared, new_pin_enc), ) assert ctap_error(lambda: set_raw("123")) == CtapError.ERR.PIN_POLICY_VIOLATION # Characters, not bytes: three two-byte characters are still too short. assert ctap_error(lambda: set_raw("ééé")) == CtapError.ERR.PIN_POLICY_VIOLATION set_raw("éééé") cp.get_pin_token("éééé") def test_registration_requires_pin_once_set(device, protocol): cp = setup_pin(device, protocol) assert ctap_error(lambda: make(cp)) == CtapError.ERR.PUAT_REQUIRED def test_register_and_login_with_pin(device, protocol): cp = setup_pin(device, protocol) token = cp.get_pin_token(PIN, MC_GA, RP["id"]) att = make(cp, token, rk=True) assertion = assert_with(cp, token) assert att.auth_data.is_user_verified() assert assertion.auth_data.is_user_verified() # With the PIN verified, the account names are returned too. assert assertion.user == {"id": ALICE["id"], "name": "alice", "displayName": "Alice"} assert assertion.credential["id"] == att.auth_data.credential_data.credential_id def test_login_without_pin_is_not_verified(device, protocol): cp = setup_pin(device, protocol) make(cp, cp.get_pin_token(PIN, MC_GA, RP["id"]), rk=True) assertion = assert_with(cp, None) assert not assertion.auth_data.is_user_verified() assert assertion.user == {"id": ALICE["id"]} def test_wrong_pin_and_power_cycle_lockout(sim, protocol): cp = setup_pin(sim.device, protocol) assert ctap_error(lambda: cp.get_pin_token("0000")) == CtapError.ERR.PIN_INVALID assert cp.get_pin_retries()[0] == 7 assert ctap_error(lambda: cp.get_pin_token("0000")) == CtapError.ERR.PIN_INVALID assert ctap_error(lambda: cp.get_pin_token("0000")) == CtapError.ERR.PIN_AUTH_BLOCKED # Locked until unplugged, even with the right PIN. assert ctap_error(lambda: cp.get_pin_token(PIN)) == CtapError.ERR.PIN_AUTH_BLOCKED cp = ClientPin(Ctap2(sim.restart()), protocol) assert cp.get_pin_retries()[0] == 5 cp.get_pin_token(PIN) assert cp.get_pin_retries()[0] == 8 def test_pin_blocked_after_eight_failures_until_reset(sim, protocol): cp = setup_pin(sim.device, protocol) codes = [] for attempt in range(8): if attempt in (3, 6): cp = ClientPin(Ctap2(sim.restart()), protocol) codes.append(ctap_error(lambda: cp.get_pin_token("0000"))) assert codes[-1] == CtapError.ERR.PIN_BLOCKED assert ctap_error(lambda: cp.get_pin_token(PIN)) == CtapError.ERR.PIN_BLOCKED ctap = Ctap2(sim.restart()) ctap.reset() assert ctap.get_info().options["clientPin"] is False def test_change_pin(device, protocol): cp = setup_pin(device, protocol) assert ctap_error(lambda: cp.change_pin("0000", "5678")) == CtapError.ERR.PIN_INVALID assert cp.get_pin_retries()[0] == 7 cp.change_pin(PIN, "5678") assert ctap_error(lambda: cp.get_pin_token(PIN)) == CtapError.ERR.PIN_INVALID cp.get_pin_token("5678") assert cp.get_pin_retries()[0] == 8 def test_pin_survives_restart(sim, protocol): setup_pin(sim.device, protocol) cp = ClientPin(Ctap2(sim.restart()), protocol) assert cp.ctap.info.options["clientPin"] is True make(cp, cp.get_pin_token(PIN, MC_GA, RP["id"])) def test_token_permissions(device, protocol): cp = setup_pin(device, protocol) token = cp.get_pin_token(PIN, ClientPin.PERMISSION.GET_ASSERTION, RP["id"]) assert ctap_error(lambda: make(cp, token)) == CtapError.ERR.PIN_AUTH_INVALID def test_token_bound_to_rp(device, protocol): cp = setup_pin(device, protocol) make(cp, cp.get_pin_token(PIN, MC_GA, "other.com"), rp={"id": "other.com"}, rk=True) token = cp.get_pin_token(PIN, MC_GA, RP["id"]) assert ctap_error(lambda: assert_with(cp, token, rp_id="other.com")) == CtapError.ERR.PIN_AUTH_INVALID def test_unsupported_permission_costs_no_retry(device, protocol): cp = setup_pin(device, protocol) code = ctap_error(lambda: cp.get_pin_token(PIN, ClientPin.PERMISSION.CREDENTIAL_MGMT)) assert code == CtapError.ERR.UNAUTHORIZED_PERMISSION assert cp.get_pin_retries()[0] == 8 def test_new_token_replaces_old(device, protocol): cp = setup_pin(device, protocol) old = cp.get_pin_token(PIN, MC_GA, RP["id"]) cp.get_pin_token(PIN, MC_GA, RP["id"]) assert ctap_error(lambda: make(cp, old)) == CtapError.ERR.PIN_AUTH_INVALID def test_legacy_get_pin_token(device, protocol, monkeypatch): # CTAP 2.0 platforms use getPINToken (subcommand 5), which has no permissions. monkeypatch.setattr(ClientPin, "is_token_supported", staticmethod(lambda info: False)) cp = setup_pin(device, protocol) token = cp.get_pin_token(PIN) assert make(cp, token).auth_data.is_user_verified() def test_bad_pin_auth(device, protocol): cp = setup_pin(device, protocol) cp.get_pin_token(PIN, MC_GA, RP["id"]) bogus = {"pin_uv_param": os.urandom(32 if protocol.VERSION == 2 else 16), "pin_uv_protocol": protocol.VERSION} code = ctap_error(lambda: cp.ctap.make_credential(os.urandom(32), RP, ALICE, ES256, **bogus)) assert code == CtapError.ERR.PIN_AUTH_INVALID code = ctap_error(lambda: cp.ctap.make_credential(os.urandom(32), RP, ALICE, ES256, pin_uv_param=os.urandom(32))) assert code == CtapError.ERR.MISSING_PARAMETER def test_empty_pin_auth_with_pin_set(device, protocol): # The "touch to select" probe answers PIN_INVALID once a PIN exists. cp = setup_pin(device, protocol) code = ctap_error( lambda: cp.ctap.make_credential(os.urandom(32), RP, ALICE, ES256, pin_uv_param=b"", pin_uv_protocol=protocol.VERSION) ) assert code == CtapError.ERR.PIN_INVALID