commit 9abf41fcd59ad416f5c676a785e41b3f62b95556 Author: Morten V. Christiansen Date: Fri Apr 24 05:38:00 2026 +0200 Initialize workspace tracking repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fdbb11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Local agent and transient artifacts +.codex/ +__pycache__/ +*.pyc + +# Keep firmware SDK tree out of this workspace-tracking repo +CR_SDK_CK-main/ diff --git a/Setup.md b/Setup.md new file mode 100644 index 0000000..fd7fef5 --- /dev/null +++ b/Setup.md @@ -0,0 +1,191 @@ +# Setup + +Last updated: 2026-04-24 + +This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`. +Update this file whenever environment status or verified behavior changes. + +## Repository Policy + +- Treat `/home/user/chromecard/CR_SDK_CK-main` as read-only in this workflow. +- Do not add or modify helper/test scripts inside `CR_SDK_CK-main`. +- Keep host-side helper scripts at workspace root (`/home/user/chromecard`). + +## Documentation Maintenance + +- Canonical living status docs for this workspace are: + - `/home/user/chromecard/Setup.md` + - `/home/user/chromecard/Workplan.md` +- After each meaningful execution step, update at least: + - `Setup.md` for observed environment/runtime state + - `Workplan.md` for phase progress and next blocking action +- Keep helper script paths consistent in docs: + - `/home/user/chromecard/fido2_probe.py` + - `/home/user/chromecard/webauthn_local_demo.py` +- Treat `CR_SDK_CK-main/README_HOST.md` as historical reference unless its script paths are aligned with this workspace policy. + +## Scope + +- Experimental ChromeCard connected over USB. +- Firmware source tree: `/home/user/chromecard/CR_SDK_CK-main`. +- Host-side FIDO2 demo tools: + - `/home/user/chromecard/fido2_probe.py` + - `/home/user/chromecard/webauthn_local_demo.py` +- Target runtime platform: Qubes OS with 3 AppVMs: + - `k_client` (browser + enrollment process) + - `k_proxy` (card-connected proxy/auth client) + - `k_server` (protected resource/backend) + +## Planned Transport Evolution + +- Current phase assumption: card is connected directly to `k_proxy` (USB). +- Future target: card is connected to a phone, and `k_proxy` performs validation through a wireless link to that phone. +- Design implication: keep authenticator transport behind an abstraction in `k_proxy` so USB-direct and phone-wireless backends can be swapped without changing client/server API contracts. + +## Target Qubes Topology + +- Base template for all AppVMs: Debian template. +- Allowed network paths: + - `k_client` -> `k_proxy` over TLS + - `k_proxy` -> `k_server` over TLS + - Response traffic returns on those established connections. +- Disallowed direct path: + - `k_client` -> `k_server` (direct access should be blocked). + +Functional roles: +- `k_client`: + - Browser-only traffic client. + - Runs a user enrollment process. +- `k_proxy`: + - Current: connected to the ChromeCard over USB. + - Future: connects wirelessly to phone-attached card for validation. + - Accepts TLS requests from `k_client`. + - Uses card-backed FIDO2/WebAuthn operations to authenticate user/session. + - Calls `k_server` over TLS after successful authorization. + - Returns proxied data and session information to `k_client`. +- `k_server`: + - Hosts resource(s) requiring login via the proxy-mediated flow. + - Provides a dummy protected resource for early integration testing (monotonic increasing number/counter). + - May hold user/session state logic needed for authorization decisions. + +## Target Request Flow + +1. `k_client` sends HTTPS request to `k_proxy`. +2. `k_proxy` validates/authenticates user via card-backed flow. +3. If allowed, `k_proxy` opens HTTPS request to `k_server` resource. +4. `k_server` responds to `k_proxy`. +5. `k_proxy` returns response payload to `k_client` plus session state. +6. Subsequent requests reuse session state so card auth is not required every request. + +Implementation note: +- `k_proxy` does not need a full web server stack; a minimal TLS API service is sufficient. +- Session state should be integrity-protected (signed/encrypted token or server-side session ID) with TTL and revocation behavior defined. +- `k_proxy` and `k_server` must be safe under concurrent access (thread-safe state handling). + +## Minimum Service Behavior (Current Target) + +- `k_server`: + - Expose protected endpoint returning an increasing integer value (dummy resource). + - Increment behavior must remain correct under concurrent requests. + - Optionally expose/maintain user/session validation logic. +- `k_proxy`: + - Accept concurrent HTTPS requests from one or more `k_client` instances. + - Perform card-backed auth when no valid session is present. + - Cache and validate session state so repeated requests avoid card access until expiry. + - Forward authorized requests to `k_server` and return upstream data plus session info. + +Thread-safety expectation: +- Shared mutable state (counter, session store, user state) must be protected against races. +- Parallel requests must not corrupt session records or return duplicate/skipped counter values caused by unsafe updates. + +## Test Topology Requirement + +- Support concurrency testing from multiple simultaneous clients: + - multiple browser tabs/processes in one `k_client`, and/or + - multiple `k_client` AppVM instances if available. +- Validate both correctness and stability under load: + - session reuse works as intended + - unauthorized access stays blocked + - protected counter/resource remains consistent. + +## Current Status Snapshot (2026-04-24) + +- Python is available: `Python 3.13.12`. +- `python3 fido2_probe.py --list` runs, but returns: `No CTAP HID devices found.` +- No HID raw device nodes currently visible: `no hidraw devices visible`. +- `west` is not currently installed/in PATH: `west not found`. +- The checked-out `CR_SDK_CK-main` tree appears incomplete for documented sysbuild role layout: + - missing: `mvp`, `setup`, `components`, `samples` +- `CR_SDK_CK-main/scripts/build_flash_mvp.sh` exists, but it expects the above role directories. +- Python helper scripts were intentionally moved out of `CR_SDK_CK-main/scripts` and are now maintained at workspace root. + +Implication: +- We cannot currently confirm live FIDO2 connectivity from this host. +- We cannot currently run the documented firmware build/flash flow. + +Session note (2026-04-24): +- Markdown tracking was reviewed and normalized around `Setup.md` + `Workplan.md` as the active, continuously updated execution record. + +## Known FIDO2 Transport Boundary + +- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT. +- Key code points in `CR_SDK_CK-main`: + - `mgr_fido2.c`: `mgr_fido2_init()` registers `fido2_ctaphid_handle_packet`. + - `ctaphid.c`: `fido2_ctaphid_handle_packet(...)`. + - `cr_config.h`: FIDO2 HID report descriptor definitions. + +## Host Bring-Up Steps (How To Get To A Working FIDO2 Check) + +1. Confirm USB enumeration and HID visibility. +- Replug card with a known data-capable cable. +- Check: `ls -l /dev/hidraw*` + +2. If needed, grant Linux HID access for this device. +- Add rule at `/etc/udev/rules.d/70-chromecard-fido.rules`: +```udev +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="0660", TAG+="uaccess" +``` +- Reload/apply rules and replug the device. + +3. Verify CTAP HID presence. +- `python3 /home/user/chromecard/fido2_probe.py --list` +- Then: +- `python3 /home/user/chromecard/fido2_probe.py --json` + +4. Run local WebAuthn bring-up demo. +- `python3 /home/user/chromecard/webauthn_local_demo.py` +- Open `http://localhost:8765` (use `localhost`, not `127.0.0.1`). + +5. Execute register/login test. +- Register a user. +- Login with the same user. +- Confirm no origin/challenge mismatch errors. + +## Build/Flash Prerequisites (How To Get To Firmware Build) + +1. Ensure full SDK checkout layout exists under `CR_SDK_CK-main`: +- `mvp` +- `setup` +- `components` +- `samples` + +2. Ensure toolchain is available in shell: +- `west --version` +- `nrfjprog --version` + +3. Once layout/tooling are in place, run: +- `cd /home/user/chromecard/CR_SDK_CK-main` +- `./scripts/build_flash_mvp.sh` + +## Open Gaps To Resolve + +- Why no `/dev/hidraw*` device is visible despite USB connection. +- Whether udev rule is missing or device VID/PID differs from expected. +- Whether current firmware on card exposes the FIDO2 HID interface. +- 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. +- 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`. +- Concrete session format/lifetime so cached sessions reduce card prompts without weakening security. +- Precise ownership split of session/user state between `k_proxy` and `k_server`. +- Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds). diff --git a/Workplan.md b/Workplan.md new file mode 100644 index 0000000..ced9886 --- /dev/null +++ b/Workplan.md @@ -0,0 +1,269 @@ +# Workplan + +Last updated: 2026-04-24 + +This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine. + +## Constraints + +- Treat `/home/user/chromecard/CR_SDK_CK-main` as read-only. +- Keep helper scripts such as `fido2_probe.py` and `webauthn_local_demo.py` at `/home/user/chromecard`. +- Target deployment model is Qubes OS with 3 Debian-based AppVMs: `k_client`, `k_proxy`, `k_server`. +- Current authenticator link is card->`k_proxy` (USB), but architecture must allow migration to wireless phone-mediated validation. + +## Goals + +- Re-establish deterministic host-to-card FIDO2 communication over USB HID/CTAPHID. +- Restore a buildable/flashable firmware workspace for `CR_SDK_CK-main`. +- Turn ad-hoc demos into a repeatable verification flow. +- Stand up chained TLS communication in Qubes: `k_client -> k_proxy -> k_server`. +- Support both login flow (browser in `k_client`) and user enrollment flow (process in `k_client`). +- Minimize repeated card prompts by introducing secure session reuse after successful authentication. +- Implement a protected dummy resource on `k_server` (monotonic counter) for end-to-end validation. +- Ensure `k_proxy` and `k_server` are thread-safe and support concurrent access. +- Prepare `k_proxy` auth path for future transport shift: USB-direct -> wireless phone bridge. + +## Phase 0: Qubes VM Baseline (Blocking) + +1. Provision/verify AppVMs. +- Ensure `k_client`, `k_proxy`, `k_server` exist and are based on the Debian template. + +2. Assign functional responsibilities. +- `k_client`: browser client + enrollment process. +- `k_proxy`: USB card access + proxy/auth bridge. +- `k_server`: protected resource/service endpoint. + +3. Define TLS endpoints and certificates. +- `k_proxy` presents TLS service to `k_client`. +- `k_server` presents TLS service to `k_proxy`. +- Trust roots and cert distribution model documented per VM. + +Exit criteria: +- All 3 VMs exist, boot, and have clearly defined service ownership. + +## Phase 1: Qubes Firewall Policy (Blocking) + +1. Enforce allowed forward paths only. +- Allow `k_client` outbound TLS only to `k_proxy` service port(s). +- Allow `k_proxy` outbound TLS only to `k_server` service port(s). +- Deny direct `k_client` to `k_server` traffic. + +2. Validate return path behavior. +- Confirm responses propagate back through established flows. + +3. Verify with simple probes. +- TLS handshake and HTTP(S) checks from `k_client` to `k_proxy`. +- TLS handshake and HTTP(S) checks from `k_proxy` to `k_server`. + +Exit criteria: +- Policy matches intended chain and is test-verified. + +## Phase 2: TLS Certificates and Service Endpoints + +1. Certificate model. +- Create or import CA and issue certs for `k_proxy` and `k_server`. +- Install trust roots in client VM(s) that need validation. + +2. Service shape. +- `k_server`: HTTPS service exposing protected resource endpoint(s), including a monotonic counter endpoint. +- `k_proxy`: minimal HTTPS API gateway service (full web server framework not required). + +3. Endpoint contract. +- Define request/response schema between `k_client` and `k_proxy`. +- Define upstream request contract from `k_proxy` to `k_server`. + +Exit criteria: +- Mutual TLS trust decisions are documented and tested. +- HTTPS calls succeed on both links with expected cert validation. + +## Phase 2.5: Define State Ownership and Concurrency Model + +1. State ownership. +- Decide where user/session state is authoritative (`k_proxy`, `k_server`, or split model). +- Define token/session format and validation boundary. + +2. Concurrency controls. +- Define thread-safe strategy for session store and shared counters. +- Define locking/atomic/update semantics for counter increments and session updates. + +3. Runtime model. +- Choose service runtime/config that supports simultaneous requests safely. + +Exit criteria: +- Architecture clearly documents state authority and race-free update rules. + +## Phase 3: Recover Basic Device Visibility on `k_proxy` (Blocking) + +1. Verify physical + USB enumeration path. +- Check cable/port and confirm device appears in USB listings. +- Confirm `/dev/hidraw*` nodes appear when card is connected. + +2. Validate Linux permissions. +- Install/update udev rule for ChromeCard HID VID/PID. +- Reload udev and verify non-root read/write access to hidraw node. + +3. Re-run host probe. +- Run `python3 /home/user/chromecard/fido2_probe.py --list`. +- Run `python3 /home/user/chromecard/fido2_probe.py --json`. +- Record VID/PID/path and CTAP2 `getInfo` output in `Setup.md`. + +Exit criteria: +- At least one CTAP HID device is listed. +- `--json` returns valid `ctap2_info`. + +## Phase 4: Re-validate Local WebAuthn Demo on `k_proxy` + +1. Start local demo server. +- Run `python3 /home/user/chromecard/webauthn_local_demo.py`. +- Confirm URL is `http://localhost:8765`. + +2. Exercise register/login. +- Register a test user. +- Authenticate with same user. +- Capture errors (if any) and update `Setup.md`. + +3. Decide next demo hardening step. +- Keep bring-up-only mode, or +- add signature verification for attestation/assertion. + +Exit criteria: +- Register and login both complete with card interaction prompts. + +## Phase 5: Implement Proxy Auth + Session Reuse + +1. Authenticate via card once per session window. +- `k_proxy` handles initial auth using connected card. +- On success, create session state for `k_client`. + +2. Session model. +- Prefer server-side session store or signed session token. +- Include TTL/expiry, rotation, and explicit invalidation/logout path. +- Do not expose card secrets or long-lived auth material to `k_client`. + +3. Proxying behavior. +- With valid session: `k_proxy` forwards request to `k_server` and returns result. +- Without valid session: require fresh card-backed auth flow. + +Exit criteria: +- Repeated authorized requests do not require card interaction until session expiry. +- Expired/invalid sessions are correctly rejected. + +## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server` + +1. Protected dummy resource. +- Add endpoint returning increasing number. +- Require valid upstream auth/session context from `k_proxy`. + +2. Optional user/session handling. +- Add minimal user/session checks if `k_server` is chosen as authority (or partial authority). + +3. Correctness under concurrency. +- Ensure increments are monotonic and race-safe under parallel calls. + +Exit criteria: +- Authorized requests obtain consistent increasing values. +- Unauthorized requests are rejected. + +## Phase 6: Integrate Client Enrollment + Proxy Login Flow + +1. Enrollment process in `k_client`. +- Start process from `k_client` that captures new-user enrollment intent/data. +- Route enrollment requests to `k_proxy` over TLS. + +2. Card-mediated login in `k_proxy`. +- `k_proxy` uses connected card for FIDO2/WebAuthn operations. +- `k_proxy` authenticates toward `k_server` over TLS. + +3. Browser flow in `k_client`. +- Browser traffic goes only to `k_proxy`. +- Validate end-to-end login to `k_server` resource through proxy chain. + +Exit criteria: +- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. + +## Phase 6.5: Concurrency and Multi-Client Test Setup + +1. Single-VM concurrency tests. +- Generate parallel request bursts from `k_client` to `k_proxy`. +- Verify response integrity, session reuse behavior, and error rates. + +2. Multi-client tests. +- Run requests from multiple `k_client` instances (or equivalent parallel clients) concurrently. +- Verify isolation between users/sessions. + +3. Acceptance checks. +- No race-related crashes/corruption in `k_proxy` or `k_server`. +- Counter/resource behavior remains correct under load. +- Session reuse reduces card prompts while preserving authorization checks. + +Exit criteria: +- Test results demonstrate stable concurrent operation with documented limits. + +## Phase 7: Restore Firmware Build/Flash Path + +1. Validate SDK tree completeness. +- Confirm presence of `mvp`, `setup`, `components`, `samples` under `CR_SDK_CK-main`. +- If missing, obtain full repository/checkpoint and document source. + +2. Install/enable build tools. +- Ensure `west` and `nrfjprog` are available in shell. +- Confirm target board/toolchain match (`nrf7002dk/nrf5340/cpuapp`, NCS `v2.9.2` baseline in docs). + +3. Run baseline build+flash. +- From `CR_SDK_CK-main`, run `./scripts/build_flash_mvp.sh`. +- If flashing fails, run documented recovery and retry. + +Exit criteria: +- Successful `west build` and `west flash`. + +## Phase 8: Consolidate Documentation and Paths + +1. Remove path drift between docs and actual files. +- Keep `fido2_probe.py` and `webauthn_local_demo.py` at workspace root. +- Ensure docs never instruct placing helper scripts under `CR_SDK_CK-main`. +- Update references consistently in all docs. + +2. Keep `Setup.md` current. +- After each significant change, update status snapshot and outcomes. + +3. Add minimal reproducibility checklist. +- One command list for probe + demo + build/flash prechecks. + +4. Maintain Markdown execution records continuously. +- `Setup.md` and `Workplan.md` are the canonical living docs for this workspace. +- Re-scan relevant `.md` files before each new execution cycle and reconcile drift. +- Record date-stamped session notes when priorities or blockers change. + +Exit criteria: +- New team member can follow docs end-to-end without path or tooling ambiguity. + +## Phase 9: Migrate to Phone-Mediated Wireless Validation (Future) + +1. Auth transport abstraction in `k_proxy`. +- Introduce/keep a transport interface for authenticator operations. +- Implement at least two backends: +- USB-direct backend (current). +- Phone-wireless backend (future). + +2. Wireless phone integration. +- Define protocol between `k_proxy` and phone service. +- Define secure pairing/authentication and message integrity for wireless link. +- Add timeout/retry behavior and offline handling. + +3. Functional equivalence tests. +- Verify login/enrollment behavior is unchanged at API level for `k_client`. +- Verify session reuse still works and card prompts are not increased unexpectedly. + +Exit criteria: +- `k_proxy` can validate via wireless phone path with no client-facing API changes. + +## Inputs Expected During This Session + +- Exact observed behavior on reconnect attempts (USB/hidraw/probe). +- Whether we should pull server-side code now. +- Any board/firmware variants different from default documentation assumptions. +- Preferred TLS ports, certificate approach, and hostname scheme for `k_client`, `k_proxy`, `k_server`. +- Session TTL and invalidation requirements for cached authenticated access. +- Decision on where user/session authority lives (`k_proxy` vs `k_server` vs split). +- Target concurrency level for validation (parallel clients and parallel requests per client). +- Preferred wireless transport/protocol between `k_proxy` and phone (for future phase). diff --git a/fido2_probe.py b/fido2_probe.py new file mode 100755 index 0000000..f409beb --- /dev/null +++ b/fido2_probe.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Minimal host tool for checking CTAP2/FIDO2 connectivity to a ChromeCard. + +Requires: + pip install fido2 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +try: + from fido2.hid import CtapHidDevice + from fido2.ctap2 import Ctap2 +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) + return str(value) + + +def list_devices() -> list[CtapHidDevice]: + return list(CtapHidDevice.list_devices()) + + +def describe_device(dev: CtapHidDevice) -> dict[str, Any]: + desc = getattr(dev, "descriptor", None) + out = { + "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), + } + return out + + +def print_device_table(devs: list[CtapHidDevice]) -> None: + if not devs: + print("No CTAP HID devices found.") + return + for idx, dev in enumerate(devs): + info = describe_device(dev) + print( + f"[{idx}] " + f"vid:pid={info['vendor_id']}:{info['product_id']} " + f"manufacturer={info['manufacturer']} " + f"product={info['product_name']} " + f"path={info['path']}" + ) + + +def get_info(dev: CtapHidDevice) -> dict[str, Any]: + ctap2 = Ctap2(dev) + info = ctap2.get_info() + out = { + "versions": getattr(info, "versions", None), + "extensions": getattr(info, "extensions", None), + "aaguid": getattr(info, "aaguid", None), + "options": getattr(info, "options", None), + "max_msg_size": getattr(info, "max_msg_size", None), + "pin_uv_protocols": getattr(info, "pin_uv_protocols", None), + "firmware_version": getattr(info, "firmware_version", None), + "transports": getattr(info, "transports", None), + "algorithms": getattr(info, "algorithms", None), + } + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description="Probe USB FIDO2/CTAP2 devices") + parser.add_argument( + "--index", + type=int, + default=0, + help="Device index from --list output (default: 0)", + ) + parser.add_argument( + "--list", + action="store_true", + help="List devices only", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print CTAP2 info as JSON", + ) + args = parser.parse_args() + + devs = list_devices() + if args.list: + print_device_table(devs) + return 0 if devs else 1 + + if not devs: + print("No CTAP HID devices found.") + 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) + print_device_table(devs) + return 2 + + dev = devs[args.index] + dev_meta = describe_device(dev) + info = get_info(dev) + + if args.json: + payload = {"device": dev_meta, "ctap2_info": info} + print(json.dumps(payload, indent=2, default=_json_default)) + return 0 + + print("Selected device:") + print( + f" vid:pid={dev_meta['vendor_id']}:{dev_meta['product_id']} " + f"manufacturer={dev_meta['manufacturer']} " + f"product={dev_meta['product_name']}" + ) + print(f" path={dev_meta['path']}") + print("CTAP2 getInfo:") + print(json.dumps(info, indent=2, default=_json_default)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/webauthn_local_demo.py b/webauthn_local_demo.py new file mode 100644 index 0000000..a34ff81 --- /dev/null +++ b/webauthn_local_demo.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +Local WebAuthn demo server for USB FIDO2 card testing. + +Purpose: +- Validate registration and authentication flows with the connected card. +- Keep setup minimal (Python stdlib only). + +Security note: +- This demo does NOT verify attestation or assertion signatures. +- Use only for local bring-up/testing, not production. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import secrets +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + + +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 random_b64u(n: int = 32) -> str: + return b64u_encode(secrets.token_bytes(n)) + + +class DemoState: + def __init__(self, db_path: Path, rp_id: str, rp_name: str, origin: str): + self.db_path = db_path + self.rp_id = rp_id + self.rp_name = rp_name + self.origin = origin + self.pending_register: dict[str, str] = {} + self.pending_auth: dict[str, str] = {} + self.db: dict[str, Any] = self._load_db() + + def _load_db(self) -> dict[str, Any]: + if not self.db_path.exists(): + return {"users": {}} + with self.db_path.open("r", encoding="utf-8") as f: + return json.load(f) + + def save_db(self) -> None: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + with self.db_path.open("w", encoding="utf-8") as f: + json.dump(self.db, f, indent=2) + + def get_user(self, username: str) -> dict[str, Any]: + users = self.db.setdefault("users", {}) + return users.setdefault(username, {"credentials": []}) + + +def html_page() -> str: + return """ + + + + + ChromeCard WebAuthn Local Demo + + + +

ChromeCard WebAuthn Demo

+

Use this page to test local FIDO2 register/login over USB.

+
+ + + +
+

+  
+
+
+"""
+
+
+class Handler(BaseHTTPRequestHandler):
+    state: DemoState
+
+    def _json(self, status: int, data: dict[str, Any]) -> None:
+        body = json.dumps(data).encode("utf-8")
+        self.send_response(status)
+        self.send_header("Content-Type", "application/json")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def _bad(self, message: str, status: int = 400) -> None:
+        self._json(status, {"ok": False, "error": message})
+
+    def _read_json(self) -> dict[str, Any]:
+        length = int(self.headers.get("Content-Length", "0"))
+        raw = self.rfile.read(length)
+        return json.loads(raw.decode("utf-8"))
+
+    def do_GET(self) -> None:  # noqa: N802
+        path = urlparse(self.path).path
+        if path == "/":
+            body = html_page().encode("utf-8")
+            self.send_response(200)
+            self.send_header("Content-Type", "text/html; charset=utf-8")
+            self.send_header("Content-Length", str(len(body)))
+            self.end_headers()
+            self.wfile.write(body)
+            return
+        self.send_error(404)
+
+    def do_POST(self) -> None:  # noqa: N802
+        path = urlparse(self.path).path
+        try:
+            data = self._read_json()
+        except Exception:
+            self._bad("Invalid JSON")
+            return
+
+        if path == "/register/start":
+            self._register_start(data)
+            return
+        if path == "/register/finish":
+            self._register_finish(data)
+            return
+        if path == "/auth/start":
+            self._auth_start(data)
+            return
+        if path == "/auth/finish":
+            self._auth_finish(data)
+            return
+        self.send_error(404)
+
+    def _register_start(self, data: dict[str, Any]) -> None:
+        username = str(data.get("username", "")).strip()
+        if not username:
+            self._bad("username required")
+            return
+
+        challenge = random_b64u(32)
+        user_id = random_b64u(32)
+        self.state.pending_register[username] = challenge
+        public_key = {
+            "rp": {"name": self.state.rp_name, "id": self.state.rp_id},
+            "user": {"id": user_id, "name": username, "displayName": username},
+            "challenge": challenge,
+            "pubKeyCredParams": [{"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -257}],
+            "timeout": 60000,
+            "attestation": "none",
+            "authenticatorSelection": {
+                "residentKey": "discouraged",
+                "requireResidentKey": False,
+                "userVerification": "preferred",
+            },
+        }
+        self._json(200, {"ok": True, "publicKey": public_key})
+
+    def _register_finish(self, data: dict[str, Any]) -> None:
+        username = str(data.get("username", "")).strip()
+        expected = self.state.pending_register.get(username)
+        if not username or not expected:
+            self._bad("no pending registration")
+            return
+        try:
+            client_data_raw = b64u_decode(data["response"]["clientDataJSON"])
+            client_data = json.loads(client_data_raw.decode("utf-8"))
+            challenge = client_data.get("challenge")
+            typ = client_data.get("type")
+            origin = client_data.get("origin")
+        except Exception:
+            self._bad("invalid credential response")
+            return
+
+        if typ != "webauthn.create":
+            self._bad("unexpected clientData type")
+            return
+        if challenge != expected:
+            self._bad("challenge mismatch")
+            return
+        if origin != self.state.origin:
+            self._bad(f"origin mismatch: expected {self.state.origin}, got {origin}")
+            return
+
+        raw_id = str(data.get("rawId", ""))
+        if not raw_id:
+            self._bad("rawId missing")
+            return
+
+        user = self.state.get_user(username)
+        creds = user.setdefault("credentials", [])
+        if raw_id not in creds:
+            creds.append(raw_id)
+        self.state.save_db()
+        self.state.pending_register.pop(username, None)
+        self._json(200, {"ok": True, "username": username, "credential_count": len(creds)})
+
+    def _auth_start(self, data: dict[str, Any]) -> None:
+        username = str(data.get("username", "")).strip()
+        if not username:
+            self._bad("username required")
+            return
+        user = self.state.db.get("users", {}).get(username)
+        if not user or not user.get("credentials"):
+            self._bad("no credentials for user", 404)
+            return
+
+        challenge = random_b64u(32)
+        self.state.pending_auth[username] = challenge
+        allow_credentials = [{"type": "public-key", "id": cid} for cid in user["credentials"]]
+        public_key = {
+            "challenge": challenge,
+            "rpId": self.state.rp_id,
+            "timeout": 60000,
+            "userVerification": "preferred",
+            "allowCredentials": allow_credentials,
+        }
+        self._json(200, {"ok": True, "publicKey": public_key})
+
+    def _auth_finish(self, data: dict[str, Any]) -> None:
+        username = str(data.get("username", "")).strip()
+        expected = self.state.pending_auth.get(username)
+        if not username or not expected:
+            self._bad("no pending authentication")
+            return
+
+        user = self.state.db.get("users", {}).get(username, {})
+        known = set(user.get("credentials", []))
+        raw_id = str(data.get("rawId", ""))
+        if raw_id not in known:
+            self._bad("unknown credential")
+            return
+
+        try:
+            client_data_raw = b64u_decode(data["response"]["clientDataJSON"])
+            client_data = json.loads(client_data_raw.decode("utf-8"))
+            challenge = client_data.get("challenge")
+            typ = client_data.get("type")
+            origin = client_data.get("origin")
+        except Exception:
+            self._bad("invalid assertion response")
+            return
+
+        if typ != "webauthn.get":
+            self._bad("unexpected clientData type")
+            return
+        if challenge != expected:
+            self._bad("challenge mismatch")
+            return
+        if origin != self.state.origin:
+            self._bad(f"origin mismatch: expected {self.state.origin}, got {origin}")
+            return
+
+        self.state.pending_auth.pop(username, None)
+        self._json(200, {"ok": True, "username": username, "authenticated": True})
+
+    def log_message(self, format: str, *args: Any) -> None:
+        return
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Local WebAuthn demo server")
+    parser.add_argument("--host", default="localhost")
+    parser.add_argument("--port", type=int, default=8765)
+    parser.add_argument("--rp-id", default="localhost")
+    parser.add_argument("--rp-name", default="ChromeCard Local Demo")
+    parser.add_argument("--origin", default="http://localhost:8765")
+    parser.add_argument("--db", default=".webauthn_demo_db.json")
+    args = parser.parse_args()
+
+    db_path = Path(args.db).resolve()
+    state = DemoState(db_path, rp_id=args.rp_id, rp_name=args.rp_name, origin=args.origin)
+    Handler.state = state
+
+    server = ThreadingHTTPServer((args.host, args.port), Handler)
+    print(f"WebAuthn demo listening on http://{args.host}:{args.port}")
+    print(f"RP ID: {args.rp_id}")
+    print(f"Origin: {args.origin}")
+    print(f"DB: {db_path}")
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())