Harden direct auth path and regression helper

This commit is contained in:
Morten V. Christiansen 2026-04-25 15:49:52 +02:00
parent 2448956946
commit 689587629a
7 changed files with 373 additions and 74 deletions

View File

@ -203,4 +203,18 @@ Verified result on 2026-04-25:
- `/dev/hidraw0` and `/dev/hidraw1` are visible in `k_proxy` again - `/dev/hidraw0` and `/dev/hidraw1` are visible in `k_proxy` again
- `/dev/hidraw0` opens successfully as the normal user, but `/dev/hidraw1` is still permission-denied - `/dev/hidraw0` opens successfully as the normal user, but `/dev/hidraw1` is still permission-denied
- raw `makeCredential` still shows no card prompt, so the hang is before the firmware confirmation UI - raw `makeCredential` still shows no card prompt, so the hang is before the firmware confirmation UI
- next step is to identify which hidraw interface `python-fido2` is selecting - hidraw inspection confirms `/dev/hidraw0` is the real FIDO interface and `/dev/hidraw1` is a separate vendor HID interface
- manual CTAPHID `INIT` written directly to `/dev/hidraw0` gets no reply at all within `3s`
- rerunning `webauthn_local_demo.py` inside `k_proxy` also shows no card prompt on register
- next step is to recover the USB/Qubes transport path before retrying direct auth
- after a full power cycle and reattach, manual CTAPHID `INIT` replies again and `webauthn_local_demo.py` registration succeeds again
- direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeds again after pressing `yes` on the card
- `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and explicit `/dev/hidraw0`
- after additional fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, `/enroll/register` now succeeds again for `directtest`
- `/session/login` for `directtest` now also succeeds after card confirmation and returns `auth_mode: "fido2_assertion"`
- `/session/status` succeeds
- protected `/resource/counter` succeeds again through `k_proxy -> k_server`
- `/session/logout` succeeds
- post-logout protected access returns `401`
- temporary direct-mode hidraw lifetime logging was removed again after diagnosis
- `phase5_chain_regression.sh` now supports card-interactive direct auth via `--interactive-card --expect-auth-mode fido2_assertion`

View File

@ -544,6 +544,42 @@ Session note (2026-04-25, direct FIDO2 auth attempt):
- direct node-open check confirms `/dev/hidraw0` is readable as the normal user - direct node-open check confirms `/dev/hidraw0` is readable as the normal user
- `/dev/hidraw1` still returns `PermissionError: [Errno 13] Permission denied` - `/dev/hidraw1` still returns `PermissionError: [Errno 13] Permission denied`
- raw `makeCredential` probe still produced no on-card registration prompt, so the host path is hanging before the firmware Yes/No UI - raw `makeCredential` probe still produced no on-card registration prompt, so the host path is hanging before the firmware Yes/No UI
- hidraw mapping confirms `/dev/hidraw0` is the FIDO interface:
- report descriptor begins with usage page `0xF1D0`
- `get_descriptor('/dev/hidraw0')` returns `report_size_in=64`, `report_size_out=64`
- `/dev/hidraw1` is a separate vendor HID interface with usage page `0xFF00`
- stale Python probes holding `/dev/hidraw0` were cleared, but behavior did not change
- a manual CTAPHID `INIT` packet sent directly to `/dev/hidraw0` writes successfully and still gets no response within `3s`
- this places the current blocker below `python-fido2`: raw HID traffic is not getting a CTAPHID reply after the latest reattach
- `webauthn_local_demo.py` was re-run inside `k_proxy` after reattach and still produced no card prompt on register
- that confirms the current failure is below both the browser WebAuthn path and the direct `python-fido2` path
- after a full power cycle and reattach, manual CTAPHID `INIT` on `/dev/hidraw0` started replying again
- `webauthn_local_demo.py` register in `k_proxy` then succeeded again, confirming the card transport was recovered by the power cycle
- direct host-side registration via `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeded again after pressing `yes` on the card
- returned credential material included:
- `fmt="none"`
- credential id `7986cfcf45663f625eb7fc7b52640d83cf3d0e8a6627eeadaba3126406b1e0b8`
- this confirms the recovered direct path now reaches the real card confirmation UI and completes CTAP2 `makeCredential`
- `k_proxy_app.py --auth-mode fido2-direct` was then patched to:
- use low-level CTAP2 instead of the higher-level `Fido2Client` registration/assertion calls
- open the explicit FIDO node `/dev/hidraw0` instead of scanning devices
- cache the direct device handle instead of reopening it for each operation
- current remaining blocker:
- was narrowed through repeated retries to a mix of hidraw node disappearance, older `python-fido2` response-mapping requirements, and CTAP payload-shape mismatches
- latest verified state:
- after reattach with healthy CTAPHID `INIT`, real app registration through `k_proxy_app.py --auth-mode fido2-direct` now succeeds
- `/enroll/register` for `directtest` returned `ok=true` and `has_credential=true`
- real app login through `/session/login` for `directtest` also now succeeds after card confirmation
- returned `auth_mode` is `fido2_assertion`
- session status succeeds
- protected `/resource/counter` access succeeds again through `k_proxy -> k_server`
- logout succeeds
- post-logout protected access returns `401`
- temporary direct-mode hidraw lifetime logging has been removed again after diagnosis
- `/home/user/chromecard/phase5_chain_regression.sh` now supports the direct-auth baseline via:
- `--interactive-card`
- `--login-timeout`
- `--expect-auth-mode fido2_assertion`
- Practical outcome for this session: - Practical outcome for this session:
- the experimental direct mode is kept in code for follow-up work - the experimental direct mode is kept in code for follow-up work
- the deployed `k_proxy` service was restored to default `probe` mode - the deployed `k_proxy` service was restored to default `probe` mode
@ -616,7 +652,7 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06
- `/dev/hidraw*` must exist in `k_proxy` - `/dev/hidraw*` must exist in `k_proxy`
- `fido2_probe.py --list` must detect the card before the raw Yes/No probe can continue - `fido2_probe.py --list` must detect the card before the raw Yes/No probe can continue
- Identify why the host probe hangs before card UI even with `/dev/hidraw0` readable: - Identify why the host probe hangs before card UI even with `/dev/hidraw0` readable:
- determine which hidraw interface `python-fido2` is selecting on `k_proxy` - determine why CTAPHID `INIT` on the correct FIDO hidraw node receives no reply after reattach
- determine whether the blocked path is on the second HID interface or in the Qubes USB mediation layer - likely recovery targets are the Qubes USB mediation path, a fresh USB reassign, or a `k_proxy` VM/device reset
- Precise ownership split of session/user state between `k_proxy` and `k_server`. - Precise ownership split of session/user state between `k_proxy` and `k_server`.
- Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds). - Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds).

