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
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -553,15 +553,8 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -757,7 +757,7 @@ class ProxyState:
|
||||||
auth_data = self.fido_server.register_complete(
|
auth_data = self.fido_server.register_complete(
|
||||||
state,
|
state,
|
||||||
RegistrationResponse(
|
RegistrationResponse(
|
||||||
raw_id=attestation.auth_data.credential_data.credential_id,
|
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(
|
||||||
raw_id=response.credential["id"],
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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 (
|
||||||
|
|
@ -801,248 +800,5 @@ 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue