Add CTAP probe and update phase docs

This commit is contained in:
Morten V. Christiansen 2026-04-25 10:25:40 +02:00
parent d0d27a0896
commit 2448956946
8 changed files with 1507 additions and 68 deletions

View File

@ -150,9 +150,57 @@ curl -i --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0
-H "Authorization: Bearer $TOKEN" -H "Authorization: Bearer $TOKEN"
``` ```
## Regression Script
For the split-VM chain, use the host-side regression helper:
```bash
/home/user/chromecard/phase5_chain_regression.sh
```
Defaults:
- Drives the test from `k_client` over SSH.
- Uses `https://127.0.0.1:9771` and `/home/user/chromecard/tls/phase2/ca.crt` inside `k_client`.
- Logs in as `alice`.
- Runs `20` counter requests at parallelism `8`.
- Verifies that returned counter values are unique and gap-free, then logs out and checks for `401` after logout.
Useful overrides:
```bash
REQUESTS=50 PARALLELISM=12 /home/user/chromecard/phase5_chain_regression.sh
```
```bash
/home/user/chromecard/phase5_chain_regression.sh --username alice --client-host k_client
```
Verified result on 2026-04-25:
- Live split-VM chain passed end-to-end.
- Login, session status, counter reuse, and logout all worked from `k_client`.
- A `20` request / `8` worker concurrency burst returned unique, gap-free counter values `23..42`.
## Current Limitation ## Current Limitation
- This uses card-presence probing, not a full WebAuthn assertion verification path. - This uses card-presence probing, not a full WebAuthn assertion verification path.
- Intended as a Phase 5 starter for session semantics and proxy/server behavior. - Intended as a Phase 5 starter for session semantics and proxy/server behavior.
- Session and counter state are currently process-local only; restart loses state. - Session and counter state are currently process-local only; restart loses state.
- Upstream trust still relies on a shared static `X-Proxy-Token`. - Upstream trust still relies on a shared static `X-Proxy-Token`.
- Experimental direct FIDO2 mode now exists in `k_proxy_app.py` behind `--auth-mode fido2-direct`, but it is not the default runtime:
- direct registration on the current `k_proxy` card/library stack still fails with `No compatible PIN/UV protocols supported!`
- a CTAP1 fallback probe did not complete quickly enough to promote as the working path
- the deployed service was restored to default `probe` mode so the validated Phase 5 chain remains usable
- Raw CTAP debugging helper now exists at `/home/user/chromecard/raw_ctap_probe.py`:
- use it on `k_proxy` to exercise low-level `makeCredential` / `getAssertion`
- it logs keepalive callbacks and transport exceptions
- Current blocker before the next direct-auth attempt:
- `k_proxy` currently has no visible `/dev/hidraw*`
- `python3 /home/user/chromecard/fido2_probe.py --list` in `k_proxy` returns `No CTAP HID devices found.`
- restore card visibility first, then retry the raw CTAP probe and stop to tell the user when to press `yes` or `no`
- Latest retry after card reattach:
- `/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

134
Setup.md
View File

@ -359,6 +359,72 @@ Session note (2026-04-25, browser target moved to k_proxy):
- browser traffic is now intended to go straight to `k_proxy` - browser traffic is now intended to go straight to `k_proxy`
- the `k_client` portal remains only as a temporary bridge/compatibility layer - the `k_client` portal remains only as a temporary bridge/compatibility layer
Session note (2026-04-25, provisional enrollment hardening):
- The enrollment contract in `k_proxy` is now explicit but provisional.
- Current prototype enrollment rules:
- usernames are canonicalized to lowercase
- allowed username pattern is `3-32` chars using lowercase letters, digits, `.`, `_`, `-`
- optional `display_name` is allowed up to `64` chars
- enrollment create is create-only and duplicate create returns `user already enrolled`
- enrollment update is a separate operation
- enrollment delete is a separate operation and removes any active sessions for that username
- Current enrollment endpoints on `k_proxy`:
- `POST /enroll/register`
- `GET /enroll/status?username=<name>`
- `POST /enroll/update`
- `POST /enroll/delete`
- `GET /enroll/list`
- Verified behavior from `k_client` against `https://127.0.0.1:9771`:
- invalid username `A!` is rejected
- create for `dave` with `display_name` succeeds
- duplicate create for `dave` is rejected
- update for `dave` succeeds
- list returns enrolled users and metadata
- delete for `dave` succeeds
- login for deleted `dave` fails with `user not enrolled`
- Deliberate current limit:
- enrollment itself still does not require card presence; only login does
- this was kept lightweight because the enrollment semantics are expected to change later
Session note (2026-04-25, Phase 6.5 concurrency probe):
- Added reproducible concurrency probe:
- `/home/user/chromecard/phase65_concurrency_probe.py`
- probe now supports `--max-workers` so client-side fan-out can be swept explicitly
- Successful baseline run from `k_client` against direct proxy path:
- `3` users
- `4` protected requests per user
- `12/12` requests succeeded
- counter values were unique and contiguous from `6` to `17`
- max observed latency was about `457 ms`
- Larger follow-up run exposed current limit:
- `5` users
- `5` protected requests per user
- `18/25` requests succeeded
- failures returned TLS EOF / upstream unavailable errors
- successful counter values were still unique and contiguous from `18` to `35`
- max observed latency was about `758 ms`
- Additional Phase 6.5 diagnosis:
- fixed a keep-alive/body-drain bug in the HTTP/1.1 experiment so `k_server` no longer misparses follow-on requests as `{}POST`
- added an upstream connection pool in `k_proxy`; current default/test setting clamps `k_proxy -> k_server` to one pooled TLS connection
- despite that change, a full fan-out run with `25` in-flight protected calls still fails on client-observed TLS EOFs
- a worker-limited run now passes cleanly:
- `5` users
- `5` protected requests per user
- `25/25` requests succeeded with `--max-workers 10`
- raising client-side fan-out still breaks:
- `22/25` requests succeeded with `--max-workers 15`
- `15/25` requests succeeded with fully unbounded `25` workers in the latest rerun
- Current diagnosis:
- the protected counter and session logic stay correct under load; successful values remain unique and contiguous
- `k_proxy` and `k_server` can complete the requests that actually reach them
- the primary collapse point in current testing is the client-facing Qubes forwarder on `9771`
- `qvm_connect_9771.log` shows `qrexec-agent-data` / data-vchan failures and repeated `xs_transaction_start: No space left on device`
- `qvm_connect_9780.log` also showed earlier qrexec failures, but the latest worker-threshold evidence points first to connection fan-out on `k_client -> k_proxy`
- Practical meaning:
- the application logic is good for moderate concurrent use in the current prototype
- the direct browser path appears stable around `10` in-flight protected calls in the current Qubes setup
- the current concurrency ceiling is being set by Qubes forwarding behavior rather than by the monotonic counter logic
Session note (2026-04-25, in-VM forwarding test): Session note (2026-04-25, in-VM forwarding test):
- Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`. - Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
- Forwarders start and bind locally: - Forwarders start and bind locally:
@ -428,6 +494,61 @@ Session note (2026-04-25, dom0 policy fix validated):
- `k_client -> k_proxy -> k_server` chain is operational - `k_client -> k_proxy -> k_server` chain is operational
- session reuse and logout behavior are working in the current prototype - session reuse and logout behavior are working in the current prototype
Session note (2026-04-25, live chain re-validation and regression helper):
- Re-validated the split-VM chain after restart using the current TLS/localhost-forward shape:
- `k_client` local `9771` -> `k_proxy:8771`
- `k_proxy` local `9780` -> `k_server:8780`
- Verified live service state during this run:
- `k_server` local `https://127.0.0.1:8780/health` returned `ok=true`
- `k_proxy` local `https://127.0.0.1:8771/health` returned `ok=true`
- `k_proxy` local `https://127.0.0.1:9780/health` reached `k_server`
- `k_client` local `https://127.0.0.1:9771/health` reached `k_proxy`
- Verified end-to-end behavior from `k_client`:
- login for `alice` succeeded
- session status succeeded
- protected counter calls succeeded with session reuse
- logout succeeded
- post-logout protected access returned `401 invalid or expired session`
- Added reproducible regression helper at:
- `/home/user/chromecard/phase5_chain_regression.sh`
- Verified the new helper end-to-end on 2026-04-25:
- default run uses `20` requests at parallelism `8`
- returned values were unique and gap-free
- latest verified counter range from the helper was `43..62`
- Practical meaning:
- the current blocker is no longer Qubes forwarding for the base Phase 5 chain
- the current next-step gap is auth semantics, not transport bring-up
Session note (2026-04-25, direct FIDO2 auth attempt):
- Added an experimental direct FIDO2 path in `/home/user/chromecard/k_proxy_app.py`:
- runtime switch: `--auth-mode fido2-direct`
- default runtime remains `probe`
- Added a low-level CTAP helper at `/home/user/chromecard/raw_ctap_probe.py`:
- purpose: bypass `Fido2Client` and exercise raw CTAP2 `makeCredential` / `getAssertion`
- logs keepalive callbacks and exact transport exceptions for host-side debugging
- Direct-mode intent:
- replace the legacy `fido2_probe.py --json` session gate
- perform real credential registration and real assertion verification locally in `k_proxy` with `python-fido2`
- Current observed blocker on `k_proxy`:
- direct `make_credential` fails with `No compatible PIN/UV protocols supported!`
- reproduces outside the app in a minimal VM-side probe, so this is not just a handler bug
- likely cause is the current card / `python-fido2` stack selecting a PIN/UV-dependent CTAP2 path for registration
- Additional probe:
- a forced CTAP1 fallback experiment did not fail immediately, but also did not complete quickly enough to treat as a usable working path in this turn
- Latest live blocker (2026-04-25, after refactor/deploy):
- direct probing is currently blocked before the card Yes/No UI stage because `k_proxy` no longer sees any CTAP HID device
- `ssh k_proxy "python3 /home/user/chromecard/fido2_probe.py --list"` now returns `No CTAP HID devices found.`
- `ssh k_proxy "ls -l /dev/hidraw*"` shows no `hidraw` nodes at the moment
- Follow-up after card reattach (2026-04-25):
- `k_proxy` again shows `/dev/hidraw0` and `/dev/hidraw1`
- 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
- 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
- verified `alice` login still works afterward, so the validated Phase 5 baseline remains intact
## Known FIDO2 Transport Boundary ## Known FIDO2 Transport Boundary
- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT. - FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.
@ -453,6 +574,9 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06
- `python3 /home/user/chromecard/fido2_probe.py --list` - `python3 /home/user/chromecard/fido2_probe.py --list`
- Then: - Then:
- `python3 /home/user/chromecard/fido2_probe.py --json` - `python3 /home/user/chromecard/fido2_probe.py --json`
- For raw CTAP debugging on `k_proxy`:
- `python3 /home/user/chromecard/raw_ctap_probe.py info`
- `python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost`
4. Run local WebAuthn bring-up demo. 4. Run local WebAuthn bring-up demo.
- `python3 /home/user/chromecard/webauthn_local_demo.py` - `python3 /home/user/chromecard/webauthn_local_demo.py`
@ -483,8 +607,16 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06
- Whether a full `CR_SDK_CK-main` checkout (with role directories) is available locally. - Whether a full `CR_SDK_CK-main` checkout (with role directories) is available locally.
- Whether server-side code should be pulled now for broader CIP/WebAuthn integration testing. - Whether server-side code should be pulled now for broader CIP/WebAuthn integration testing.
- Exact Qubes firewall and service binding rules to enforce the `k_client -> k_proxy -> k_server` chain.
- Exact enrollment process interface running in `k_client` and how it reaches `k_proxy`. - Exact enrollment process interface running in `k_client` and how it reaches `k_proxy`.
- Upgrade Phase 5 auth gate from card-presence probe to full WebAuthn assertion verification for session creation. - Upgrade Phase 5 auth gate from card-presence probe to full WebAuthn assertion verification for session creation.
- Determine the viable path for real credential registration on `k_proxy`:
- enable whatever PIN/UV support the card expects for direct CTAP2 registration, or
- adopt a different one-time enrollment path that can persist real credential material for later direct assertion verification.
- Restore card visibility inside `k_proxy` so direct probes can reach the card UI again:
- `/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
- Precise ownership split of session/user state between `k_proxy` and `k_server`. - Precise ownership split of session/user state between `k_proxy` and `k_server`.
- Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds). - Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds).

View File

@ -42,7 +42,7 @@ This is the execution plan for making ChromeCard FIDO2 development and validatio
Exit criteria: Exit criteria:
- All 3 VMs exist, boot, and have clearly defined service ownership. - All 3 VMs exist, boot, and have clearly defined service ownership.
## Phase 1: Qubes Firewall Policy (Blocking) ## Phase 1: Qubes Firewall Policy
1. Enforce allowed forward paths only. 1. Enforce allowed forward paths only.
- Allow `k_client` outbound TLS only to `k_proxy` service port(s). - Allow `k_client` outbound TLS only to `k_proxy` service port(s).
@ -76,6 +76,16 @@ Status (2026-04-25, after restart and service recovery):
- Immediate next action for Phase 1: - Immediate next action for Phase 1:
- verify and fix the dom0 policy/mechanism that should permit `qubes.ConnectTCP` forwarding for the chain ports - verify and fix the dom0 policy/mechanism that should permit `qubes.ConnectTCP` forwarding for the chain ports
Status (2026-04-25, dom0 policy fix validated):
- The forwarding blocker is cleared for the current prototype shape.
- Verified working chain:
- `k_client` localhost `9771` -> `k_proxy:8771`
- `k_proxy` localhost `9780` -> `k_server:8780`
- Verified outcome:
- TLS health checks pass on both hops
- end-to-end login, session status, protected counter access, and logout all succeed from `k_client`
- Phase 1 is complete for the current localhost-forwarded `qubes.ConnectTCP` design.
## Phase 2: TLS Certificates and Service Endpoints ## Phase 2: TLS Certificates and Service Endpoints
1. Certificate model. 1. Certificate model.
@ -227,7 +237,23 @@ Status (2026-04-25):
- Current split-VM test shape is: - Current split-VM test shape is:
- `k_proxy` listening on `127.0.0.1:8771` - `k_proxy` listening on `127.0.0.1:8771`
- `k_server` listening on `127.0.0.1:8780` - `k_server` listening on `127.0.0.1:8780`
- Phase 5 application logic is runnable locally inside each VM, but end-to-end validation is still blocked by Phase 1 qrexec forwarding refusal. - End-to-end validation is now passing through the live chain from `k_client`.
- Current verified behavior:
- login succeeds for `alice`
- session status succeeds
- repeated protected counter requests succeed with session reuse
- logout succeeds
- post-logout protected access returns `401`
- Added repeatable host-side regression helper:
- `/home/user/chromecard/phase5_chain_regression.sh`
- Phase 5 is complete for the current prototype semantics.
- Experimental follow-up in code:
- `k_proxy_app.py` now also has `--auth-mode fido2-direct`
- this mode attempts direct credential registration and direct assertion verification with `python-fido2`
- it is not the deployed default because direct registration currently fails on `k_proxy` with `No compatible PIN/UV protocols supported!`
- `/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
## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server` ## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server`
@ -245,6 +271,14 @@ Exit criteria:
- Authorized requests obtain consistent increasing values. - Authorized requests obtain consistent increasing values.
- Unauthorized requests are rejected. - Unauthorized requests are rejected.
Status (2026-04-25):
- The protected counter resource is implemented and validated in the live split-VM chain.
- Verified behavior:
- authorized requests from `k_proxy` obtain increasing values
- unauthorized post-logout requests from `k_client` are rejected with `401`
- `20` concurrent protected requests through the chain returned unique, gap-free values
- Phase 5.5 is complete for the current prototype shape.
## Phase 6: Integrate Client Enrollment + Proxy Login Flow ## Phase 6: Integrate Client Enrollment + Proxy Login Flow
1. Enrollment process in `k_client`. 1. Enrollment process in `k_client`.
@ -257,6 +291,15 @@ Exit criteria:
3. Browser flow in `k_client`. 3. Browser flow in `k_client`.
- Browser traffic goes only to `k_proxy`. - 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. - Validate end-to-end login to `k_server` resource through proxy chain.
Exit criteria: Exit criteria:
@ -285,6 +328,61 @@ Status (2026-04-25):
- the `k_client` bridge remains only for transition/compatibility - the `k_client` bridge remains only for transition/compatibility
- final enrollment semantics are still provisional - final enrollment semantics are still provisional
Status (2026-04-25, enrollment hardening):
- Added a more explicit provisional enrollment contract in `k_proxy`:
- username normalization and validation
- optional `display_name`
- separate create, update, delete, status, and list operations
- delete invalidates existing sessions for that username
- Verified the hardened behaviors on the direct proxy path.
- Phase 6 is now strong enough to treat the browser/proxy flow as a stable prototype baseline.
- The remaining reason Phase 6 is not "final" is product semantics, not missing basic mechanics:
- whether enrollment should require card presence
- what user attributes belong in enrollment
- what re-enroll and recovery should mean
Status (2026-04-25, Phase 6.5 initial concurrency results):
- Added reproducible probe script at `/home/user/chromecard/phase65_concurrency_probe.py`.
- Probe now supports `--max-workers` so client-side fan-out can be tested separately from total request count.
- Moderate direct-path concurrency passes:
- `3 users x 4 requests`
- `12/12` successful protected calls
- counter values remained unique and contiguous
- Larger direct-path concurrency currently fails:
- `5 users x 5 requests`
- only `18/25` successful protected calls
- failed calls report TLS EOF / upstream unavailable errors
- Follow-up findings are more precise:
- body-drain handling was fixed for the HTTP/1.1 keep-alive experiment
- `k_proxy -> k_server` upstream concurrency is now clampable and currently tested at one pooled connection
- `5 users x 5 requests` passes at `25/25` when client fan-out is limited to `--max-workers 10`
- the same total load still fails at higher fan-out:
- `22/25` at `--max-workers 15`
- `15/25` at fully unbounded `25` workers in the latest rerun
- Current bottleneck is still not counter correctness:
- successful results still show unique, contiguous counter values
- `k_proxy` and `k_server` complete the requests that actually arrive
- Current likely bottleneck is the client-facing Qubes forwarding layer:
- `qvm_connect_9771.log` shows qrexec data-vchan failures
- observed message includes `xs_transaction_start: No space left on device`
- `qvm_connect_9780.log` showed earlier failures too, but the latest threshold test points first to connection fan-out on `k_client -> k_proxy`
- Phase 6.5 is therefore started but not complete:
- application-level concurrency looks acceptable at moderate load
- current working envelope is roughly `10` in-flight protected calls on the direct browser path
- higher-load failures still need Qubes forwarding diagnosis before the phase can be closed
Status (2026-04-25, Phase 5 regression helper):
- Added repeatable split-VM regression helper:
- `/home/user/chromecard/phase5_chain_regression.sh`
- Verified helper result on the live chain:
- `20` requests at parallelism `8`
- login/session-status/counter/logout sequence completed successfully
- returned counter values were unique and gap-free
- latest verified helper range was `43..62`
- Current implication:
- the Phase 5 baseline is now reproducible
- next work should target auth semantics rather than basic chain bring-up
## Phase 6.5: Concurrency and Multi-Client Test Setup ## Phase 6.5: Concurrency and Multi-Client Test Setup
1. Single-VM concurrency tests. 1. Single-VM concurrency tests.
@ -434,6 +532,14 @@ Exit criteria:
Exit criteria: Exit criteria:
- `k_proxy` can validate via wireless phone path with no client-facing API changes. - `k_proxy` can validate via wireless phone path with no client-facing API changes.
## Current Next Step
- Resolve the direct-registration blocker for `--auth-mode fido2-direct` in `k_proxy`.
- Candidate directions:
- determine whether the current card can support the required PIN/UV path for direct CTAP2 registration from `python-fido2`
- or provide a different one-time enrollment route that yields persistent real credential material for later direct assertion verification
- Keep the new regression helper as the fast check that transport, session reuse, and counter semantics still hold after each change.
## Inputs Expected During This Session ## Inputs Expected During This Session
- Exact observed behavior on reconnect attempts (USB/hidraw/probe). - Exact observed behavior on reconnect attempts (USB/hidraw/probe).

View File

@ -3,19 +3,24 @@
Minimal k_proxy service for Phase 5 bring-up. Minimal k_proxy service for Phase 5 bring-up.
Behavior: Behavior:
- Creates short-lived sessions after a card-presence check. - Creates short-lived sessions after a card-backed auth gate.
- Reuses valid sessions to access k_server protected counter endpoint. - Reuses valid sessions to access k_server protected counter endpoint.
- Supports session status and logout. - Supports enrollment, session status, and logout.
Notes: Notes:
- Session login uses `fido2_probe.py --json` command success as auth gate for now. - Default runtime still uses the legacy card-presence probe gate.
- This is a Phase 5 starter and not a final production auth design. - Experimental direct FIDO2 registration/assertion lives behind `--auth-mode fido2-direct`.
- This is still a prototype and not a final production auth design.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import http.client
import json import json
import queue
import re
import secrets import secrets
import ssl import ssl
import subprocess import subprocess
@ -29,6 +34,24 @@ 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
from fido2.client import Fido2Client, UserInteraction, verify_rp_id
from fido2.hid import CtapHidDevice
from fido2.server import Fido2Server
from fido2.webauthn import (
AttestedCredentialData,
PublicKeyCredentialCreationOptions,
PublicKeyCredentialRequestOptions,
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
UserVerificationRequirement,
)
try:
from fido2.client import ClientDataCollector, CollectedClientData
except ImportError:
ClientDataCollector = None
CollectedClientData = None
HTML = """<!doctype html> HTML = """<!doctype html>
<html lang="en"> <html lang="en">
@ -146,7 +169,7 @@ HTML = """<!doctype html>
<h1>ChromeCard Proxy Portal</h1> <h1>ChromeCard Proxy Portal</h1>
<p class="subtitle"> <p class="subtitle">
Primary browser entry point for the current prototype. Browser traffic now targets k_proxy directly. Primary browser entry point for the current prototype. Browser traffic now targets k_proxy directly.
Enrollment, login, session reuse, counter access, and logout all happen on this TLS endpoint. Enrollment, card-backed login, session reuse, counter access, and logout all happen on this TLS endpoint.
</p> </p>
</section> </section>
@ -155,9 +178,14 @@ HTML = """<!doctype html>
<h2>Enrollment</h2> <h2>Enrollment</h2>
<label for="username">Username</label> <label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off"> <input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions"> <div class="actions">
<button id="enrollBtn">Enroll User</button> <button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button> <button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div> </div>
<div class="status"> <div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div> <div>Stored username: <strong id="storedUser">none</strong></div>
@ -185,6 +213,7 @@ HTML = """<!doctype html>
const EXP_KEY = "chromecard.proxy.expires_at"; const EXP_KEY = "chromecard.proxy.expires_at";
const logNode = document.getElementById("log"); const logNode = document.getElementById("log");
const usernameNode = document.getElementById("username"); const usernameNode = document.getElementById("username");
const displayNameNode = document.getElementById("displayName");
const storedUserNode = document.getElementById("storedUser"); const storedUserNode = document.getElementById("storedUser");
const sessionActiveNode = document.getElementById("sessionActive"); const sessionActiveNode = document.getElementById("sessionActive");
@ -226,7 +255,8 @@ HTML = """<!doctype html>
document.getElementById("enrollBtn").addEventListener("click", async () => { document.getElementById("enrollBtn").addEventListener("click", async () => {
try { try {
const username = usernameNode.value.trim(); const username = usernameNode.value.trim();
const data = await jsonRequest("POST", "/enroll/register", {username}); const display_name = displayNameNode.value.trim();
const data = await jsonRequest("POST", "/enroll/register", {username, display_name});
localStorage.setItem(USER_KEY, username); localStorage.setItem(USER_KEY, username);
syncState(); syncState();
log("Enrollment updated", data); log("Enrollment updated", data);
@ -242,11 +272,55 @@ HTML = """<!doctype html>
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(data)); if (!resp.ok) throw new Error(JSON.stringify(data));
log("Enrollment status", data); log("Enrollment status", data);
if (data.display_name) {
displayNameNode.value = data.display_name;
}
} catch (err) { } catch (err) {
log("Enrollment status failed", {error: err.message}); log("Enrollment status failed", {error: err.message});
} }
}); });
document.getElementById("updateBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim() || getStoredUser();
const display_name = displayNameNode.value.trim();
const data = await jsonRequest("POST", "/enroll/update", {username, display_name});
localStorage.setItem(USER_KEY, username);
syncState();
log("Enrollment updated", data);
} catch (err) {
log("Enrollment update failed", {error: err.message});
}
});
document.getElementById("deleteBtn").addEventListener("click", async () => {
try {
const username = usernameNode.value.trim() || getStoredUser();
const data = await jsonRequest("POST", "/enroll/delete", {username});
if (getStoredUser() === username) {
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXP_KEY);
}
displayNameNode.value = "";
syncState();
log("Enrollment deleted", data);
} catch (err) {
log("Enrollment delete failed", {error: err.message});
}
});
document.getElementById("listBtn").addEventListener("click", async () => {
try {
const resp = await fetch("/enroll/list");
const data = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(data));
log("Enrollment list", data);
} catch (err) {
log("Enrollment list failed", {error: err.message});
}
});
document.getElementById("loginBtn").addEventListener("click", async () => { document.getElementById("loginBtn").addEventListener("click", async () => {
try { try {
const username = usernameNode.value.trim() || getStoredUser(); const username = usernameNode.value.trim() || getStoredUser();
@ -307,30 +381,149 @@ class Session:
@dataclass @dataclass
class Enrollment: class Enrollment:
username: str username: str
enrolled_at: int display_name: str | None
created_at: int
updated_at: int
user_id_b64: str | None = None
credential_data_b64: str | None = None
USERNAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$")
AUTH_MODE_PROBE = "probe"
AUTH_MODE_FIDO2_DIRECT = "fido2-direct"
def normalize_username(raw: str) -> str:
username = raw.strip().lower()
if not USERNAME_PATTERN.fullmatch(username):
raise ValueError(
"username must be 3-32 chars of lowercase letters, digits, dot, underscore, or dash"
)
return username
def normalize_display_name(raw: str | None) -> str | None:
value = (raw or "").strip()
if not value:
return None
if len(value) > 64:
raise ValueError("display_name must be 64 characters or fewer")
return value
def b64u_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64u_decode(data: str) -> bytes:
pad = "=" * ((4 - len(data) % 4) % 4)
return base64.urlsafe_b64decode((data + pad).encode("ascii"))
def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {
"ok": True,
"username": enrollment.username,
"display_name": enrollment.display_name,
"created_at": enrollment.created_at,
"updated_at": enrollment.updated_at,
"has_credential": bool(enrollment.credential_data_b64),
}
if created is not None:
payload["created"] = created
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)
super().prompt_up()
def request_pin(self, permissions, rp_id: str | None) -> str | None:
print("Authenticator PIN is required but not supported by this prototype.", flush=True)
return super().request_pin(permissions, rp_id)
class ProxyState: class ProxyState:
def __init__( def __init__(
self, self,
session_ttl_s: int, session_ttl_s: int,
auth_mode: str,
auth_command: str, auth_command: str,
server_base_url: str, server_base_url: str,
server_ca_file: str | None, server_ca_file: str | None,
server_max_connections: int,
proxy_token: str, proxy_token: str,
enrollment_db: Path, enrollment_db: Path,
rp_id: str,
rp_name: str,
origin: str,
): ):
self.session_ttl_s = session_ttl_s self.session_ttl_s = session_ttl_s
self.auth_mode = auth_mode
self.auth_command = auth_command self.auth_command = auth_command
self.server_base_url = server_base_url.rstrip("/") self.server_base_url = server_base_url.rstrip("/")
self.server_ca_file = server_ca_file self.server_ca_file = server_ca_file
self.proxy_token = proxy_token self.proxy_token = proxy_token
self.enrollment_db = enrollment_db self.enrollment_db = enrollment_db
self.rp_id = rp_id
self.origin = origin
self.lock = threading.Lock() self.lock = threading.Lock()
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.fido_server = Fido2Server(self.rp)
self.client_data_collector = (
ProxyClientDataCollector(origin=origin, rp_id=rp_id) if ProxyClientDataCollector else None
)
self.upstream = UpstreamPool(
server_base_url=self.server_base_url,
server_ca_file=self.server_ca_file,
max_connections=server_max_connections,
)
self._load_enrollments() self._load_enrollments()
def uses_direct_fido2(self) -> bool:
return self.auth_mode == AUTH_MODE_FIDO2_DIRECT
def auth_mode_label(self) -> str:
return "fido2_assertion" if self.uses_direct_fido2() else "card_presence_probe"
def _now(self) -> float: def _now(self) -> float:
return time.time() return time.time()
@ -373,39 +566,156 @@ class ProxyState:
username = str(item.get("username", "")).strip() username = str(item.get("username", "")).strip()
if not username: if not username:
continue continue
enrolled_at = int(item.get("enrolled_at", int(self._now()))) created_at = int(item.get("created_at", item.get("enrolled_at", int(self._now()))))
self.enrollments[username] = Enrollment(username=username, enrolled_at=enrolled_at) updated_at = int(item.get("updated_at", created_at))
self.enrollments[username] = Enrollment(
username=username,
display_name=normalize_display_name(item.get("display_name")),
created_at=created_at,
updated_at=updated_at,
user_id_b64=item.get("user_id_b64"),
credential_data_b64=item.get("credential_data_b64"),
)
except Exception: except Exception:
self.enrollments = {} self.enrollments = {}
def _save_enrollments_locked(self) -> None: def _save_enrollments_locked(self) -> None:
self.enrollment_db.parent.mkdir(parents=True, exist_ok=True) self.enrollment_db.parent.mkdir(parents=True, exist_ok=True)
users = [ users = [
{"username": enrollment.username, "enrolled_at": enrollment.enrolled_at} {
"username": enrollment.username,
"display_name": enrollment.display_name,
"created_at": enrollment.created_at,
"updated_at": enrollment.updated_at,
"user_id_b64": enrollment.user_id_b64,
"credential_data_b64": enrollment.credential_data_b64,
}
for enrollment in sorted(self.enrollments.values(), key=lambda item: item.username) for enrollment in sorted(self.enrollments.values(), key=lambda item: item.username)
] ]
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 register_enrollment(self, username: str) -> tuple[bool, Enrollment]: def _new_fido_client(self) -> Fido2Client:
username = username.strip() try:
enrolled_at = int(self._now()) 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
# 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 _user_entity(self, username: str, display_name: str | None, user_id: bytes) -> PublicKeyCredentialUserEntity:
return PublicKeyCredentialUserEntity(
id=user_id,
name=username,
display_name=display_name or username,
)
def _register_metadata_only(self, username: str, display_name: str | None) -> Enrollment:
canonical = normalize_username(username)
pretty = normalize_display_name(display_name)
now = int(self._now())
with self.lock: with self.lock:
existing = self.enrollments.get(username) existing = self.enrollments.get(canonical)
if existing: if existing:
return False, existing raise FileExistsError("user already enrolled")
enrollment = Enrollment(username=username, enrolled_at=enrolled_at) enrollment = Enrollment(
self.enrollments[username] = enrollment username=canonical,
display_name=pretty,
created_at=now,
updated_at=now,
)
self.enrollments[canonical] = enrollment
self._save_enrollments_locked() self._save_enrollments_locked()
return True, enrollment return enrollment
def _register_direct_fido2(self, username: str, display_name: str | None) -> Enrollment:
canonical = normalize_username(username)
pretty = normalize_display_name(display_name)
now = int(self._now())
with self.lock:
existing = self.enrollments.get(canonical)
if existing and existing.credential_data_b64:
raise FileExistsError("user already enrolled")
user_id = b64u_decode(existing.user_id_b64) if existing and existing.user_id_b64 else secrets.token_bytes(32)
created_at = existing.created_at if existing else now
options, state = self.fido_server.register_begin(
self._user_entity(canonical, pretty, user_id),
user_verification=UserVerificationRequirement.DISCOURAGED,
)
try:
auth_data = self.fido_server.register_complete(
state,
self._new_fido_client().make_credential(options.public_key),
)
except Exception as exc:
raise RuntimeError(f"card registration failed: {exc}") from exc
credential_data = auth_data.credential_data
if credential_data is None:
raise RuntimeError("card registration returned no credential data")
enrollment = Enrollment(
username=canonical,
display_name=pretty,
created_at=created_at,
updated_at=now,
user_id_b64=b64u_encode(user_id),
credential_data_b64=b64u_encode(bytes(credential_data)),
)
with self.lock:
self.enrollments[canonical] = enrollment
self._save_enrollments_locked()
return enrollment
def register_enrollment(self, username: str, display_name: str | None) -> Enrollment:
if self.uses_direct_fido2():
return self._register_direct_fido2(username, display_name)
return self._register_metadata_only(username, display_name)
def update_enrollment(self, username: str, display_name: str | None) -> Enrollment:
canonical = normalize_username(username)
pretty = normalize_display_name(display_name)
now = int(self._now())
with self.lock:
existing = self.enrollments.get(canonical)
if not existing:
raise KeyError("user not enrolled")
existing.display_name = pretty
existing.updated_at = now
self._save_enrollments_locked()
return existing
def delete_enrollment(self, username: str) -> Enrollment:
canonical = normalize_username(username)
with self.lock:
existing = self.enrollments.pop(canonical, None)
if not existing:
raise KeyError("user not enrolled")
dead_tokens = [token for token, sess in self.sessions.items() if sess.username == canonical]
for token in dead_tokens:
del self.sessions[token]
self._save_enrollments_locked()
return existing
def list_enrollments(self) -> list[Enrollment]:
with self.lock:
return [self.enrollments[key] for key in sorted(self.enrollments)]
def get_enrollment(self, username: str) -> Enrollment | None: def get_enrollment(self, username: str) -> Enrollment | None:
try:
canonical = normalize_username(username)
except ValueError:
return None
with self.lock: with self.lock:
return self.enrollments.get(username.strip()) return self.enrollments.get(canonical)
def has_enrollment(self, username: str) -> bool: def has_enrollment(self, username: str) -> bool:
return self.get_enrollment(username) is not None return self.get_enrollment(username) is not None
def authenticate_with_card(self) -> tuple[bool, str]: def _authenticate_with_probe(self) -> tuple[bool, str]:
try: try:
proc = subprocess.run( proc = subprocess.run(
self.auth_command, self.auth_command,
@ -426,33 +736,107 @@ class ProxyState:
return True, "card presence check succeeded" return True, "card presence check succeeded"
def fetch_counter(self) -> tuple[int, dict[str, Any]]: def _authenticate_with_direct_fido2(self, username: str) -> tuple[bool, str]:
url = f"{self.server_base_url}/resource/counter" enrollment = self.get_enrollment(username)
req = Request(url, method="POST") if not enrollment:
req.add_header("X-Proxy-Token", self.proxy_token) return False, "user not enrolled"
req.add_header("Content-Type", "application/json") if not enrollment.credential_data_b64:
body = b"{}" return False, "user has no registered credential"
ssl_context = None
if self.server_base_url.startswith("https://"):
ssl_context = ssl.create_default_context(cafile=self.server_ca_file)
try: try:
with urlopen(req, data=body, timeout=5, context=ssl_context) as resp: credential = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64))
data = json.loads(resp.read().decode("utf-8")) # Keep UV explicitly discouraged here. On the current card/library stack,
return resp.status, data # asking for stronger UV flows immediately trips PIN/UV capability errors.
except HTTPError as exc: options, state = self.fido_server.authenticate_begin(
[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)
except Exception as exc:
return False, f"assertion verification failed: {exc}"
return True, "assertion verified"
def authenticate_with_card(self, username: str) -> tuple[bool, str]:
if not self.uses_direct_fido2():
return self._authenticate_with_probe()
return self._authenticate_with_direct_fido2(username)
def fetch_counter(self) -> tuple[int, dict[str, Any]]:
return self.upstream.request_json(
path="/resource/counter",
headers={"X-Proxy-Token": self.proxy_token},
payload={},
)
class UpstreamPool:
def __init__(self, server_base_url: str, server_ca_file: str | None, max_connections: int = 4):
parsed = urlparse(server_base_url)
self.scheme = parsed.scheme
self.host = parsed.hostname or "127.0.0.1"
self.port = parsed.port or (443 if parsed.scheme == "https" else 80)
self.base_path = parsed.path.rstrip("/")
self.server_ca_file = server_ca_file
self.timeout = 5
self.max_connections = max_connections
self.idle: queue.LifoQueue[http.client.HTTPConnection] = queue.LifoQueue()
self.capacity = threading.BoundedSemaphore(max_connections)
def _new_connection(self) -> http.client.HTTPConnection:
if self.scheme == "https":
context = ssl.create_default_context(cafile=self.server_ca_file)
return http.client.HTTPSConnection(
self.host,
self.port,
timeout=self.timeout,
context=context,
)
return http.client.HTTPConnection(self.host, self.port, timeout=self.timeout)
def _acquire(self) -> http.client.HTTPConnection:
self.capacity.acquire()
try:
return self.idle.get_nowait()
except queue.Empty:
return self._new_connection()
def _release(self, conn: http.client.HTTPConnection | None, reusable: bool) -> None:
try:
if conn is not None and reusable:
self.idle.put(conn)
elif conn is not None:
conn.close()
finally:
self.capacity.release()
def request_json(self, path: str, headers: dict[str, str], payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
conn = self._acquire()
reusable = False
full_path = f"{self.base_path}{path}" if self.base_path else path
try:
body = json.dumps(payload).encode("utf-8")
req_headers = {"Content-Type": "application/json", **headers}
conn.request("POST", full_path, body=body, headers=req_headers)
resp = conn.getresponse()
raw = resp.read()
reusable = not resp.will_close
try: try:
data = json.loads(exc.read().decode("utf-8")) data = json.loads(raw.decode("utf-8")) if raw else {}
except Exception: except Exception:
data = {"ok": False, "error": f"server http error {exc.code}"} data = {"ok": False, "error": f"server http error {resp.status}"}
return exc.code, data return resp.status, data
except URLError as exc: except (http.client.HTTPException, OSError, ssl.SSLError) as exc:
return 502, {"ok": False, "error": f"server unavailable: {exc.reason}"} return 502, {"ok": False, "error": f"server unavailable: {exc}"}
except Exception as exc: except Exception as exc:
return 502, {"ok": False, "error": f"server call failed: {exc}"} return 502, {"ok": False, "error": f"server call failed: {exc}"}
finally:
self._release(conn, reusable)
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
state: ProxyState state: ProxyState
protocol_version = "HTTP/1.1"
def _json(self, status: int, payload: dict[str, Any]) -> None: def _json(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
@ -477,6 +861,11 @@ class Handler(BaseHTTPRequestHandler):
return {} return {}
return json.loads(raw.decode("utf-8")) return json.loads(raw.decode("utf-8"))
def _discard_request_body(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
if length > 0:
self.rfile.read(length)
def _bearer_token(self) -> str | None: def _bearer_token(self) -> str | None:
value = self.headers.get("Authorization", "") value = self.headers.get("Authorization", "")
if not value.startswith("Bearer "): if not value.startswith("Bearer "):
@ -514,6 +903,9 @@ class Handler(BaseHTTPRequestHandler):
if path.startswith("/enroll/status"): if path.startswith("/enroll/status"):
self._enroll_status() self._enroll_status()
return return
if path == "/enroll/list":
self._enroll_list()
return
self.send_error(404) self.send_error(404)
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
@ -524,6 +916,12 @@ class Handler(BaseHTTPRequestHandler):
if path == "/enroll/register": if path == "/enroll/register":
self._enroll_register() self._enroll_register()
return return
if path == "/enroll/update":
self._enroll_update()
return
if path == "/enroll/delete":
self._enroll_delete()
return
if path == "/session/status": if path == "/session/status":
self._session_status() self._session_status()
return return
@ -542,15 +940,16 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"ok": False, "error": "invalid json"}) self._json(400, {"ok": False, "error": "invalid json"})
return return
username = str(data.get("username", "")).strip() try:
if not username: username = normalize_username(str(data.get("username", "")))
self._json(400, {"ok": False, "error": "username required"}) except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return return
if not self.state.has_enrollment(username): if not self.state.has_enrollment(username):
self._json(403, {"ok": False, "error": "user not enrolled", "username": username}) self._json(403, {"ok": False, "error": "user not enrolled", "username": username})
return return
ok, message = self.state.authenticate_with_card() ok, message = self.state.authenticate_with_card(username)
if not ok: if not ok:
self._json(401, {"ok": False, "error": "card auth failed", "details": message}) self._json(401, {"ok": False, "error": "card auth failed", "details": message})
return return
@ -564,7 +963,7 @@ class Handler(BaseHTTPRequestHandler):
"session_token": token, "session_token": token,
"expires_at": int(expires_at), "expires_at": int(expires_at),
"ttl_seconds": self.state.session_ttl_s, "ttl_seconds": self.state.session_ttl_s,
"auth_mode": "card_presence_probe", "auth_mode": self.state.auth_mode_label(),
}, },
) )
@ -575,21 +974,57 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"ok": False, "error": "invalid json"}) self._json(400, {"ok": False, "error": "invalid json"})
return return
username = str(data.get("username", "")).strip() try:
if not username: enrollment = self.state.register_enrollment(
self._json(400, {"ok": False, "error": "username required"}) str(data.get("username", "")),
data.get("display_name"),
)
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
except FileExistsError:
self._json(409, {"ok": False, "error": "user already enrolled"})
return
except RuntimeError as exc:
self._json(401, {"ok": False, "error": str(exc)})
return return
created, enrollment = self.state.register_enrollment(username) self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at))
self._json(
200, def _enroll_update(self) -> None:
{ try:
"ok": True, data = self._read_json()
"username": enrollment.username, except Exception:
"enrolled_at": enrollment.enrolled_at, self._json(400, {"ok": False, "error": "invalid json"})
"created": created, return
}, try:
) enrollment = self.state.update_enrollment(
str(data.get("username", "")),
data.get("display_name"),
)
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
except KeyError:
self._json(404, {"ok": False, "error": "user not enrolled"})
return
self._json(200, enrollment_payload(enrollment))
def _enroll_delete(self) -> None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
enrollment = self.state.delete_enrollment(str(data.get("username", "")))
except ValueError as exc:
self._json(400, {"ok": False, "error": str(exc)})
return
except KeyError:
self._json(404, {"ok": False, "error": "user not enrolled"})
return
self._json(200, {"ok": True, "username": enrollment.username, "deleted": True})
def _enroll_status(self) -> None: def _enroll_status(self) -> None:
parsed = urlparse(self.path) parsed = urlparse(self.path)
@ -608,16 +1043,14 @@ class Handler(BaseHTTPRequestHandler):
if not enrollment: if not enrollment:
self._json(404, {"ok": False, "error": "user not enrolled", "username": username}) self._json(404, {"ok": False, "error": "user not enrolled", "username": username})
return return
self._json( self._json(200, enrollment_payload(enrollment))
200,
{ def _enroll_list(self) -> None:
"ok": True, users = [enrollment_payload(item) for item in self.state.list_enrollments()]
"username": enrollment.username, self._json(200, {"ok": True, "users": users})
"enrolled_at": enrollment.enrolled_at,
},
)
def _session_status(self) -> None: def _session_status(self) -> None:
self._discard_request_body()
got = self._require_session() got = self._require_session()
if not got: if not got:
return return
@ -633,6 +1066,7 @@ class Handler(BaseHTTPRequestHandler):
) )
def _session_logout(self) -> None: def _session_logout(self) -> None:
self._discard_request_body()
token = self._bearer_token() token = self._bearer_token()
if not token: if not token:
self._json(401, {"ok": False, "error": "missing bearer token"}) self._json(401, {"ok": False, "error": "missing bearer token"})
@ -641,6 +1075,7 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "invalidated": removed}) self._json(200, {"ok": True, "invalidated": removed})
def _resource_counter(self) -> None: def _resource_counter(self) -> None:
self._discard_request_body()
got = self._require_session() got = self._require_session()
if not got: if not got:
return return
@ -667,10 +1102,31 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--tls-certfile", help="PEM certificate chain for HTTPS listener") parser.add_argument("--tls-certfile", help="PEM certificate chain for HTTPS listener")
parser.add_argument("--tls-keyfile", help="PEM private key for HTTPS listener") parser.add_argument("--tls-keyfile", help="PEM private key for HTTPS listener")
parser.add_argument("--session-ttl", type=int, default=300, help="Session TTL in seconds") parser.add_argument("--session-ttl", type=int, default=300, help="Session TTL in seconds")
parser.add_argument(
"--auth-mode",
choices=(AUTH_MODE_PROBE, AUTH_MODE_FIDO2_DIRECT),
default=AUTH_MODE_PROBE,
help="Session auth mode: legacy card-presence probe or experimental direct FIDO2 registration/assertion",
)
parser.add_argument( parser.add_argument(
"--auth-command", "--auth-command",
default="python3 /home/user/chromecard/fido2_probe.py --json", default="python3 /home/user/chromecard/fido2_probe.py --json",
help="Command used for session creation auth gate", help="Command used for legacy probe auth mode",
)
parser.add_argument(
"--rp-id",
default="localhost",
help="Relying party ID used for direct card-backed registration and assertion verification",
)
parser.add_argument(
"--rp-name",
default="ChromeCard Proxy",
help="Relying party name used for direct card-backed registration",
)
parser.add_argument(
"--origin",
default="https://localhost",
help="Synthetic origin used by the local FIDO2 client when talking directly to the attached card",
) )
parser.add_argument( parser.add_argument(
"--server-base-url", "--server-base-url",
@ -681,6 +1137,12 @@ def parse_args() -> argparse.Namespace:
"--server-ca-file", "--server-ca-file",
help="CA certificate used to verify HTTPS certificate presented by k_server", help="CA certificate used to verify HTTPS certificate presented by k_server",
) )
parser.add_argument(
"--server-max-connections",
type=int,
default=1,
help="Maximum concurrent pooled upstream connections from k_proxy to k_server",
)
parser.add_argument( parser.add_argument(
"--proxy-token", "--proxy-token",
default="dev-proxy-token", default="dev-proxy-token",
@ -703,11 +1165,16 @@ def main() -> int:
state = ProxyState( state = ProxyState(
session_ttl_s=args.session_ttl, session_ttl_s=args.session_ttl,
auth_mode=args.auth_mode,
auth_command=args.auth_command, auth_command=args.auth_command,
server_base_url=args.server_base_url, server_base_url=args.server_base_url,
server_ca_file=args.server_ca_file, server_ca_file=args.server_ca_file,
server_max_connections=args.server_max_connections,
proxy_token=args.proxy_token, proxy_token=args.proxy_token,
enrollment_db=Path(args.enrollment_db), enrollment_db=Path(args.enrollment_db),
rp_id=args.rp_id,
rp_name=args.rp_name,
origin=args.origin,
) )
Handler.state = state Handler.state = state
server = ThreadingHTTPServer((args.host, args.port), Handler) server = ThreadingHTTPServer((args.host, args.port), Handler)

View File

@ -34,6 +34,7 @@ class ServerState:
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
state: ServerState state: ServerState
protocol_version = "HTTP/1.1"
def _json(self, status: int, payload: dict[str, Any]) -> None: def _json(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
@ -43,6 +44,11 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def _discard_request_body(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
if length > 0:
self.rfile.read(length)
def _is_proxy_authorized(self) -> bool: def _is_proxy_authorized(self) -> bool:
return self.headers.get("X-Proxy-Token") == self.state.proxy_token return self.headers.get("X-Proxy-Token") == self.state.proxy_token
@ -65,6 +71,7 @@ class Handler(BaseHTTPRequestHandler):
if path != "/resource/counter": if path != "/resource/counter":
self.send_error(404) self.send_error(404)
return return
self._discard_request_body()
if not self._is_proxy_authorized(): if not self._is_proxy_authorized():
self._json(401, {"ok": False, "error": "unauthorized proxy"}) self._json(401, {"ok": False, "error": "unauthorized proxy"})
return return

180
phase5_chain_regression.sh Executable file
View File

@ -0,0 +1,180 @@
#!/usr/bin/env bash
set -euo pipefail
CLIENT_HOST="${CLIENT_HOST:-k_client}"
CA_FILE="${CA_FILE:-/home/user/chromecard/tls/phase2/ca.crt}"
PROXY_URL="${PROXY_URL:-https://127.0.0.1:9771}"
USERNAME="${USERNAME:-alice}"
REQUESTS="${REQUESTS:-20}"
PARALLELISM="${PARALLELISM:-8}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}"
usage() {
cat <<'EOF'
Usage: phase5_chain_regression.sh [options]
Runs the Phase 5 split-VM regression from the host by executing the client-side
flow inside k_client over SSH.
Options:
--client-host HOST SSH host alias for k_client (default: k_client)
--ca-file PATH CA bundle path inside k_client
--proxy-url URL Proxy URL visible from k_client
--username NAME Username for session login
--requests N Number of counter requests to issue
--parallelism N Number of concurrent workers
--connect-timeout SEC SSH connect timeout
-h, --help Show this help text
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--client-host)
CLIENT_HOST="$2"
shift 2
;;
--ca-file)
CA_FILE="$2"
shift 2
;;
--proxy-url)
PROXY_URL="$2"
shift 2
;;
--username)
USERNAME="$2"
shift 2
;;
--requests)
REQUESTS="$2"
shift 2
;;
--parallelism)
PARALLELISM="$2"
shift 2
;;
--connect-timeout)
CONNECT_TIMEOUT="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
ssh \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o ConnectTimeout="${CONNECT_TIMEOUT}" \
"${CLIENT_HOST}" \
env \
CA_FILE="${CA_FILE}" \
PROXY_URL="${PROXY_URL}" \
USERNAME="${USERNAME}" \
REQUESTS="${REQUESTS}" \
PARALLELISM="${PARALLELISM}" \
python3 - <<'PY'
import concurrent.futures
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
ca_file = os.environ["CA_FILE"]
proxy_url = os.environ["PROXY_URL"].rstrip("/")
username = os.environ["USERNAME"]
requests = int(os.environ["REQUESTS"])
parallelism = int(os.environ["PARALLELISM"])
if requests < 1:
raise SystemExit("REQUESTS must be >= 1")
if parallelism < 1:
raise SystemExit("PARALLELISM must be >= 1")
ctx = ssl.create_default_context(cafile=ca_file)
def post_json(path: str, payload: dict | None = None, token: str | None = None):
data = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {}
if payload is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(
f"{proxy_url}{path}",
data=data,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = {"ok": False, "error": body}
return exc.code, payload
status, login = post_json("/session/login", {"username": username})
if status != 200 or "session_token" not in login:
print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login}))
raise SystemExit(1)
token = login["session_token"]
values = []
def fetch_one(_: int) -> int:
status, payload = post_json("/resource/counter", {}, token=token)
if status != 200:
raise RuntimeError(json.dumps({"status": status, "response": payload}))
return int(payload["upstream"]["value"])
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as pool:
for value in pool.map(fetch_one, range(requests)):
values.append(value)
status_resp, session = post_json("/session/status", {}, token=token)
logout_status, logout = post_json("/session/logout", {}, token=token)
invalid_status, invalid = post_json("/resource/counter", {}, token=token)
except Exception as exc:
try:
post_json("/session/logout", {}, token=token)
finally:
raise SystemExit(str(exc))
sorted_values = sorted(values)
expected = list(range(sorted_values[0], sorted_values[-1] + 1)) if sorted_values else []
summary = {
"ok": True,
"username": username,
"proxy_url": proxy_url,
"requests": requests,
"parallelism": parallelism,
"unique": len(set(values)) == len(values),
"gap_free": sorted_values == expected,
"min": min(sorted_values) if sorted_values else None,
"max": max(sorted_values) if sorted_values else None,
"values": sorted_values,
"login": login,
"session_status": {"status": status_resp, "response": session},
"logout": {"status": logout_status, "response": logout},
"post_logout": {"status": invalid_status, "response": invalid},
}
print(json.dumps(summary, indent=2, sort_keys=True))
if not summary["unique"] or not summary["gap_free"] or logout_status != 200 or invalid_status != 401:
raise SystemExit(1)
PY

View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Phase 6.5 concurrency probe for the direct browser-to-k_proxy path.
What it does:
- Creates a small batch of enrolled users.
- Logs each user in through k_proxy over TLS.
- Fires protected counter requests in parallel using the returned bearer tokens.
- Verifies that all calls succeed and that returned counter values are unique and contiguous.
"""
from __future__ import annotations
import argparse
import json
import ssl
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@dataclass
class Session:
username: str
token: str
def request_json(
base_url: str,
path: str,
*,
method: str = "GET",
payload: dict[str, Any] | None = None,
token: str | None = None,
cafile: str | None = None,
timeout: int = 10,
) -> tuple[int, dict[str, Any]]:
req = Request(f"{base_url.rstrip('/')}{path}", method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
data = None if payload is None else json.dumps(payload).encode("utf-8")
context = ssl.create_default_context(cafile=cafile) if base_url.startswith("https://") else None
try:
with urlopen(req, data=data, timeout=timeout, context=context) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except HTTPError as exc:
try:
return exc.code, json.loads(exc.read().decode("utf-8"))
except Exception:
return exc.code, {"ok": False, "error": f"http error {exc.code}"}
except URLError as exc:
return 502, {"ok": False, "error": f"url error: {exc.reason}"}
except Exception as exc:
return 502, {"ok": False, "error": f"request failed: {exc}"}
def enroll_user(base_url: str, cafile: str, username: str, display_name: str) -> None:
status, data = request_json(
base_url,
"/enroll/register",
method="POST",
payload={"username": username, "display_name": display_name},
cafile=cafile,
)
if status == 200:
return
if status == 409 and data.get("error") == "user already enrolled":
return
raise RuntimeError(f"enroll failed for {username}: status={status} data={data}")
def login_user(base_url: str, cafile: str, username: str) -> Session:
status, data = request_json(
base_url,
"/session/login",
method="POST",
payload={"username": username},
cafile=cafile,
)
if status != 200 or not data.get("session_token"):
raise RuntimeError(f"login failed for {username}: status={status} data={data}")
return Session(username=username, token=data["session_token"])
def counter_call(base_url: str, cafile: str, session: Session, call_id: int) -> dict[str, Any]:
started = time.time()
status, data = request_json(
base_url,
"/resource/counter",
method="POST",
payload={},
token=session.token,
cafile=cafile,
)
finished = time.time()
return {
"call_id": call_id,
"username": session.username,
"status": status,
"data": data,
"latency_ms": int((finished - started) * 1000),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Phase 6.5 concurrency probe against k_proxy")
parser.add_argument("--base-url", default="https://127.0.0.1:9771")
parser.add_argument("--ca-file", required=True)
parser.add_argument("--users", type=int, default=3)
parser.add_argument("--requests-per-user", type=int, default=4)
parser.add_argument("--username-prefix", default="phase65")
parser.add_argument(
"--max-workers",
type=int,
help="Maximum number of in-flight protected calls; defaults to total requests",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
sessions: list[Session] = []
for idx in range(args.users):
username = f"{args.username_prefix}_{idx}"
enroll_user(args.base_url, args.ca_file, username, f"Phase65 User {idx}")
sessions.append(login_user(args.base_url, args.ca_file, username))
jobs: list[tuple[Session, int]] = []
call_id = 0
for session in sessions:
for _ in range(args.requests_per_user):
jobs.append((session, call_id))
call_id += 1
results: list[dict[str, Any]] = []
max_workers = args.max_workers or len(jobs)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(counter_call, args.base_url, args.ca_file, session, job_id): (session.username, job_id)
for session, job_id in jobs
}
for future in as_completed(future_map):
username, job_id = future_map[future]
try:
results.append(future.result())
except Exception as exc:
results.append(
{
"call_id": job_id,
"username": username,
"status": 599,
"data": {"ok": False, "error": str(exc)},
"latency_ms": -1,
}
)
results.sort(key=lambda item: item["call_id"])
ok_results = [item for item in results if item["status"] == 200 and item["data"].get("ok")]
values = [item["data"]["upstream"]["value"] for item in ok_results]
values_sorted = sorted(values)
contiguous = bool(values_sorted) and values_sorted == list(range(values_sorted[0], values_sorted[0] + len(values_sorted)))
summary = {
"ok": len(ok_results) == len(results) and len(set(values)) == len(values) and contiguous,
"users": args.users,
"requests_per_user": args.requests_per_user,
"total_requests": len(results),
"max_workers": max_workers,
"successful_requests": len(ok_results),
"unique_counter_values": len(set(values)),
"counter_min": min(values_sorted) if values_sorted else None,
"counter_max": max(values_sorted) if values_sorted else None,
"counter_contiguous": contiguous,
"max_latency_ms": max((item["latency_ms"] for item in results), default=None),
"results": results,
}
print(json.dumps(summary, indent=2))
return 0 if summary["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())

311
raw_ctap_probe.py Normal file
View File

@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""
Low-level CTAP2 probe for ChromeCard host debugging.
This bypasses the higher-level Fido2Client/WebAuthn helpers so we can inspect
raw makeCredential/getAssertion behavior, keepalive callbacks, and transport
errors on the host stack.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import secrets
import sys
import time
import traceback
from typing import Any
try:
from fido2.ctap import CtapError
from fido2.ctap2 import Ctap2
from fido2.hid import CtapHidDevice
except Exception as exc:
print("Missing dependency: python-fido2", file=sys.stderr)
print("Install with: python3 -m pip install fido2", file=sys.stderr)
print(f"Import error: {exc}", file=sys.stderr)
sys.exit(2)
def _json_default(value: Any) -> Any:
if isinstance(value, bytes):
return value.hex()
if isinstance(value, set):
return sorted(value)
if hasattr(value, "items"):
return dict(value.items())
return str(value)
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
def log(message: str) -> None:
print(f"[{_now()}] {message}", file=sys.stderr, flush=True)
def list_devices() -> list[CtapHidDevice]:
return list(CtapHidDevice.list_devices())
def describe_device(dev: CtapHidDevice) -> dict[str, Any]:
desc = getattr(dev, "descriptor", None)
return {
"product_name": getattr(desc, "product_name", None),
"manufacturer": getattr(desc, "manufacturer_string", None),
"vendor_id": getattr(desc, "vid", None),
"product_id": getattr(desc, "pid", None),
"path": getattr(desc, "path", None),
}
def get_ctap2(dev: CtapHidDevice) -> Ctap2:
return Ctap2(dev)
def print_json(payload: dict[str, Any]) -> None:
print(json.dumps(payload, indent=2, default=_json_default))
def keepalive_logger(status: int) -> None:
log(f"keepalive status={status}")
def _coerce_hex_bytes(value: str | None, label: str) -> bytes | None:
if value is None:
return None
raw = value.strip().lower()
if raw.startswith("0x"):
raw = raw[2:]
try:
return bytes.fromhex(raw)
except ValueError as exc:
raise SystemExit(f"invalid hex for {label}: {value}") from exc
def _client_data_hash(label: str) -> bytes:
return hashlib.sha256(label.encode("utf-8")).digest()
def _key_params() -> list[dict[str, Any]]:
return [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257},
]
def do_info(ctap2: Ctap2, device_meta: dict[str, Any]) -> int:
info = ctap2.get_info()
print_json({"device": device_meta, "ctap2_info": info})
return 0
def do_make_credential(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[str, Any]) -> int:
rp = {"id": args.rp_id, "name": args.rp_name or args.rp_id}
user_id = args.user_id.encode("utf-8")
user = {
"id": user_id,
"name": args.user_name,
"displayName": args.user_display_name or args.user_name,
}
client_data_hash = _client_data_hash(f"chromecard-make-credential:{args.rp_id}:{args.user_name}")
options = {"rk": args.resident_key, "uv": args.user_verification}
log(
"starting makeCredential "
f"rp_id={args.rp_id} user={args.user_name} rk={options['rk']} uv={options['uv']}"
)
try:
response = ctap2.make_credential(
client_data_hash=client_data_hash,
rp=rp,
user=user,
key_params=_key_params(),
options=options,
on_keepalive=keepalive_logger,
)
except CtapError as exc:
print_json(
{
"operation": "makeCredential",
"device": device_meta,
"rp": rp,
"user": user,
"options": options,
"error_type": "CtapError",
"error_code": getattr(exc, "code", None),
"error_name": str(getattr(exc, "code", None)),
"message": str(exc),
}
)
return 1
except Exception as exc:
print_json(
{
"operation": "makeCredential",
"device": device_meta,
"rp": rp,
"user": user,
"options": options,
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
}
)
return 1
auth_data = getattr(response, "auth_data", None)
credential_data = getattr(auth_data, "credential_data", None)
print_json(
{
"operation": "makeCredential",
"device": device_meta,
"rp": rp,
"user": user,
"options": options,
"fmt": getattr(response, "fmt", None),
"auth_data": auth_data,
"credential_id_hex": getattr(credential_data, "credential_id", b"").hex()
if credential_data is not None
else None,
"credential_data_hex": bytes(credential_data).hex() if credential_data is not None else None,
"att_stmt": getattr(response, "att_stmt", None),
}
)
return 0
def do_get_assertion(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[str, Any]) -> int:
allow_credential = _coerce_hex_bytes(args.allow_credential_id, "allow-credential-id")
allow_list = [{"type": "public-key", "id": allow_credential}] if allow_credential else None
client_data_hash = _client_data_hash(f"chromecard-get-assertion:{args.rp_id}")
options = {"up": True, "uv": args.user_verification}
log(
"starting getAssertion "
f"rp_id={args.rp_id} allow_list={1 if allow_list else 0} uv={options['uv']}"
)
try:
response = ctap2.get_assertion(
rp_id=args.rp_id,
client_data_hash=client_data_hash,
allow_list=allow_list,
options=options,
on_keepalive=keepalive_logger,
)
except CtapError as exc:
print_json(
{
"operation": "getAssertion",
"device": device_meta,
"rp_id": args.rp_id,
"allow_list": allow_list,
"options": options,
"error_type": "CtapError",
"error_code": getattr(exc, "code", None),
"error_name": str(getattr(exc, "code", None)),
"message": str(exc),
}
)
return 1
except Exception as exc:
print_json(
{
"operation": "getAssertion",
"device": device_meta,
"rp_id": args.rp_id,
"allow_list": allow_list,
"options": options,
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
}
)
return 1
assertions: list[dict[str, Any]] = []
for item in getattr(response, "assertions", []) or []:
assertions.append(
{
"credential": getattr(item, "credential", None),
"auth_data": getattr(item, "auth_data", None),
"signature": getattr(item, "signature", None),
"user": getattr(item, "user", None),
"number_of_credentials": getattr(item, "number_of_credentials", None),
}
)
print_json(
{
"operation": "getAssertion",
"device": device_meta,
"rp_id": args.rp_id,
"allow_list": allow_list,
"options": options,
"assertions": assertions,
}
)
return 0
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")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list", help="List CTAP HID devices")
subparsers.add_parser("info", help="Fetch CTAP2 getInfo")
make_credential = subparsers.add_parser("make-credential", help="Run raw CTAP2 makeCredential")
make_credential.add_argument("--rp-id", default="localhost")
make_credential.add_argument("--rp-name", default="ChromeCard Local Probe")
make_credential.add_argument("--user-name", default="probe-user")
make_credential.add_argument("--user-display-name", default="Probe User")
make_credential.add_argument("--user-id", default=secrets.token_hex(16))
make_credential.add_argument("--resident-key", action="store_true")
make_credential.add_argument("--user-verification", action="store_true")
get_assertion = subparsers.add_parser("get-assertion", help="Run raw CTAP2 getAssertion")
get_assertion.add_argument("--rp-id", default="localhost")
get_assertion.add_argument("--allow-credential-id", help="Credential id as hex")
get_assertion.add_argument("--user-verification", action="store_true")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
devs = list_devices()
if args.command == "list":
print_json(
{
"devices": [describe_device(dev) for dev in devs],
}
)
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]
device_meta = describe_device(dev)
ctap2 = get_ctap2(dev)
if args.command == "info":
return do_info(ctap2, device_meta)
if args.command == "make-credential":
return do_make_credential(ctap2, args, device_meta)
if args.command == "get-assertion":
return do_get_assertion(ctap2, args, device_meta)
parser.error(f"unsupported command: {args.command}")
return 2
if __name__ == "__main__":
raise SystemExit(main())