Skey: a FIDO2 security key on the Raspberry Pi Pico

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>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-10-14 15:07:38 -04:00
commit 6c7e6ab5f5
41 files changed
+4218

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
"""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