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` 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`
|
||||||
|
|
|
||||||
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
|
- 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).
|
||||||
|
|
|
||||||
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
|
- `/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`.
|
||||||
|
|
|
||||||
|
|
@ -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.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)
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue