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:
commit
6c7e6ab5f5
41 files changed
+4218
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user