Firmware for the Pico / Pico 2 that acts as a USB FIDO2 authenticator: CTAP2 and U2F, passkeys, BOOTSEL as the touch button, and clientPIN with PIN/UV auth protocols 1 and 2. A host simulator runs the same core so the python-fido2 test suite exercises it without hardware. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
7.9 KiB
Python
213 lines
7.9 KiB
Python
"""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
|