Compare commits

..

2 Commits

Author SHA1 Message Date
Morten V. Christiansen 35c40985dd Update Setup.md and Workplan.md for 2026-04-27 emulator session
Records CardEmulator addition, the two fido2-direct id=/raw_id= bug fixes,
and the expanded test count (100 → 122). Marks project status unchanged:
Phases 7 and 9 remain externally gated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:32:09 +02:00
Morten V. Christiansen 56132528fe 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 <noreply@anthropic.com>
2026-04-27 16:31:07 +02:00
5 changed files with 613 additions and 3 deletions

View File

@ -648,6 +648,26 @@ Session note (2026-04-26, markdown maintenance re-scan):
- direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime - direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime
- the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up - the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up
Session note (2026-04-27, card emulator and bug fixes):
- Added software emulator of the ChromeCard FIDO2 authenticator:
- `/home/user/chromecard/tests/card_emulator.py`
- implements `make_credential` and `get_assertion` with real P-256 cryptography
- in-memory credential store keyed by credential ID (matching firmware layout)
- auth_data byte layout and COSE key encoding mirror `fido_make_cred.c` / `fido_get_assertion.c` exactly
- `user_confirms=True/False` parameter simulates the card's Yes/No confirmation dialog
- `refusing()` method returns a wrapper that forces `user_confirms=False` for integration test paths
- `forget_user(username)` simulates card-side credential removal
- module docstring is the usage guide
- Fixed two bugs in `k_proxy_app.py` that were silently breaking fido2-direct mode:
- `RegistrationResponse(id=..., ...)``RegistrationResponse(raw_id=..., ...)` (fido2 2.2.0 API)
- `AuthenticationResponse(id=..., ...)``AuthenticationResponse(raw_id=..., ...)` (same)
- both calls raised `TypeError` at runtime, caught by the surrounding `except`, so register and
authenticate in fido2-direct mode always returned failure without any visible error
- Extended test suite: 22 new tests across `TestCardEmulatorUnit` and `TestCardEmulatorIntegration`
- covers: register, authenticate, user-says-no (register and auth), forget, two-user isolation,
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
- total test count is now 122, all passing locally without card or VMs
## Known FIDO2 Transport Boundary ## Known FIDO2 Transport Boundary
- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT. - FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.

View File

@ -553,8 +553,15 @@ Status (2026-04-27):
- fido2-direct mode confirmed working end-to-end with real card via browser on k_client. - fido2-direct mode confirmed working end-to-end with real card via browser on k_client.
- Full register → login → counter → logout flow verified with physical card button presses. - Full register → login → counter → logout flow verified with physical card button presses.
- Bug fixed: ClientState.enroll() now calls /session/logout on k_proxy before re-enrolling. - Bug fixed: ClientState.enroll() now calls /session/logout on k_proxy before re-enrolling.
- 100-test unit suite added for k_proxy (tests/test_k_proxy.py); runs locally without card or VMs.
- All three service files refactored and re-deployed. - All three service files refactored and re-deployed.
- Added CardEmulator: software emulator of the ChromeCard FIDO2 authenticator for use in tests.
- real P-256 crypto; auth_data layout mirrors firmware exactly
- user_confirms=True/False simulates card Yes/No; refusing() wrapper for integration test paths
- forget_user() simulates card-side key removal
- module docstring in tests/card_emulator.py is the usage guide
- Fixed two silent fido2-direct bugs: RegistrationResponse and AuthenticationResponse were both
constructed with id= instead of raw_id=; all direct-mode register/authenticate calls were failing.
- Test suite now at 122 tests (was 100), all passing locally without card or VMs.
Phase status (2026-04-27): Phase status (2026-04-27):
- Phase 6.5 (concurrency): deferred. Ceiling (~10 in-flight) is acceptable until multi-card use cases arrive. - Phase 6.5 (concurrency): deferred. Ceiling (~10 in-flight) is acceptable until multi-card use cases arrive.

View File

@ -757,7 +757,7 @@ class ProxyState:
auth_data = self.fido_server.register_complete( auth_data = self.fido_server.register_complete(
state, state,
RegistrationResponse( RegistrationResponse(
id=attestation.auth_data.credential_data.credential_id, raw_id=attestation.auth_data.credential_data.credential_id,
response=AuthenticatorAttestationResponse( response=AuthenticatorAttestationResponse(
client_data=client_data, client_data=client_data,
attestation_object=AttestationObject.create( attestation_object=AttestationObject.create(
@ -888,7 +888,7 @@ class ProxyState:
state, state,
[credential], [credential],
AuthenticationResponse( AuthenticationResponse(
id=response.credential["id"], raw_id=response.credential["id"],
response=AuthenticatorAssertionResponse( response=AuthenticatorAssertionResponse(
client_data=client_data, client_data=client_data,
authenticator_data=response.auth_data, authenticator_data=response.auth_data,

339
tests/card_emulator.py Normal file
View File

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

View File

@ -18,6 +18,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
import k_proxy_app as app import k_proxy_app as app
from k_proxy_app import ( from k_proxy_app import (
@ -800,5 +801,248 @@ class TestHandlerResource(ServerFixture):
self.assertEqual(status, 200) 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__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)