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

+266
View File
@@ -0,0 +1,266 @@
import hashlib
import os
import pytest
from fido2 import cbor
from fido2.attestation import PackedAttestation
from fido2.ctap import CtapError
from fido2.ctap2 import Ctap2
from fido2.hid import CTAPHID
from fido2.webauthn import AuthenticatorData
RP = {"id": "example.com", "name": "Example"}
ALICE = {"id": b"alice-id", "name": "alice", "displayName": "Alice"}
BOB = {"id": b"bob-id", "name": "bob", "displayName": "Bob"}
ES256 = [{"type": "public-key", "alg": -7}]
def descriptor(credential_id):
return {"type": "public-key", "id": credential_id}
def make(ctap, user=ALICE, rp=RP, rk=False, **kwargs):
options = {"rk": True} if rk else None
return ctap.make_credential(os.urandom(32), rp, user, ES256, options=options, **kwargs)
def ctap_error(call):
with pytest.raises(CtapError) as e:
call()
return e.value.code
def test_get_info(device):
info = Ctap2(device).get_info()
assert "FIDO_2_0" in info.versions
assert "U2F_V2" in info.versions
assert info.options["rk"] is True
assert info.options["up"] is True
assert info.options["clientPin"] is False # supported, no PIN set yet
assert info.pin_uv_protocols == [2, 1]
assert len(info.aaguid) == 16
def test_make_credential_attestation(device):
ctap = Ctap2(device)
client_data_hash = os.urandom(32)
att = ctap.make_credential(client_data_hash, RP, ALICE, ES256)
assert att.fmt == "packed"
PackedAttestation().verify(att.att_stmt, att.auth_data, client_data_hash)
assert att.auth_data.rp_id_hash == hashlib.sha256(b"example.com").digest()
assert att.auth_data.is_user_present()
assert not att.auth_data.is_user_verified()
assert att.auth_data.credential_data.aaguid == ctap.info.aaguid
def test_get_assertion(device):
ctap = Ctap2(device)
cred = make(ctap).auth_data.credential_data
client_data_hash = os.urandom(32)
assertion = ctap.get_assertion(RP["id"], client_data_hash, allow_list=[descriptor(cred.credential_id)])
assertion.verify(client_data_hash, cred.public_key)
assert assertion.credential["id"] == cred.credential_id
assert assertion.auth_data.is_user_present()
assert assertion.user is None # not a discoverable credential
def test_counter_increases(device):
ctap = Ctap2(device)
att = make(ctap)
allow = [descriptor(att.auth_data.credential_data.credential_id)]
counters = [att.auth_data.counter]
for _ in range(3):
counters.append(ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow).auth_data.counter)
assert counters == sorted(set(counters))
def test_picks_own_credential_from_allow_list(device):
ctap = Ctap2(device)
cred = make(ctap).auth_data.credential_data
allow = [descriptor(os.urandom(49)), descriptor(b"short"), descriptor(cred.credential_id)]
assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow)
assert assertion.credential["id"] == cred.credential_id
def test_credential_is_bound_to_rp(device):
ctap = Ctap2(device)
cred = make(ctap).auth_data.credential_data
code = ctap_error(lambda: ctap.get_assertion("evil.com", os.urandom(32), allow_list=[descriptor(cred.credential_id)]))
assert code == CtapError.ERR.NO_CREDENTIALS
def test_tampered_credential_rejected(device):
ctap = Ctap2(device)
cred_id = bytearray(make(ctap).auth_data.credential_data.credential_id)
cred_id[5] ^= 1
code = ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(bytes(cred_id))]))
assert code == CtapError.ERR.NO_CREDENTIALS
def test_exclude_list(device):
ctap = Ctap2(device)
cred = make(ctap).auth_data.credential_data
code = ctap_error(lambda: make(ctap, exclude_list=[descriptor(cred.credential_id)]))
assert code == CtapError.ERR.CREDENTIAL_EXCLUDED
# The same ID under another RP isn't ours, so it doesn't block.
make(ctap, rp={"id": "other.com"}, exclude_list=[descriptor(cred.credential_id)])
def test_unsupported_algorithm(device):
ctap = Ctap2(device)
code = ctap_error(lambda: ctap.make_credential(os.urandom(32), RP, ALICE, [{"type": "public-key", "alg": -257}]))
assert code == CtapError.ERR.UNSUPPORTED_ALGORITHM
def test_uv_not_supported(device):
ctap = Ctap2(device)
code = ctap_error(lambda: ctap.make_credential(os.urandom(32), RP, ALICE, ES256, options={"uv": True}))
assert code == CtapError.ERR.UNSUPPORTED_OPTION
def test_missing_parameter(device):
ctap = Ctap2(device)
code = ctap_error(lambda: ctap.send_cbor(Ctap2.CMD.MAKE_CREDENTIAL, {1: os.urandom(32), 2: RP, 4: ES256}))
assert code == CtapError.ERR.MISSING_PARAMETER
def test_empty_pin_auth_means_select(device):
ctap = Ctap2(device)
code = ctap_error(lambda: make(ctap, pin_uv_param=b"", pin_uv_protocol=1))
assert code == CtapError.ERR.PIN_NOT_SET
def test_silent_assertion(device):
ctap = Ctap2(device)
cred = make(ctap).auth_data.credential_data
assertion = ctap.get_assertion(
RP["id"], os.urandom(32), allow_list=[descriptor(cred.credential_id)], options={"up": False}
)
assert not assertion.auth_data.is_user_present()
def test_discoverable_credentials(device):
ctap = Ctap2(device)
alice = make(ctap, ALICE, rk=True).auth_data.credential_data
bob = make(ctap, BOB, rk=True).auth_data.credential_data
make(ctap, ALICE, rp={"id": "other.com"}, rk=True)
client_data_hash = os.urandom(32)
first = ctap.get_assertion(RP["id"], client_data_hash)
second = ctap.get_next_assertion()
assert first.number_of_credentials == 2
assert second.number_of_credentials is None
# Newest first; without user verification only the user ID is returned.
assert first.user == {"id": BOB["id"]}
assert second.user == {"id": ALICE["id"]}
first.verify(client_data_hash, bob.public_key)
second.verify(client_data_hash, alice.public_key)
assert ctap_error(ctap.get_next_assertion) == CtapError.ERR.NOT_ALLOWED
def test_discoverable_via_allow_list_includes_user(device):
ctap = Ctap2(device)
cred = make(ctap, ALICE, rk=True).auth_data.credential_data
assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(cred.credential_id)])
assert assertion.user == {"id": ALICE["id"]}
def test_no_discoverable_credentials(device):
ctap = Ctap2(device)
make(ctap) # not discoverable
assert ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32))) == CtapError.ERR.NO_CREDENTIALS
def test_same_user_replaces_credential(device):
ctap = Ctap2(device)
make(ctap, ALICE, rk=True)
newest = make(ctap, ALICE, rk=True).auth_data.credential_data
assertion = ctap.get_assertion(RP["id"], os.urandom(32))
assert assertion.number_of_credentials is None
assert assertion.credential["id"] == newest.credential_id
def test_replacing_many_times_reclaims_space(device):
# Each replacement leaves a dead slot; this needs several compactions.
ctap = Ctap2(device)
for _ in range(150):
cred = make(ctap, ALICE, rk=True).auth_data.credential_data
assertion = ctap.get_assertion(RP["id"], os.urandom(32))
assert assertion.credential["id"] == cred.credential_id
assert assertion.number_of_credentials is None
def test_key_store_full(device):
ctap = Ctap2(device)
for i in range(64):
make(ctap, {"id": b"user-%d" % i, "name": "u%d" % i}, rk=True)
code = ctap_error(lambda: make(ctap, {"id": b"one-too-many", "name": "x"}, rk=True))
assert code == CtapError.ERR.KEY_STORE_FULL
# Replacing an existing user still works when full.
make(ctap, {"id": b"user-0", "name": "u0"}, rk=True)
def test_state_survives_restart(sim):
ctap = Ctap2(sim.device)
resident = make(ctap, ALICE, rk=True)
plain = make(ctap, BOB).auth_data.credential_data
ctap = Ctap2(sim.restart())
assertion = ctap.get_assertion(RP["id"], os.urandom(32))
assert assertion.credential["id"] == resident.auth_data.credential_data.credential_id
assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(plain.credential_id)])
assert assertion.auth_data.counter > resident.auth_data.counter
def test_counter_survives_sector_rollover(sim):
# The counter log holds 1024 values per sector; cross into the second sector and back.
ctap = Ctap2(sim.device)
cred = make(ctap).auth_data.credential_data
allow = [descriptor(cred.credential_id)]
last = 0
for _ in range(2100):
counter = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow, options={"up": False}).auth_data.counter
assert counter > last
last = counter
ctap = Ctap2(sim.restart())
assert ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow).auth_data.counter == last + 1
def test_reset(device):
ctap = Ctap2(device)
plain = make(ctap).auth_data.credential_data
make(ctap, ALICE, rk=True)
ctap.reset()
code = ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(plain.credential_id)]))
assert code == CtapError.ERR.NO_CREDENTIALS
assert ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32))) == CtapError.ERR.NO_CREDENTIALS
def test_responses_are_canonical_cbor(device):
# Browsers reject non-canonical CBOR (e.g. map keys out of order); python-fido2
# does not, so compare the raw bytes against a canonical re-encoding.
def raw_call(cmd, request=None):
payload = bytes([cmd]) + (cbor.encode(request) if request else b"")
response = device.call(CTAPHID.CBOR, payload)
assert response[0] == 0
assert cbor.encode(cbor.decode(response[1:])) == response[1:]
return cbor.decode(response[1:])
raw_call(Ctap2.CMD.GET_INFO)
raw_call(Ctap2.CMD.CLIENT_PIN, {1: 2, 2: 2}) # getKeyAgreement
raw_call(Ctap2.CMD.CLIENT_PIN, {2: 1}) # getPinRetries
client_data_hash = os.urandom(32)
att = raw_call(Ctap2.CMD.MAKE_CREDENTIAL, {1: client_data_hash, 2: RP, 3: ALICE, 4: ES256, 7: {"rk": True}})
make(Ctap2(device), BOB, rk=True)
raw_call(Ctap2.CMD.GET_ASSERTION, {1: RP["id"], 2: client_data_hash})
raw_call(Ctap2.CMD.GET_NEXT_ASSERTION)
cred_id = AuthenticatorData(att[2]).credential_data.credential_id
raw_call(Ctap2.CMD.GET_ASSERTION, {1: RP["id"], 2: client_data_hash, 3: [descriptor(cred_id)]})