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

+106
View File
@@ -0,0 +1,106 @@
"""Runs the authenticator simulator and exposes it as a python-fido2 HID device."""
import os
import pathlib
import subprocess
import threading
import pytest
from fido2.hid import CtapHidDevice
from fido2.hid.base import CtapHidConnection, HidDescriptor
ROOT = pathlib.Path(__file__).resolve().parent.parent
SIMULATOR = pathlib.Path(os.environ.get("SKEY_SIM", ROOT / "build-host" / "skey_sim"))
PACKET_SIZE = 64
class PipeConnection(CtapHidConnection):
"""Carries HID reports over the simulator's stdin/stdout."""
def __init__(self, proc: subprocess.Popen):
self.proc = proc
def write_packet(self, data: bytes) -> None:
assert len(data) == PACKET_SIZE
self.proc.stdin.write(data)
self.proc.stdin.flush()
def read_packet(self) -> bytes:
data = self.proc.stdout.read(PACKET_SIZE)
if len(data) != PACKET_SIZE:
raise OSError(f"simulator exited with {self.proc.wait()}")
return data
def close(self) -> None:
self.proc.stdin.close()
assert self.proc.wait(timeout=10) == 0, "simulator crashed"
class Simulator:
"""One simulated key. Restarting keeps its flash, like unplugging a real one.
Without a button file every request is approved; with one, press() is a
touch of the button.
"""
def __init__(self, flash_file: pathlib.Path, button_file: pathlib.Path | None = None):
self.flash_file = flash_file
self.button_file = button_file
self.device = None
def start(self) -> CtapHidDevice:
if not SIMULATOR.exists():
pytest.fail(f"{SIMULATOR} not found; build it with: cmake -B build-host -DSKEY_HOST=ON && cmake --build build-host")
env = {**os.environ, "SKEY_FLASH_FILE": str(self.flash_file)}
if self.button_file:
env["SKEY_BUTTON_FILE"] = str(self.button_file)
proc = subprocess.Popen([SIMULATOR], stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env)
descriptor = HidDescriptor(
path="skey-sim",
vid=0x1209,
pid=0x0001,
report_size_in=PACKET_SIZE,
report_size_out=PACKET_SIZE,
product_name="Skey simulator",
serial_number="sim",
)
self.device = CtapHidDevice(descriptor, PipeConnection(proc))
return self.device
def stop(self) -> None:
if self.device:
self.device.close()
self.device = None
def restart(self) -> CtapHidDevice:
self.stop()
return self.start()
def press(self) -> None:
self.button_file.touch()
def press_after(self, seconds: float) -> threading.Timer:
timer = threading.Timer(seconds, self.press)
timer.start()
return timer
@pytest.fixture
def sim(tmp_path):
simulator = Simulator(tmp_path / "flash.bin")
simulator.start()
yield simulator
simulator.stop()
@pytest.fixture
def device(sim):
return sim.device
@pytest.fixture
def button_sim(tmp_path):
simulator = Simulator(tmp_path / "flash.bin", tmp_path / "button")
simulator.start()
yield simulator
simulator.stop()
+55
View File
@@ -0,0 +1,55 @@
import hashlib
import os
import pytest
from fido2.ctap1 import APDU, ApduError, Ctap1
from fido2.ctap2 import Ctap2
APP_ID = hashlib.sha256(b"example.com").digest()
def test_version(device):
assert Ctap1(device).get_version() == "U2F_V2"
def test_register_and_authenticate(device):
ctap1 = Ctap1(device)
challenge = os.urandom(32)
reg = ctap1.register(challenge, APP_ID)
reg.verify(APP_ID, challenge)
challenge = os.urandom(32)
sig = ctap1.authenticate(challenge, APP_ID, reg.key_handle)
sig.verify(APP_ID, challenge, reg.public_key)
assert sig.user_presence & 1
def test_check_only(device):
ctap1 = Ctap1(device)
reg = ctap1.register(os.urandom(32), APP_ID)
with pytest.raises(ApduError) as e:
ctap1.authenticate(os.urandom(32), APP_ID, reg.key_handle, check_only=True)
assert e.value.code == APDU.USE_NOT_SATISFIED
def test_wrong_app_id(device):
ctap1 = Ctap1(device)
reg = ctap1.register(os.urandom(32), APP_ID)
with pytest.raises(ApduError) as e:
ctap1.authenticate(os.urandom(32), hashlib.sha256(b"evil.com").digest(), reg.key_handle)
assert e.value.code == APDU.WRONG_DATA
def test_u2f_credential_works_with_ctap2(device):
reg = Ctap1(device).register(os.urandom(32), APP_ID)
client_data_hash = os.urandom(32)
assertion = Ctap2(device).get_assertion(
"example.com", client_data_hash, allow_list=[{"type": "public-key", "id": reg.key_handle}]
)
assert assertion.credential["id"] == reg.key_handle
def test_unknown_instruction(device):
with pytest.raises(ApduError) as e:
Ctap1(device).send_apdu(ins=0x55)
assert e.value.code == 0x6D00 # INS not supported
+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)]})
+62
View File
@@ -0,0 +1,62 @@
import os
import struct
import pytest
from fido2.ctap import CtapError
from fido2.hid import CAPABILITY, CTAPHID
def test_init_assigns_channel(device):
assert device._channel_id not in (0, 0xFFFFFFFF)
assert device.capabilities & CAPABILITY.CBOR
assert device.capabilities & CAPABILITY.WINK
def test_ping_single_packet(device):
assert device.ping(b"hello") == b"hello"
def test_ping_multi_packet(device):
# 57 bytes in the first packet, then 59 per continuation packet.
payload = os.urandom(3000)
assert device.ping(payload) == payload
def test_ping_largest_message(device):
payload = os.urandom(57 + 128 * 59)
assert device.ping(payload) == payload
def test_wink(device):
device.wink()
def test_unknown_command(device):
with pytest.raises(CtapError) as e:
device.call(0x40)
assert e.value.code == CtapError.ERR.INVALID_COMMAND
def test_out_of_order_continuation(device):
conn = device._connection
cid = device._channel_id
conn.write_packet(struct.pack(">IBH", cid, 0x80 | CTAPHID.PING, 100).ljust(64, b"\0"))
conn.write_packet(struct.pack(">IB", cid, 1).ljust(64, b"\0")) # expected seq 0
response = conn.read_packet()
assert response[:7] == struct.pack(">IBH", cid, 0x80 | CTAPHID.ERROR, 1)
assert response[7] == CtapError.ERR.INVALID_SEQ
assert device.ping(b"still alive") == b"still alive"
def test_other_channel_busy_while_receiving(device):
conn = device._connection
cid = device._channel_id
conn.write_packet(struct.pack(">IBH", cid, 0x80 | CTAPHID.PING, 100).ljust(64, b"\0"))
conn.write_packet(struct.pack(">IBH", cid + 1, 0x80 | CTAPHID.PING, 1).ljust(64, b"\0"))
response = conn.read_packet()
assert response[:8] == struct.pack(">IBHB", cid + 1, 0x80 | CTAPHID.ERROR, 1, CtapError.ERR.CHANNEL_BUSY)
# Finish the original message.
conn.write_packet(struct.pack(">IB", cid, 0).ljust(64, b"\0"))
response = conn.read_packet()
assert response[:7] == struct.pack(">IBH", cid, 0x80 | CTAPHID.PING, 100)
conn.read_packet() # continuation packet with the rest of the echo
+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
+141
View File
@@ -0,0 +1,141 @@
"""The touch button: requests wait for a press, with keepalives and cancel."""
import os
import struct
import threading
import time
import pytest
from fido2.ctap import STATUS, CtapError
from fido2.ctap1 import APDU, ApduError, Ctap1
from fido2.ctap2 import Ctap2
from fido2.hid import CTAPHID
from fido2 import cbor
RP = {"id": "example.com", "name": "Example"}
ALICE = {"id": b"alice-id", "name": "alice"}
ES256 = [{"type": "public-key", "alg": -7}]
TOUCH_DELAY = 0.3
def make(ctap, **kwargs):
return ctap.make_credential(os.urandom(32), RP, ALICE, ES256, **kwargs)
def test_waits_for_touch_with_keepalives(button_sim):
ctap = Ctap2(button_sim.device)
statuses = []
button_sim.press_after(TOUCH_DELAY)
start = time.monotonic()
att = make(ctap, on_keepalive=statuses.append)
assert time.monotonic() - start >= TOUCH_DELAY
assert STATUS.UPNEEDED in statuses
assert att.auth_data.is_user_present()
def test_assertion_waits_for_touch(button_sim):
ctap = Ctap2(button_sim.device)
button_sim.press_after(0.05) # presses only count once the request has arrived
cred = make(ctap).auth_data.credential_data
allow = [{"type": "public-key", "id": cred.credential_id}]
button_sim.press_after(TOUCH_DELAY)
start = time.monotonic()
assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow)
assert time.monotonic() - start >= TOUCH_DELAY
assert assertion.auth_data.is_user_present()
def test_press_before_request_does_not_count(button_sim):
ctap = Ctap2(button_sim.device)
button_sim.press()
time.sleep(0.1)
button_sim.press_after(TOUCH_DELAY)
start = time.monotonic()
make(ctap)
assert time.monotonic() - start >= TOUCH_DELAY
def test_silent_assertion_needs_no_touch(button_sim):
ctap = Ctap2(button_sim.device)
button_sim.press_after(0.05) # presses only count once the request has arrived
cred = make(ctap).auth_data.credential_data
allow = [{"type": "public-key", "id": cred.credential_id}]
assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow, options={"up": False})
assert not assertion.auth_data.is_user_present()
def test_cancel(button_sim):
ctap = Ctap2(button_sim.device)
cancel = threading.Event()
threading.Timer(TOUCH_DELAY, cancel.set).start()
with pytest.raises(CtapError) as e:
make(ctap, event=cancel)
assert e.value.code == CtapError.ERR.KEEPALIVE_CANCEL
assert ctap.get_info().versions # still responsive
def test_u2f_fails_until_touched(button_sim):
ctap1 = Ctap1(button_sim.device)
app_id, challenge = os.urandom(32), os.urandom(32)
with pytest.raises(ApduError) as e:
ctap1.register(challenge, app_id)
assert e.value.code == APDU.USE_NOT_SATISFIED
# Hosts retry; a press since the last attempt lets the next one through.
button_sim.press()
reg = ctap1.register(challenge, app_id)
reg.verify(app_id, challenge)
with pytest.raises(ApduError) as e:
ctap1.authenticate(challenge, app_id, reg.key_handle)
assert e.value.code == APDU.USE_NOT_SATISFIED
button_sim.press()
ctap1.authenticate(challenge, app_id, reg.key_handle).verify(app_id, challenge, reg.public_key)
def send_message(conn, cid, cmd, data):
conn.write_packet((struct.pack(">IBH", cid, 0x80 | cmd, len(data)) + data[:57]).ljust(64, b"\0"))
data, seq = data[57:], 0
while data:
conn.write_packet((struct.pack(">IB", cid, seq) + data[:59]).ljust(64, b"\0"))
data, seq = data[59:], seq + 1
def read_until(conn, cid, cmd):
"""Reads packets until an init packet for (cid, cmd); returns all seen."""
seen = []
while True:
packet = conn.read_packet()
r_cid, r_cmd = struct.unpack_from(">IB", packet)
seen.append((r_cid, r_cmd, packet))
if (r_cid, r_cmd) == (cid, 0x80 | cmd):
return seen
def test_other_channels_busy_while_waiting(button_sim):
conn = button_sim.device._connection
cid = button_sim.device._channel_id
request = bytes([Ctap2.CMD.MAKE_CREDENTIAL]) + cbor.encode({1: os.urandom(32), 2: RP, 3: ALICE, 4: ES256})
send_message(conn, cid, CTAPHID.CBOR, request)
read_until(conn, cid, CTAPHID.KEEPALIVE)
send_message(conn, cid + 1, CTAPHID.PING, b"hi")
seen = read_until(conn, cid + 1, CTAPHID.ERROR)
assert seen[-1][2][7] == CtapError.ERR.CHANNEL_BUSY
button_sim.press()
seen = read_until(conn, cid, CTAPHID.CBOR)
assert seen[-1][2][7] == 0 # CTAP2_OK
assert all(r_cid == cid for r_cid, _, _ in seen)
# Drain the rest of the response so the connection closes cleanly.
length = struct.unpack_from(">H", seen[-1][2], 5)[0]
for _ in range((max(0, length - 57) + 58) // 59):
conn.read_packet()