340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""
|
|
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)
|