View File

@ -254,6 +254,12 @@ Status (2026-04-25):
- `/home/user/chromecard/raw_ctap_probe.py` now exists for lower-level CTAP2 probing with keepalive/error logging - `/home/user/chromecard/raw_ctap_probe.py` now exists for lower-level CTAP2 probing with keepalive/error logging
- latest retry result: after reattaching the card, `k_proxy` again exposes `/dev/hidraw0` and `/dev/hidraw1`, but raw `makeCredential` still reaches no Yes/No card prompt - latest retry result: after reattaching the card, `k_proxy` again exposes `/dev/hidraw0` and `/dev/hidraw1`, but raw `makeCredential` still reaches no Yes/No card prompt
- `/dev/hidraw0` opens successfully as the normal user; `/dev/hidraw1` is still permission-denied - `/dev/hidraw0` opens successfully as the normal user; `/dev/hidraw1` is still permission-denied
- manual CTAPHID testing now shows `/dev/hidraw0` is the correct FIDO interface and a direct `INIT` write gets no response at all
- rerunning `webauthn_local_demo.py` inside `k_proxy` also still gives no card prompt, so the current break is below both browser WebAuthn and direct host probes
- after a full power cycle and reattach, manual CTAPHID `INIT` replies again and browser registration in `webauthn_local_demo.py` succeeds again
- direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` now also succeeds again after card confirmation
- `k_proxy_app.py --auth-mode fido2-direct` has been moved onto low-level CTAP2 and explicit `/dev/hidraw0`
- after repeated fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, real app registration now succeeds for `directtest`
## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server` ## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server`
@ -293,14 +299,18 @@ Status (2026-04-25):
- Browser traffic goes only to `k_proxy`. - Browser traffic goes only to `k_proxy`.
Immediate next action: Immediate next action:
- Determine which hidraw interface the host CTAP stack is actually selecting on `k_proxy`. Immediate next action:
- Verify which interface is blocked: - Preserve the now-working direct auth path and record it as the current baseline.
- map `/dev/hidraw0` and `/dev/hidraw1` to their USB/HID descriptors - Verified end-to-end state:
- determine whether `python-fido2` is trying to use the permission-blocked interface - direct `/enroll/register` succeeds for `directtest`
- Then retry: - direct `/session/login` succeeds for `directtest`
- `ssh k_proxy "python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost"` - `/session/status` succeeds
- Stop before the raw probe and tell the user explicitly to press `yes` or `no` on the card. - protected `/resource/counter` succeeds through `k_proxy -> k_server`
- Validate end-to-end login to `k_server` resource through proxy chain. - `/session/logout` succeeds
- post-logout protected access returns `401`
- Next work should be cleanup/hardening:
- decide whether to keep `directtest` enrollment
- rerun `phase5_chain_regression.sh --interactive-card --expect-auth-mode fido2_assertion` against the current direct-auth baseline
Exit criteria: Exit criteria:
- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. - Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`.

74
ctaphid_init_probe.py Normal file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Manual CTAPHID INIT probe for a specific hidraw node.
This bypasses python-fido2's device bootstrap so we can see whether the raw HID
transport itself exchanges packets on the expected FIDO interface.
"""
from __future__ import annotations
import argparse
import os
import secrets
import select
import struct
import sys
from pathlib import Path
CTAPHID_INIT = 0x06
TYPE_INIT = 0x80
BROADCAST_CID = 0xFFFFFFFF
def build_init_packet(nonce: bytes) -> bytes:
frame = struct.pack(">IBH", BROADCAST_CID, TYPE_INIT | CTAPHID_INIT, len(nonce)) + nonce
return b"\0" + frame.ljust(64, b"\0")
def main() -> int:
parser = argparse.ArgumentParser(description="Manual CTAPHID INIT probe")
parser.add_argument("--device-path", default="/dev/hidraw0")
parser.add_argument("--timeout", type=float, default=3.0)
args = parser.parse_args()
path = Path(args.device_path)
if not path.exists():
print(f"missing device: {path}", file=sys.stderr)
return 2
nonce = secrets.token_bytes(8)
packet = build_init_packet(nonce)
print(f"device={path}")
print(f"nonce={nonce.hex()}")
print(f"write_len={len(packet)}")
print(f"write_hex={packet.hex()}")
fd = os.open(str(path), os.O_RDWR)
try:
written = os.write(fd, packet)
print(f"written={written}")
poller = select.poll()
poller.register(fd, select.POLLIN)
events = poller.poll(int(args.timeout * 1000))
print(f"events={events}")
if not events:
print("timeout_waiting_for_response")
return 1
response = os.read(fd, 64)
print(f"read_len={len(response)}")
print(f"read_hex={response.hex()}")
if len(response) >= 24:
cid, cmd, bc = struct.unpack(">IBH", response[:7])
print(f"resp_cid=0x{cid:08x}")
print(f"resp_cmd=0x{cmd:02x}")
print(f"resp_bc={bc}")
print(f"resp_payload={response[7:7+bc].hex()}")
return 0
finally:
os.close(fd)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -34,23 +34,29 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
import fido2.features
from fido2.client import Fido2Client, UserInteraction, verify_rp_id from fido2.client import Fido2Client, UserInteraction, verify_rp_id
from fido2.ctap2 import Ctap2
from fido2.hid import CtapHidDevice from fido2.hid import CtapHidDevice
from fido2.hid.linux import get_descriptor, open_connection
from fido2.server import Fido2Server from fido2.server import Fido2Server
from fido2.webauthn import ( from fido2.webauthn import (
AttestedCredentialData, AttestedCredentialData,
AttestationObject,
AuthenticatorAssertionResponse,
AuthenticatorAttestationResponse,
AuthenticationResponse,
CollectedClientData,
PublicKeyCredentialCreationOptions, PublicKeyCredentialCreationOptions,
PublicKeyCredentialRequestOptions, PublicKeyCredentialRequestOptions,
PublicKeyCredentialRpEntity, PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity, PublicKeyCredentialUserEntity,
RegistrationResponse,
UserVerificationRequirement, UserVerificationRequirement,
) )
try: if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None:
from fido2.client import ClientDataCollector, CollectedClientData fido2.features.webauthn_json_mapping.enabled = True
except ImportError:
ClientDataCollector = None
CollectedClientData = None
HTML = """<!doctype html> HTML = """<!doctype html>
@ -420,6 +426,46 @@ def b64u_decode(data: str) -> bytes:
return base64.urlsafe_b64decode((data + pad).encode("ascii")) return base64.urlsafe_b64decode((data + pad).encode("ascii"))
def direct_ctap_key_params() -> list[dict[str, Any]]:
# Match the raw probe's narrower algorithm set. The broader default list from
# Fido2Server.register_begin was still hitting post-confirmation I/O errors.
return [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257},
]
def direct_ctap_rp(rp: PublicKeyCredentialRpEntity) -> dict[str, Any]:
return {"id": rp.id, "name": rp.name}
def direct_ctap_user(user: PublicKeyCredentialUserEntity) -> dict[str, Any]:
user_id = user.id
if isinstance(user_id, bytes):
# Match the raw probe's ASCII user-id shape rather than sending opaque
# binary bytes into the card path.
user_id = user_id.hex().encode("ascii")
return {
"id": user_id,
"name": user.name,
"displayName": user.display_name or user.name,
}
def direct_ctap_allow_list(
creds: list[Any] | None,
) -> list[dict[str, Any]] | None:
if not creds:
return None
out: list[dict[str, Any]] = []
for cred in creds:
cred_id = getattr(cred, "id", None)
if cred_id is None and isinstance(cred, dict):
cred_id = cred.get("id")
out.append({"type": "public-key", "id": cred_id})
return out
def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) -> dict[str, Any]: def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) -> dict[str, Any]:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"ok": True, "ok": True,
@ -434,41 +480,6 @@ def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None)
return payload return payload
if ClientDataCollector is not None and CollectedClientData is not None:
class ProxyClientDataCollector(ClientDataCollector):
def __init__(self, origin: str, rp_id: str):
if not verify_rp_id(rp_id, origin):
raise ValueError(f"origin {origin!r} is not valid for rp_id {rp_id!r}")
self.origin = origin
self.rp_id = rp_id
def collect_client_data(
self,
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
) -> tuple[CollectedClientData, str]:
if isinstance(options, PublicKeyCredentialCreationOptions):
request_type = "webauthn.create"
requested_rp_id = options.rp.id
challenge = options.challenge
elif isinstance(options, PublicKeyCredentialRequestOptions):
request_type = "webauthn.get"
requested_rp_id = options.rp_id
challenge = options.challenge
else:
raise TypeError(f"unsupported options type: {type(options)!r}")
if requested_rp_id != self.rp_id:
raise ValueError(f"rp_id mismatch: expected {self.rp_id}, got {requested_rp_id}")
return CollectedClientData.create(
type=request_type,
challenge=challenge,
origin=self.origin,
), self.rp_id
else:
ProxyClientDataCollector = None
class ProxyUserInteraction(UserInteraction): class ProxyUserInteraction(UserInteraction):
def prompt_up(self) -> None: def prompt_up(self) -> None:
print("Touch the ChromeCard to continue...", flush=True) print("Touch the ChromeCard to continue...", flush=True)
@ -493,6 +504,7 @@ class ProxyState:
rp_id: str, rp_id: str,
rp_name: str, rp_name: str,
origin: str, origin: str,
direct_device_path: str,
): ):
self.session_ttl_s = session_ttl_s self.session_ttl_s = session_ttl_s
self.auth_mode = auth_mode self.auth_mode = auth_mode
@ -503,14 +515,15 @@ class ProxyState:
self.enrollment_db = enrollment_db self.enrollment_db = enrollment_db
self.rp_id = rp_id self.rp_id = rp_id
self.origin = origin self.origin = origin
self.direct_device_path = direct_device_path
self.lock = threading.Lock() self.lock = threading.Lock()
self.direct_device_lock = threading.RLock()
self.direct_device: CtapHidDevice | None = None
self.sessions: dict[str, Session] = {} self.sessions: dict[str, Session] = {}
self.enrollments: dict[str, Enrollment] = {} self.enrollments: dict[str, Enrollment] = {}
self.rp = PublicKeyCredentialRpEntity(id=rp_id, name=rp_name) self.rp = PublicKeyCredentialRpEntity(id=rp_id, name=rp_name)
self.fido_server = Fido2Server(self.rp) self.fido_server = Fido2Server(self.rp)
self.client_data_collector = ( self.client_data_collector = None
ProxyClientDataCollector(origin=origin, rp_id=rp_id) if ProxyClientDataCollector else None
)
self.upstream = UpstreamPool( self.upstream = UpstreamPool(
server_base_url=self.server_base_url, server_base_url=self.server_base_url,
server_ca_file=self.server_ca_file, server_ca_file=self.server_ca_file,
@ -595,16 +608,61 @@ class ProxyState:
self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n") self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n")
def _new_fido_client(self) -> Fido2Client: def _new_fido_client(self) -> Fido2Client:
try: device = self._get_direct_device()
device = next(CtapHidDevice.list_devices())
except StopIteration as exc:
raise RuntimeError("no CTAP HID devices found") from exc
# Newer python-fido2 builds accept a custom client-data collector, while the # Newer python-fido2 builds accept a custom client-data collector, while the
# VM-side package still expects an origin string plus verifier callback. # VM-side package still expects an origin string plus verifier callback.
if self.client_data_collector is not None: if self.client_data_collector is not None:
return Fido2Client(device, self.client_data_collector, ProxyUserInteraction()) return Fido2Client(device, self.client_data_collector, ProxyUserInteraction())
return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction()) return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction())
def _open_direct_device(self) -> CtapHidDevice:
descriptor = get_descriptor(self.direct_device_path)
return CtapHidDevice(descriptor, open_connection(descriptor))
def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice:
with self.direct_device_lock:
if force_reopen and self.direct_device is not None:
try:
self.direct_device.close()
except Exception:
pass
self.direct_device = None
if self.direct_device is None:
self.direct_device = self._open_direct_device()
return self.direct_device
def _with_direct_ctap2(self, action):
with self.direct_device_lock:
last_exc: Exception | None = None
for reopen in (False, True):
try:
device = self._get_direct_device(force_reopen=reopen)
return action(Ctap2(device))
except Exception as exc:
last_exc = exc
try:
if self.direct_device is not None:
self.direct_device.close()
except Exception:
pass
self.direct_device = None
assert last_exc is not None
raise last_exc
def _collect_client_data(
self,
request_type: str,
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
) -> CollectedClientData:
requested_rp_id = options.rp.id if isinstance(options, PublicKeyCredentialCreationOptions) else options.rp_id
if requested_rp_id != self.rp_id:
raise RuntimeError(f"rp_id mismatch: expected {self.rp_id}, got {requested_rp_id}")
return CollectedClientData.create(
type=request_type,
challenge=options.challenge,
origin=self.origin,
)
def _user_entity(self, username: str, display_name: str | None, user_id: bytes) -> PublicKeyCredentialUserEntity: def _user_entity(self, username: str, display_name: str | None, user_id: bytes) -> PublicKeyCredentialUserEntity:
return PublicKeyCredentialUserEntity( return PublicKeyCredentialUserEntity(
id=user_id, id=user_id,
@ -646,9 +704,30 @@ class ProxyState:
user_verification=UserVerificationRequirement.DISCOURAGED, user_verification=UserVerificationRequirement.DISCOURAGED,
) )
try: try:
client_data = self._collect_client_data("webauthn.create", options.public_key)
attestation = self._with_direct_ctap2(
lambda ctap2: ctap2.make_credential(
client_data_hash=client_data.hash,
rp=direct_ctap_rp(options.public_key.rp),
user=direct_ctap_user(options.public_key.user),
key_params=direct_ctap_key_params(),
exclude_list=direct_ctap_allow_list(options.public_key.exclude_credentials),
options={"rk": False, "uv": False},
)
)
auth_data = self.fido_server.register_complete( auth_data = self.fido_server.register_complete(
state, state,
self._new_fido_client().make_credential(options.public_key), RegistrationResponse(
id=attestation.auth_data.credential_data.credential_id,
response=AuthenticatorAttestationResponse(
client_data=client_data,
attestation_object=AttestationObject.create(
attestation.fmt,
attestation.auth_data,
attestation.att_stmt,
),
),
),
) )
except Exception as exc: except Exception as exc:
raise RuntimeError(f"card registration failed: {exc}") from exc raise RuntimeError(f"card registration failed: {exc}") from exc
@ -750,9 +829,29 @@ class ProxyState:
[credential], [credential],
user_verification=UserVerificationRequirement.DISCOURAGED, user_verification=UserVerificationRequirement.DISCOURAGED,
) )
selection = self._new_fido_client().get_assertion(options.public_key) client_data = self._collect_client_data("webauthn.get", options.public_key)
assertion = selection.get_response(0) assertion = self._with_direct_ctap2(
self.fido_server.authenticate_complete(state, [credential], assertion) lambda ctap2: ctap2.get_assertion(
rp_id=options.public_key.rp_id,
client_data_hash=client_data.hash,
allow_list=direct_ctap_allow_list(options.public_key.allow_credentials),
options={"up": True, "uv": False},
)
)
response = assertion.assertions[0] if getattr(assertion, "assertions", None) else assertion
self.fido_server.authenticate_complete(
state,
[credential],
AuthenticationResponse(
id=response.credential["id"],
response=AuthenticatorAssertionResponse(
client_data=client_data,
authenticator_data=response.auth_data,
signature=response.signature,
user_handle=response.user.get("id") if response.user else None,
),
),
)
except Exception as exc: except Exception as exc:
return False, f"assertion verification failed: {exc}" return False, f"assertion verification failed: {exc}"
return True, "assertion verified" return True, "assertion verified"
@ -1153,6 +1252,11 @@ def parse_args() -> argparse.Namespace:
default="/home/user/chromecard/k_proxy_enrollments.json", default="/home/user/chromecard/k_proxy_enrollments.json",
help="JSON file used to persist enrolled usernames for the prototype", help="JSON file used to persist enrolled usernames for the prototype",
) )
parser.add_argument(
"--direct-device-path",
default="/dev/hidraw0",
help="Explicit hidraw path used for direct FIDO2 mode",
)
return parser.parse_args() return parser.parse_args()
@ -1175,6 +1279,7 @@ def main() -> int:
rp_id=args.rp_id, rp_id=args.rp_id,
rp_name=args.rp_name, rp_name=args.rp_name,
origin=args.origin, origin=args.origin,
direct_device_path=args.direct_device_path,
) )
Handler.state = state Handler.state = state
server = ThreadingHTTPServer((args.host, args.port), Handler) server = ThreadingHTTPServer((args.host, args.port), Handler)

View File

@ -8,6 +8,10 @@ USERNAME="${USERNAME:-alice}"
REQUESTS="${REQUESTS:-20}" REQUESTS="${REQUESTS:-20}"
PARALLELISM="${PARALLELISM:-8}" PARALLELISM="${PARALLELISM:-8}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}" CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}"
LOGIN_TIMEOUT="${LOGIN_TIMEOUT:-90}"
INTERACTIVE_CARD="${INTERACTIVE_CARD:-0}"
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE:-}"
SSH_CONFIG="${SSH_CONFIG:-/home/user/.ssh/config}"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@ -24,6 +28,10 @@ Options:
--requests N Number of counter requests to issue --requests N Number of counter requests to issue
--parallelism N Number of concurrent workers --parallelism N Number of concurrent workers
--connect-timeout SEC SSH connect timeout --connect-timeout SEC SSH connect timeout
--login-timeout SEC Timeout for the interactive login request (default: 90)
--interactive-card Print card-confirmation instructions before login
--expect-auth-mode NAME Require login response auth_mode to match
--ssh-config PATH SSH config file to use (default: /home/user/.ssh/config)
-h, --help Show this help text -h, --help Show this help text
EOF EOF
} }
@ -58,6 +66,22 @@ while [[ $# -gt 0 ]]; do
CONNECT_TIMEOUT="$2" CONNECT_TIMEOUT="$2"
shift 2 shift 2
;; ;;
--login-timeout)
LOGIN_TIMEOUT="$2"
shift 2
;;
--interactive-card)
INTERACTIVE_CARD=1
shift
;;
--expect-auth-mode)
EXPECT_AUTH_MODE="$2"
shift 2
;;
--ssh-config)
SSH_CONFIG="$2"
shift 2
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@ -70,7 +94,16 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
if [[ "${INTERACTIVE_CARD}" == "1" ]]; then
cat <<EOF
Starting interactive login for ${USERNAME}.
When the card shows the authentication prompt, press yes to approve.
Press no only if you want to reject the login.
EOF
fi
ssh \ ssh \
-F "${SSH_CONFIG}" \
-o BatchMode=yes \ -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout="${CONNECT_TIMEOUT}" \ -o ConnectTimeout="${CONNECT_TIMEOUT}" \
@ -81,6 +114,8 @@ ssh \
USERNAME="${USERNAME}" \ USERNAME="${USERNAME}" \
REQUESTS="${REQUESTS}" \ REQUESTS="${REQUESTS}" \
PARALLELISM="${PARALLELISM}" \ PARALLELISM="${PARALLELISM}" \
LOGIN_TIMEOUT="${LOGIN_TIMEOUT}" \
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE}" \
python3 - <<'PY' python3 - <<'PY'
import concurrent.futures import concurrent.futures
import json import json
@ -95,6 +130,8 @@ proxy_url = os.environ["PROXY_URL"].rstrip("/")
username = os.environ["USERNAME"] username = os.environ["USERNAME"]
requests = int(os.environ["REQUESTS"]) requests = int(os.environ["REQUESTS"])
parallelism = int(os.environ["PARALLELISM"]) parallelism = int(os.environ["PARALLELISM"])
login_timeout = int(os.environ["LOGIN_TIMEOUT"])
expect_auth_mode = os.environ["EXPECT_AUTH_MODE"]
if requests < 1: if requests < 1:
raise SystemExit("REQUESTS must be >= 1") raise SystemExit("REQUESTS must be >= 1")
@ -103,7 +140,7 @@ if parallelism < 1:
ctx = ssl.create_default_context(cafile=ca_file) ctx = ssl.create_default_context(cafile=ca_file)
def post_json(path: str, payload: dict | None = None, token: str | None = None): def post_json(path: str, payload: dict | None = None, token: str | None = None, timeout: int = 10):
data = None if payload is None else json.dumps(payload).encode("utf-8") data = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {} headers = {}
if payload is not None: if payload is not None:
@ -117,7 +154,7 @@ def post_json(path: str, payload: dict | None = None, token: str | None = None):
method="POST", method="POST",
) )
try: try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp: with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8")) return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8") body = exc.read().decode("utf-8")
@ -127,10 +164,23 @@ def post_json(path: str, payload: dict | None = None, token: str | None = None):
payload = {"ok": False, "error": body} payload = {"ok": False, "error": body}
return exc.code, payload return exc.code, payload
status, login = post_json("/session/login", {"username": username}) status, login = post_json("/session/login", {"username": username}, timeout=login_timeout)
if status != 200 or "session_token" not in login: if status != 200 or "session_token" not in login:
print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login})) print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login}))
raise SystemExit(1) raise SystemExit(1)
if expect_auth_mode and login.get("auth_mode") != expect_auth_mode:
print(
json.dumps(
{
"ok": False,
"stage": "login",
"error": "unexpected auth_mode",
"expected": expect_auth_mode,
"response": login,
}
)
)
raise SystemExit(1)
token = login["session_token"] token = login["session_token"]
values = [] values = []

View File

@ -22,6 +22,7 @@ try:
from fido2.ctap import CtapError from fido2.ctap import CtapError
from fido2.ctap2 import Ctap2 from fido2.ctap2 import Ctap2
from fido2.hid import CtapHidDevice from fido2.hid import CtapHidDevice
from fido2.hid.linux import get_descriptor, open_connection
except Exception as exc: except Exception as exc:
print("Missing dependency: python-fido2", file=sys.stderr) print("Missing dependency: python-fido2", file=sys.stderr)
print("Install with: python3 -m pip install fido2", file=sys.stderr) print("Install with: python3 -m pip install fido2", file=sys.stderr)
@ -66,6 +67,18 @@ def get_ctap2(dev: CtapHidDevice) -> Ctap2:
return Ctap2(dev) return Ctap2(dev)
def get_device(index: int, device_path: str | None) -> CtapHidDevice:
if device_path:
descriptor = get_descriptor(device_path)
return CtapHidDevice(descriptor, open_connection(descriptor))
devs = list_devices()
if not devs:
raise SystemExit("No CTAP HID devices found.")
if index < 0 or index >= len(devs):
raise SystemExit(f"Invalid --index {index}; found {len(devs)} device(s).")
return devs[index]
def print_json(payload: dict[str, Any]) -> None: def print_json(payload: dict[str, Any]) -> None:
print(json.dumps(payload, indent=2, default=_json_default)) print(json.dumps(payload, indent=2, default=_json_default))
@ -251,6 +264,10 @@ def do_get_assertion(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[s
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Low-level CTAP2 host probe") parser = argparse.ArgumentParser(description="Low-level CTAP2 host probe")
parser.add_argument("--index", type=int, default=0, help="Device index from --list output") parser.add_argument("--index", type=int, default=0, help="Device index from --list output")
parser.add_argument(
"--device-path",
help="Use a specific hidraw node such as /dev/hidraw0 instead of scanning all devices",
)
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list", help="List CTAP HID devices") subparsers.add_parser("list", help="List CTAP HID devices")
@ -277,8 +294,8 @@ def main() -> int:
parser = build_parser() parser = build_parser()
args = parser.parse_args() args = parser.parse_args()
devs = list_devices()
if args.command == "list": if args.command == "list":
devs = list_devices()
print_json( print_json(
{ {
"devices": [describe_device(dev) for dev in devs], "devices": [describe_device(dev) for dev in devs],
@ -286,14 +303,7 @@ def main() -> int:
) )
return 0 if devs else 1 return 0 if devs else 1
if not devs: dev = get_device(args.index, args.device_path)
print("No CTAP HID devices found.", file=sys.stderr)
return 1
if args.index < 0 or args.index >= len(devs):
print(f"Invalid --index {args.index}; found {len(devs)} device(s).", file=sys.stderr)
return 2
dev = devs[args.index]
device_meta = describe_device(dev) device_meta = describe_device(dev)
ctap2 = get_ctap2(dev) ctap2 = get_ctap2(dev)