""" 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)