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)]})