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>
267 lines
10 KiB
Python
267 lines
10 KiB
Python
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)]})
|