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` 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`

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
- `/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).

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
- 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`.

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.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 = """<!doctype 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)

View File

@ -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 <<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 \
-F "${SSH_CONFIG}" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o ConnectTimeout="${CONNECT_TIMEOUT}" \
@ -81,6 +114,8 @@ ssh \
USERNAME="${USERNAME}" \
REQUESTS="${REQUESTS}" \
PARALLELISM="${PARALLELISM}" \
LOGIN_TIMEOUT="${LOGIN_TIMEOUT}" \
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE}" \
python3 - <<'PY'
import concurrent.futures
import json
@ -95,6 +130,8 @@ proxy_url = os.environ["PROXY_URL"].rstrip("/")
username = os.environ["USERNAME"]
requests = int(os.environ["REQUESTS"])
parallelism = int(os.environ["PARALLELISM"])
login_timeout = int(os.environ["LOGIN_TIMEOUT"])
expect_auth_mode = os.environ["EXPECT_AUTH_MODE"]
if requests < 1:
raise SystemExit("REQUESTS must be >= 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 = []

View File

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