From 689587629a4a2687c44dd18801c5023cfa7e51b5 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 15:49:52 +0200 Subject: [PATCH] Harden direct auth path and regression helper --- PHASE5_RUNBOOK.md | 16 ++- Setup.md | 40 ++++++- Workplan.md | 26 +++-- ctaphid_init_probe.py | 74 +++++++++++++ k_proxy_app.py | 207 ++++++++++++++++++++++++++++--------- phase5_chain_regression.sh | 56 +++++++++- raw_ctap_probe.py | 28 +++-- 7 files changed, 373 insertions(+), 74 deletions(-) create mode 100644 ctaphid_init_probe.py diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index ed59da4..f61cc51 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -203,4 +203,18 @@ Verified result on 2026-04-25: - `/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 - 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` diff --git a/Setup.md b/Setup.md index c1d4175..c0b39a8 100644 --- a/Setup.md +++ b/Setup.md @@ -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 - `/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 + - 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: - the experimental direct mode is kept in code for follow-up work - 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` - `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: - - determine which hidraw interface `python-fido2` is selecting on `k_proxy` - - determine whether the blocked path is on the second HID interface or in the Qubes USB mediation layer + - determine why CTAPHID `INIT` on the correct FIDO hidraw node receives no reply after reattach + - 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`. - Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds). diff --git a/Workplan.md b/Workplan.md index 3f6a73f..eb7ea69 100644 --- a/Workplan.md +++ b/Workplan.md @@ -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 - 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 + - 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` @@ -293,14 +299,18 @@ Status (2026-04-25): - Browser traffic goes only to `k_proxy`. Immediate next action: -- Determine which hidraw interface the host CTAP stack is actually selecting on `k_proxy`. -- Verify which interface is blocked: - - map `/dev/hidraw0` and `/dev/hidraw1` to their USB/HID descriptors - - determine whether `python-fido2` is trying to use the permission-blocked interface -- Then retry: - - `ssh k_proxy "python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost"` -- Stop before the raw probe and tell the user explicitly to press `yes` or `no` on the card. -- Validate end-to-end login to `k_server` resource through proxy chain. +Immediate next action: +- Preserve the now-working direct auth path and record it as the current baseline. +- Verified end-to-end state: + - direct `/enroll/register` succeeds for `directtest` + - direct `/session/login` succeeds for `directtest` + - `/session/status` succeeds + - protected `/resource/counter` succeeds through `k_proxy -> k_server` + - `/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: - Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. diff --git a/ctaphid_init_probe.py b/ctaphid_init_probe.py new file mode 100644 index 0000000..69a8cf9 --- /dev/null +++ b/ctaphid_init_probe.py @@ -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()) diff --git a/k_proxy_app.py b/k_proxy_app.py index 907bce4..346c82e 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -34,23 +34,29 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen +import fido2.features from fido2.client import Fido2Client, UserInteraction, verify_rp_id +from fido2.ctap2 import Ctap2 from fido2.hid import CtapHidDevice +from fido2.hid.linux import get_descriptor, open_connection from fido2.server import Fido2Server from fido2.webauthn import ( AttestedCredentialData, + AttestationObject, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + AuthenticationResponse, + CollectedClientData, PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, + RegistrationResponse, UserVerificationRequirement, ) -try: - from fido2.client import ClientDataCollector, CollectedClientData -except ImportError: - ClientDataCollector = None - CollectedClientData = None +if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None: + fido2.features.webauthn_json_mapping.enabled = True HTML = """ @@ -420,6 +426,46 @@ def b64u_decode(data: str) -> bytes: 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]: payload: dict[str, Any] = { "ok": True, @@ -434,41 +480,6 @@ def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) 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): def prompt_up(self) -> None: print("Touch the ChromeCard to continue...", flush=True) @@ -493,6 +504,7 @@ class ProxyState: rp_id: str, rp_name: str, origin: str, + direct_device_path: str, ): self.session_ttl_s = session_ttl_s self.auth_mode = auth_mode @@ -503,14 +515,15 @@ class ProxyState: self.enrollment_db = enrollment_db self.rp_id = rp_id self.origin = origin + self.direct_device_path = direct_device_path self.lock = threading.Lock() + self.direct_device_lock = threading.RLock() + self.direct_device: CtapHidDevice | None = None self.sessions: dict[str, Session] = {} self.enrollments: dict[str, Enrollment] = {} self.rp = PublicKeyCredentialRpEntity(id=rp_id, name=rp_name) self.fido_server = Fido2Server(self.rp) - self.client_data_collector = ( - ProxyClientDataCollector(origin=origin, rp_id=rp_id) if ProxyClientDataCollector else None - ) + self.client_data_collector = None self.upstream = UpstreamPool( server_base_url=self.server_base_url, 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") def _new_fido_client(self) -> Fido2Client: - try: - device = next(CtapHidDevice.list_devices()) - except StopIteration as exc: - raise RuntimeError("no CTAP HID devices found") from exc + device = self._get_direct_device() # Newer python-fido2 builds accept a custom client-data collector, while the # VM-side package still expects an origin string plus verifier callback. if self.client_data_collector is not None: return Fido2Client(device, self.client_data_collector, 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: return PublicKeyCredentialUserEntity( id=user_id, @@ -646,9 +704,30 @@ class ProxyState: user_verification=UserVerificationRequirement.DISCOURAGED, ) 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( 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: raise RuntimeError(f"card registration failed: {exc}") from exc @@ -750,9 +829,29 @@ class ProxyState: [credential], user_verification=UserVerificationRequirement.DISCOURAGED, ) - selection = self._new_fido_client().get_assertion(options.public_key) - assertion = selection.get_response(0) - self.fido_server.authenticate_complete(state, [credential], assertion) + client_data = self._collect_client_data("webauthn.get", options.public_key) + assertion = self._with_direct_ctap2( + 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: return False, f"assertion verification failed: {exc}" return True, "assertion verified" @@ -1153,6 +1252,11 @@ def parse_args() -> argparse.Namespace: default="/home/user/chromecard/k_proxy_enrollments.json", 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() @@ -1175,6 +1279,7 @@ def main() -> int: rp_id=args.rp_id, rp_name=args.rp_name, origin=args.origin, + direct_device_path=args.direct_device_path, ) Handler.state = state server = ThreadingHTTPServer((args.host, args.port), Handler) diff --git a/phase5_chain_regression.sh b/phase5_chain_regression.sh index dc8dbd5..7203731 100755 --- a/phase5_chain_regression.sh +++ b/phase5_chain_regression.sh @@ -8,6 +8,10 @@ USERNAME="${USERNAME:-alice}" REQUESTS="${REQUESTS:-20}" PARALLELISM="${PARALLELISM:-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() { cat <<'EOF' @@ -24,6 +28,10 @@ Options: --requests N Number of counter requests to issue --parallelism N Number of concurrent workers --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 EOF } @@ -58,6 +66,22 @@ while [[ $# -gt 0 ]]; do CONNECT_TIMEOUT="$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) usage exit 0 @@ -70,7 +94,16 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${INTERACTIVE_CARD}" == "1" ]]; then + cat <= 1") @@ -103,7 +140,7 @@ if parallelism < 1: 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") headers = {} if payload is not None: @@ -117,7 +154,7 @@ def post_json(path: str, payload: dict | None = None, token: str | None = None): method="POST", ) 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")) except urllib.error.HTTPError as exc: 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} 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: print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login})) 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"] values = [] diff --git a/raw_ctap_probe.py b/raw_ctap_probe.py index 951a29f..8c35935 100644 --- a/raw_ctap_probe.py +++ b/raw_ctap_probe.py @@ -22,6 +22,7 @@ try: from fido2.ctap import CtapError from fido2.ctap2 import Ctap2 from fido2.hid import CtapHidDevice + from fido2.hid.linux import get_descriptor, open_connection except Exception as exc: print("Missing dependency: python-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) +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: 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: 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( + "--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.add_parser("list", help="List CTAP HID devices") @@ -277,8 +294,8 @@ def main() -> int: parser = build_parser() args = parser.parse_args() - devs = list_devices() if args.command == "list": + devs = list_devices() print_json( { "devices": [describe_device(dev) for dev in devs], @@ -286,14 +303,7 @@ def main() -> int: ) return 0 if devs else 1 - if not devs: - 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] + dev = get_device(args.index, args.device_path) device_meta = describe_device(dev) ctap2 = get_ctap2(dev)