Harden direct auth path and regression helper
This commit is contained in:
parent
2448956946
commit
689587629a
|
|
@ -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`
|
||||
|
|
|
|||
40
Setup.md
40
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).
|
||||
|
|
|
|||
26
Workplan.md
26
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`.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
207
k_proxy_app.py
207
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 = """<!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)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue