From 56132528fe4e1edda1b7e669e530353cdac7e0db Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 16:31:07 +0200 Subject: [PATCH] Add CardEmulator and fix fido2-direct id= constructor bugs tests/card_emulator.py: software emulator of the ChromeCard FIDO2 authenticator. Implements make_credential and get_assertion with real P-256 cryptography and an in-memory credential store. Both methods accept user_confirms=True/False to simulate the card's Yes/No confirmation dialog; False raises CtapError(OPERATION_DENIED). refusing() returns a wrapper that forces user_confirms=False for integration tests that route through _with_direct_ctap2. forget_user() simulates card-side credential removal. Module docstring serves as the usage guide. tests/test_k_proxy.py: 22 new tests across TestCardEmulatorUnit (direct emulator calls) and TestCardEmulatorIntegration (full ProxyState flows covering register, authenticate, user-says-no, forget, two-user isolation, and sign-count monotonicity). k_proxy_app.py: fix two bugs where RegistrationResponse and AuthenticationResponse were constructed with id= instead of raw_id=. Both calls raised TypeError at runtime, silently caught by the surrounding except block, making all fido2-direct register and authenticate calls fail. Co-Authored-By: Claude Sonnet 4.6 --- k_proxy_app.py | 4 +- tests/card_emulator.py | 339 +++++++++++++++++++++++++++++++++++++++++ tests/test_k_proxy.py | 244 +++++++++++++++++++++++++++++ 3 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 tests/card_emulator.py diff --git a/k_proxy_app.py b/k_proxy_app.py index 211e326..ca72ba1 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -757,7 +757,7 @@ class ProxyState: auth_data = self.fido_server.register_complete( state, RegistrationResponse( - id=attestation.auth_data.credential_data.credential_id, + raw_id=attestation.auth_data.credential_data.credential_id, response=AuthenticatorAttestationResponse( client_data=client_data, attestation_object=AttestationObject.create( @@ -888,7 +888,7 @@ class ProxyState: state, [credential], AuthenticationResponse( - id=response.credential["id"], + raw_id=response.credential["id"], response=AuthenticatorAssertionResponse( client_data=client_data, authenticator_data=response.auth_data, diff --git a/tests/card_emulator.py b/tests/card_emulator.py new file mode 100644 index 0000000..a5b7b3f --- /dev/null +++ b/tests/card_emulator.py @@ -0,0 +1,339 @@ +""" +CardEmulator — software emulator of the ChromeCard FIDO2 authenticator +====================================================================== + +What it is +---------- +CardEmulator is a drop-in replacement for the physical ChromeCard in tests. +It implements the two Ctap2 methods that k_proxy_app calls in fido2-direct +mode — make_credential (registration) and get_assertion (authentication) — +using real P-256 cryptography and an in-memory credential store. + +The auth_data layout and COSE key encoding mirror the firmware exactly +(see fido_make_cred.c and fido_get_assertion.c), so fido2.server's +register_complete and authenticate_complete accept the emulator's responses +without any extra patching. + + +Wiring the emulator into a ProxyState test +------------------------------------------ +Two patches are needed: one to replace _with_direct_ctap2 so it hands the +emulator to the lambda instead of opening a real HID device, and one to +suppress _drop_direct_device which would otherwise try to close a real handle. + +A convenience helper for this is provided in test_k_proxy.py: + + def _patch_emulator(state, emulator): + return patch.multiple( + state, + _with_direct_ctap2=lambda fn: fn(emulator), + _drop_direct_device=lambda: None, + ) + +Typical test setup: + + from card_emulator import CardEmulator + from unittest.mock import patch + + emulator = CardEmulator() + state = _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + + with _patch_emulator(state, emulator): + enrollment = state.register_enrollment("alice", "Alice") + ok, msg = state.authenticate_with_card("alice") # True, "assertion verified" + + +User confirmation (Yes / No on the card) +----------------------------------------- +Both make_credential and get_assertion accept a `user_confirms` keyword +argument (default True). Setting it to False raises +CtapError(OPERATION_DENIED), exactly as the firmware does when the user taps +No on the card's confirmation dialog. + +Direct calls — pass the flag explicitly: + + attest = emulator.make_credential( + client_data_hash=..., rp=..., user=..., key_params=..., + user_confirms=False, + ) # raises CtapError(OPERATION_DENIED) + +Integration tests through _with_direct_ctap2 — the lambda that ProxyState +builds cannot inject user_confirms, so use refusing() instead. It returns +a thin wrapper whose methods forward to the emulator with user_confirms=False: + + with _patch_emulator(state, emulator.refusing()): + ok, msg = state.authenticate_with_card("alice") # False + # msg contains "assertion verification failed: CTAP error: OPERATION_DENIED" + + with _patch_emulator(state, emulator.refusing()): + with self.assertRaises(RuntimeError): + state.register_enrollment("bob", None) + # raises RuntimeError("card registration failed: ...") + +refusing() shares the same credential store as the parent emulator, so +credentials registered before or after the call are visible to both. + + +Simulating card-side credential removal +---------------------------------------- +forget_user(username) removes all credentials for that user from the +emulator's store and returns the count removed. Use it to simulate a +factory reset or a deliberate key deletion: + + emulator.forget_user("alice") + ok, msg = state.authenticate_with_card("alice") # False + + +API summary +----------- +CardEmulator() + Create a new emulator with an empty credential store. + +make_credential(client_data_hash, rp, user, key_params, *, user_confirms=True) + Simulate CTAP2 makeCredential. Returns AttestationResponse. + +get_assertion(rp_id, client_data_hash, allow_list, *, user_confirms=True) + Simulate CTAP2 getAssertion. Returns AssertionResponse. + Raises CtapError(NO_CREDENTIALS) if no matching credential is found. + +refusing() -> _RefusingView + Return a wrapper that forces user_confirms=False on every call. + +forget_user(username) -> int + Remove all credentials for username. Returns count removed. + +credential_count() -> int + Total credentials currently in the store. +""" + +from __future__ import annotations + +import hashlib +import os +import struct +from dataclasses import dataclass +from typing import Any, Mapping + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from fido2.ctap import CtapError +from fido2.ctap2 import AssertionResponse, AttestationResponse +from fido2.webauthn import AuthenticatorData + +# AAGUID from fido_make_cred.c +_AAGUID = bytes([ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, +]) + + +def _cose_es256(x: bytes, y: bytes) -> bytes: + """CBOR-encoded COSE ES256 public key, byte-for-byte as the firmware builds it.""" + return ( + bytes([0xA5]) # map(5) + + bytes([0x01, 0x02]) # kty: 2 (EC2) + + bytes([0x03, 0x26]) # alg: -7 (ES256) + + bytes([0x20, 0x01]) # crv (-1): 1 (P-256) + + bytes([0x21, 0x58, 0x20]) + x # x (-2): bstr(32) + + bytes([0x22, 0x58, 0x20]) + y # y (-3): bstr(32) + ) + + +@dataclass +class _Credential: + private_key: ec.EllipticCurvePrivateKey + rp_id_hash: bytes + user_id: bytes + username: str + sign_count: int = 0 + + +class CardEmulator: + """In-process emulator of a ChromeCard FIDO2 authenticator. + + Implements make_credential and get_assertion with the same signatures as + fido2.ctap2.Ctap2, plus forget_user() for test teardown. + """ + + def __init__(self) -> None: + self._creds: dict[bytes, _Credential] = {} + + # ── CTAP2 interface ─────────────────────────────────────────────────────── + + def make_credential( + self, + client_data_hash: bytes, + rp: Mapping[str, Any], + user: Mapping[str, Any], + key_params: list[Mapping[str, Any]], + exclude_list: list[Mapping[str, Any]] | None = None, + extensions: Mapping[str, Any] | None = None, + options: Mapping[str, Any] | None = None, + *, + user_confirms: bool = True, + **_: Any, + ) -> AttestationResponse: + """Simulate makeCredential. + + When user_confirms is False the call raises CtapError(OPERATION_DENIED), + mirroring the firmware's response when the user taps No on the card. + Otherwise a real P-256 keypair is generated, stored, and returned as a + fmt='none' AttestationResponse with a valid COSE ES256 public key. + """ + if not user_confirms: + raise CtapError(CtapError.ERR.OPERATION_DENIED) + + rp_id: str = rp["id"] + rp_id_hash = hashlib.sha256(rp_id.encode()).digest() + + priv = ec.generate_private_key(ec.SECP256R1()) + pub_nums = priv.public_key().public_numbers() + x = pub_nums.x.to_bytes(32, "big") + y = pub_nums.y.to_bytes(32, "big") + + credential_id = os.urandom(32) + + raw_user_id: bytes = user.get("id", b"") # type: ignore[assignment] + if isinstance(raw_user_id, str): + raw_user_id = raw_user_id.encode() + + cred = _Credential( + private_key=priv, + rp_id_hash=rp_id_hash, + user_id=raw_user_id, + username=user.get("name", ""), # type: ignore[arg-type] + ) + + # authData layout matches fido_make_cred.c build_make_credential_response(): + # rpIdHash(32) | flags(1) | signCount(4) | aaguid(16) + # | credIdLen(2) | credId(32) | coseKey(77) + auth_data_bytes = ( + rp_id_hash + + bytes([0x41]) # flags: UP=1, AT=1 + + struct.pack(">I", cred.sign_count) + + _AAGUID + + struct.pack(">H", len(credential_id)) + + credential_id + + _cose_es256(x, y) + ) + cred.sign_count += 1 + self._creds[credential_id] = cred + + return AttestationResponse( + fmt="none", + auth_data=AuthenticatorData(auth_data_bytes), + att_stmt={}, + ) + + def get_assertion( + self, + rp_id: str, + client_data_hash: bytes, + allow_list: list[Mapping[str, Any]] | None = None, + extensions: Mapping[str, Any] | None = None, + options: Mapping[str, Any] | None = None, + *, + user_confirms: bool = True, + **_: Any, + ) -> AssertionResponse: + """Simulate getAssertion. + + When user_confirms is False raises CtapError(OPERATION_DENIED). + Otherwise finds the credential, builds authData (37 bytes, no AT flag), + signs authData || clientDataHash with ECDSA-SHA256 (DER), and returns + an AssertionResponse — byte-for-byte compatible with the firmware output. + """ + if not user_confirms: + raise CtapError(CtapError.ERR.OPERATION_DENIED) + + rp_id_hash = hashlib.sha256(rp_id.encode()).digest() + + if not allow_list: + raise CtapError(CtapError.ERR.NO_CREDENTIALS) + + cred_id: bytes | None = None + cred: _Credential | None = None + for desc in allow_list: + cid: bytes = desc["id"] if isinstance(desc, dict) else getattr(desc, "id") + entry = self._creds.get(cid) + if entry is not None and entry.rp_id_hash == rp_id_hash: + cred_id = cid + cred = entry + break + + if cred is None or cred_id is None: + raise CtapError(CtapError.ERR.NO_CREDENTIALS) + + # authData layout matches fido_get_assertion.c build_get_assertion_response(): + # rpIdHash(32) | flags(1) | signCount(4) + auth_data_bytes = ( + rp_id_hash + + bytes([0x01]) # flags: UP=1 + + struct.pack(">I", cred.sign_count) + ) + cred.sign_count += 1 + + # Signature over authData || clientDataHash, DER-encoded. + # Matches drv_crypto_sign_hash_DER() in the firmware. + sig = cred.private_key.sign( + auth_data_bytes + client_data_hash, + ec.ECDSA(hashes.SHA256()), + ) + + user_field: dict[str, Any] | None = {"id": cred.user_id} if cred.user_id else None + + return AssertionResponse( + credential={"id": cred_id, "type": "public-key"}, + auth_data=AuthenticatorData(auth_data_bytes), + signature=sig, + user=user_field, + ) + + # ── test helpers ────────────────────────────────────────────────────────── + + def refusing(self) -> "_RefusingView": + """Return a view of this emulator that always declines confirmation. + + Use this when the test goes through _with_direct_ctap2, which calls + make_credential / get_assertion via a lambda and cannot inject + user_confirms directly: + + with patch.object(state, "_with_direct_ctap2", + side_effect=lambda fn: fn(emulator.refusing())): + ok, msg = state.authenticate_with_card("alice") + self.assertFalse(ok) + """ + return _RefusingView(self) + + def forget_user(self, username: str) -> int: + """Remove all credentials for *username* from the emulator store. + + Returns the number of credentials removed. Use this to simulate a + card-side credential deletion (factory reset, or deliberate removal). + """ + to_delete = [cid for cid, e in self._creds.items() if e.username == username] + for cid in to_delete: + del self._creds[cid] + return len(to_delete) + + def credential_count(self) -> int: + """Total number of credentials currently in the emulator store.""" + return len(self._creds) + + +class _RefusingView: + """Thin proxy returned by CardEmulator.refusing(). + + Forwards make_credential and get_assertion to the underlying emulator + with user_confirms=False, so every call raises OPERATION_DENIED. + The credential store is shared with the parent emulator. + """ + + def __init__(self, emulator: CardEmulator) -> None: + self._emulator = emulator + + def make_credential(self, **kwargs: Any) -> AttestationResponse: + return self._emulator.make_credential(**kwargs, user_confirms=False) + + def get_assertion(self, **kwargs: Any) -> AssertionResponse: + return self._emulator.get_assertion(**kwargs, user_confirms=False) diff --git a/tests/test_k_proxy.py b/tests/test_k_proxy.py index 91f9cf0..cd010b4 100644 --- a/tests/test_k_proxy.py +++ b/tests/test_k_proxy.py @@ -18,6 +18,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent)) import k_proxy_app as app from k_proxy_app import ( @@ -800,5 +801,248 @@ class TestHandlerResource(ServerFixture): self.assertEqual(status, 200) +# ── card emulator integration tests ────────────────────────────────────────── + +from card_emulator import CardEmulator + + +def _make_direct_state(tmp_path): + return _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + + +def _patch_emulator(state, emulator): + """Return a context manager that wires *emulator* into *state* as the card.""" + return patch.multiple( + state, + _with_direct_ctap2=lambda fn: fn(emulator), + _drop_direct_device=lambda: None, + ) + + +class TestCardEmulatorUnit(unittest.TestCase): + """Direct calls to the emulator — no ProxyState involved.""" + + def setUp(self): + self.emulator = CardEmulator() + + def _register(self, username="alice", rp_id="localhost"): + rp_id_hash = __import__("hashlib").sha256(rp_id.encode()).digest() + return self.emulator.make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": rp_id, "name": "Test RP"}, + user={"id": b"user-id", "name": username, "displayName": username}, + key_params=[{"type": "public-key", "alg": -7}], + ) + + def test_make_credential_returns_none_attestation(self): + attest = self._register() + self.assertEqual(attest.fmt, "none") + self.assertEqual(attest.att_stmt, {}) + + def test_make_credential_stores_credential(self): + self._register() + self.assertEqual(self.emulator.credential_count(), 1) + + def test_make_credential_auth_data_is_attested(self): + attest = self._register() + self.assertTrue(attest.auth_data.is_attested()) + + def test_make_credential_cred_id_is_32_bytes(self): + attest = self._register() + self.assertEqual(len(attest.auth_data.credential_data.credential_id), 32) + + def test_make_credential_user_confirms_false_raises(self): + from fido2.ctap import CtapError + with self.assertRaises(CtapError) as ctx: + self._register() # first register so there's a credential + self.emulator.make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": "localhost", "name": "Test RP"}, + user={"id": b"user-id", "name": "bob", "displayName": "bob"}, + key_params=[{"type": "public-key", "alg": -7}], + user_confirms=False, + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_get_assertion_user_confirms_false_raises(self): + from fido2.ctap import CtapError + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError) as ctx: + self.emulator.get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + user_confirms=False, + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_get_assertion_wrong_rp_raises(self): + from fido2.ctap import CtapError + attest = self._register(rp_id="localhost") + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError): + self.emulator.get_assertion( + rp_id="evil.example", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + + def test_get_assertion_empty_allow_list_raises(self): + from fido2.ctap import CtapError + self._register() + with self.assertRaises(CtapError): + self.emulator.get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=None, + ) + + def test_sign_count_increments_across_assertions(self): + import struct + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + + def _count(assertion): + return struct.unpack(">I", bytes(assertion.auth_data)[33:37])[0] + + a1 = self.emulator.get_assertion("localhost", b"\x01" * 32, + [{"id": cred_id, "type": "public-key"}]) + a2 = self.emulator.get_assertion("localhost", b"\x02" * 32, + [{"id": cred_id, "type": "public-key"}]) + self.assertGreater(_count(a2), _count(a1)) + + def test_forget_user_removes_credential(self): + self._register() + removed = self.emulator.forget_user("alice") + self.assertEqual(removed, 1) + self.assertEqual(self.emulator.credential_count(), 0) + + def test_forget_unknown_user_returns_zero(self): + self._register() + self.assertEqual(self.emulator.forget_user("nobody"), 0) + self.assertEqual(self.emulator.credential_count(), 1) + + def test_refusing_view_make_credential_raises(self): + from fido2.ctap import CtapError + with self.assertRaises(CtapError) as ctx: + self.emulator.refusing().make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": "localhost", "name": "Test RP"}, + user={"id": b"u", "name": "alice", "displayName": "Alice"}, + key_params=[{"type": "public-key", "alg": -7}], + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_refusing_view_get_assertion_raises(self): + from fido2.ctap import CtapError + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError) as ctx: + self.emulator.refusing().get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + +class TestCardEmulatorIntegration(unittest.TestCase): + """Full register → authenticate flow through ProxyState with the emulator.""" + + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmpdir.name) + self.state = _make_direct_state(self.tmp_path) + self.emulator = CardEmulator() + + def tearDown(self): + self._tmpdir.cleanup() + + def _register(self, username="alice", display_name=None): + with _patch_emulator(self.state, self.emulator): + return self.state.register_enrollment(username, display_name) + + def _authenticate(self, username="alice"): + with _patch_emulator(self.state, self.emulator): + return self.state.authenticate_with_card(username) + + def _authenticate_refusing(self, username="alice"): + with _patch_emulator(self.state, self.emulator.refusing()): + return self.state.authenticate_with_card(username) + + def test_register_produces_credential_data(self): + enrollment = self._register("alice", "Alice") + self.assertIsNotNone(enrollment.credential_data_b64) + self.assertEqual(enrollment.username, "alice") + + def test_register_persists_to_disk(self): + self._register("alice") + state2 = _make_direct_state(self.tmp_path) + self.assertTrue(state2.has_enrollment("alice")) + self.assertIsNotNone(state2.get_enrollment("alice").credential_data_b64) + + def test_authenticate_after_register_succeeds(self): + self._register("alice") + ok, msg = self._authenticate("alice") + self.assertTrue(ok) + self.assertEqual(msg, "assertion verified") + + def test_authenticate_user_says_no_fails(self): + self._register("alice") + ok, msg = self._authenticate_refusing("alice") + self.assertFalse(ok) + self.assertIn("assertion verification failed", msg) + + def test_register_user_says_no_fails(self): + with _patch_emulator(self.state, self.emulator.refusing()): + with self.assertRaises(RuntimeError) as ctx: + self.state.register_enrollment("alice", None) + self.assertIn("card registration failed", str(ctx.exception)) + + def test_authenticate_after_forget_fails(self): + self._register("alice") + self.emulator.forget_user("alice") + ok, msg = self._authenticate("alice") + self.assertFalse(ok) + + def test_two_users_independent(self): + self._register("alice") + self._register("bob") + ok_a, _ = self._authenticate("alice") + ok_b, _ = self._authenticate("bob") + self.assertTrue(ok_a) + self.assertTrue(ok_b) + + def test_forget_one_user_leaves_other_intact(self): + self._register("alice") + self._register("bob") + self.emulator.forget_user("alice") + ok_a, _ = self._authenticate("alice") + ok_b, _ = self._authenticate("bob") + self.assertFalse(ok_a) + self.assertTrue(ok_b) + + def test_sign_count_increases_across_logins(self): + import struct + from k_proxy_app import AttestedCredentialData, b64u_decode + self._register("alice") + enrollment = self.state.get_enrollment("alice") + cred_data = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64)) + cred_id = cred_data.credential_id + + sign_counts = [] + for _ in range(3): + assertion = self.emulator.get_assertion( + rp_id=self.state.rp_id, + client_data_hash=b"\xAB" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + sign_counts.append(struct.unpack(">I", bytes(assertion.auth_data)[33:37])[0]) + + self.assertLess(sign_counts[0], sign_counts[1]) + self.assertLess(sign_counts[1], sign_counts[2]) + + if __name__ == "__main__": unittest.main(verbosity=2)