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,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()
|
||||
@@ -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
|
||||
@@ -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)]})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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