Compare commits
No commits in common. "35c40985dd5027c3c2b3e954b01b365cc73f07de" and "23c37f4590ea2c37471dfbc501bc99e4a3888304" have entirely different histories.
35c40985dd
...
23c37f4590
20
Setup.md
20
Setup.md
|
|
@ -648,26 +648,6 @@ 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
|
||||
- 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
|
||||
|
||||
- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.
|
||||
|
|
|
|||
|
|
@ -553,15 +553,8 @@ Status (2026-04-27):
|
|||
- 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.
|
||||
- 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.
|
||||
- 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 6.5 (concurrency): deferred. Ceiling (~10 in-flight) is acceptable until multi-card use cases arrive.
|
||||
|
|
|
|||
|
|
@ -757,7 +757,7 @@ class ProxyState:
|
|||
auth_data = self.fido_server.register_complete(
|
||||
state,
|
||||
RegistrationResponse(
|
||||
raw_id=attestation.auth_data.credential_data.credential_id,
|
||||
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(
|
||||
raw_id=response.credential["id"],
|
||||
id=response.credential["id"],
|
||||
response=AuthenticatorAssertionResponse(
|
||||
client_data=client_data,
|
||||
authenticator_data=response.auth_data,
|
||||
|
|
|
|||
|
|
@ -1,339 +0,0 @@
|
|||
"""
|
||||
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)
|
||||
|
|
@ -18,7 +18,6 @@ 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 (
|
||||
|
|
@ -801,248 +800,5 @@ 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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue