From 9abf41fcd59ad416f5c676a785e41b3f62b95556 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Fri, 24 Apr 2026 05:38:00 +0200 Subject: [PATCH 01/24] Initialize workspace tracking repo --- .gitignore | 7 + Setup.md | 191 ++++++++++++++++++++ Workplan.md | 269 ++++++++++++++++++++++++++++ fido2_probe.py | 138 +++++++++++++++ webauthn_local_demo.py | 389 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 994 insertions(+) create mode 100644 .gitignore create mode 100644 Setup.md create mode 100644 Workplan.md create mode 100755 fido2_probe.py create mode 100644 webauthn_local_demo.py 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())

From d9e9e95b5fc0df85ddb41f029c270f66d1d90223 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Fri, 24 Apr 2026 05:38:21 +0200
Subject: [PATCH 02/24] Ignore local .codex marker

---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitignore b/.gitignore
index 1fdbb11..2c82249 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 # Local agent and transient artifacts
+.codex
 .codex/
 __pycache__/
 *.pyc

From 8888601f69ad44233e55ba8a71dfe1ab0c9555d2 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Fri, 24 Apr 2026 05:52:44 +0200
Subject: [PATCH 03/24] Record AppVM template and UI baseline

---
 Setup.md    | 8 +++++++-
 Workplan.md | 4 ++--
 2 files changed, 9 insertions(+), 3 deletions(-)

diff --git a/Setup.md b/Setup.md
index fd7fef5..376aaa0 100644
--- a/Setup.md
+++ b/Setup.md
@@ -44,7 +44,7 @@ Update this file whenever environment status or verified behavior changes.
 
 ## Target Qubes Topology
 
-- Base template for all AppVMs: Debian template.
+- Base template for all AppVMs: `debian-13-xfce`.
 - Allowed network paths:
   - `k_client` -> `k_proxy` over TLS
   - `k_proxy` -> `k_server` over TLS
@@ -68,6 +68,11 @@ Functional roles:
   - Provides a dummy protected resource for early integration testing (monotonic increasing number/counter).
   - May hold user/session state logic needed for authorization decisions.
 
+UI baseline for each AppVM (start-menu visible apps):
+- Firefox
+- XFCE Terminal
+- File Manager
+
 ## Target Request Flow
 
 1. `k_client` sends HTTPS request to `k_proxy`.
@@ -125,6 +130,7 @@ Implication:
 
 Session note (2026-04-24):
 - Markdown tracking was reviewed and normalized around `Setup.md` + `Workplan.md` as the active, continuously updated execution record.
+- AppVM template decision recorded: use `debian-13-xfce` for `k_client`, `k_proxy`, and `k_server`.
 
 ## Known FIDO2 Transport Boundary
 
diff --git a/Workplan.md b/Workplan.md
index ced9886..1d18c43 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -8,7 +8,7 @@ This is the execution plan for making ChromeCard FIDO2 development and validatio
 
 - 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`.
+- Target deployment model is Qubes OS with 3 AppVMs based on `debian-13-xfce`: `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
@@ -26,7 +26,7 @@ This is the execution plan for making ChromeCard FIDO2 development and validatio
 ## 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.
+- Ensure `k_client`, `k_proxy`, `k_server` exist and are based on `debian-13-xfce`.
 
 2. Assign functional responsibilities.
 - `k_client`: browser client + enrollment process.

From 4f58b838422520f73806c58b5aedbb441452f487 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Fri, 24 Apr 2026 06:02:47 +0200
Subject: [PATCH 04/24] Log Qubes libxenlight VM start blocker

---
 Setup.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/Setup.md b/Setup.md
index 376aaa0..b4e7e84 100644
--- a/Setup.md
+++ b/Setup.md
@@ -123,6 +123,7 @@ Thread-safety expectation:
   - 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.
+- Qubes VM provisioning is currently blocked: `libxenlight` failed to create domain `k_client`.
 
 Implication:
 - We cannot currently confirm live FIDO2 connectivity from this host.
@@ -131,6 +132,7 @@ Implication:
 Session note (2026-04-24):
 - Markdown tracking was reviewed and normalized around `Setup.md` + `Workplan.md` as the active, continuously updated execution record.
 - AppVM template decision recorded: use `debian-13-xfce` for `k_client`, `k_proxy`, and `k_server`.
+- VM start attempt failed with Xen toolstack error: `libxenlight have failed to create new domain 'k_client'`.
 
 ## Known FIDO2 Transport Boundary
 
@@ -185,6 +187,7 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06
 
 ## Open Gaps To Resolve
 
+- Why Xen/libxenlight cannot create the `k_client` domain on this host (memory/pool, stale domain, template, storage, or hypervisor state).
 - 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.

From b6dbbc483950a8dd122424fddd37321a6d6b0871 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Fri, 24 Apr 2026 06:08:43 +0200
Subject: [PATCH 05/24] Record AppVM startup success after memory tuning

---
 Setup.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Setup.md b/Setup.md
index b4e7e84..29fa5a2 100644
--- a/Setup.md
+++ b/Setup.md
@@ -123,7 +123,7 @@ Thread-safety expectation:
   - 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.
-- Qubes VM provisioning is currently blocked: `libxenlight` failed to create domain `k_client`.
+- Qubes AppVM baseline is now up: `k_client`, `k_proxy`, `k_server` can start and have terminals running.
 
 Implication:
 - We cannot currently confirm live FIDO2 connectivity from this host.
@@ -133,6 +133,7 @@ Session note (2026-04-24):
 - Markdown tracking was reviewed and normalized around `Setup.md` + `Workplan.md` as the active, continuously updated execution record.
 - AppVM template decision recorded: use `debian-13-xfce` for `k_client`, `k_proxy`, and `k_server`.
 - VM start attempt failed with Xen toolstack error: `libxenlight have failed to create new domain 'k_client'`.
+- VM start blocker was resolved by reducing VM memory to `400` MiB; all three AppVMs now start.
 
 ## Known FIDO2 Transport Boundary
 
@@ -187,7 +188,6 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06
 
 ## Open Gaps To Resolve
 
-- Why Xen/libxenlight cannot create the `k_client` domain on this host (memory/pool, stale domain, template, storage, or hypervisor state).
 - 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.

From 3dcac21dd0eeb3c5d08c7b065f53ce5c5a435b38 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Fri, 24 Apr 2026 06:48:52 +0200
Subject: [PATCH 06/24] Record successful WebAuthn register/login in k_proxy

---
 Setup.md    | 24 ++++++++++++++++++++----
 Workplan.md |  5 +++++
 2 files changed, 25 insertions(+), 4 deletions(-)

diff --git a/Setup.md b/Setup.md
index 29fa5a2..a97f2d9 100644
--- a/Setup.md
+++ b/Setup.md
@@ -115,9 +115,20 @@ Thread-safety expectation:
 
 ## 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`.
+- AppVM OS version is confirmed: Debian `13.4` (`k_server`, and same on `k_client`/`k_proxy`).
+- Python in AppVMs is available: `Python 3.13.5`.
+- `python3 /home/user/chromecard/fido2_probe.py --list` in `k_proxy` now detects ChromeCard on `/dev/hidraw0` (`vid:pid=4617:5`).
+- HID raw device nodes are now visible in `k_proxy`:
+  - `/dev/hidraw0` -> `crw-rw----+`
+  - `/dev/hidraw1` -> `crw-------`
+- `python3 /home/user/chromecard/fido2_probe.py --json` succeeds and returns CTAP2 `getInfo`:
+  - versions: `["FIDO_2_0"]`
+  - aaguid: `1234567890abcdef0123456789abcdef`
+  - options: `rk=false`, `up=true`, `uv=true`
+  - max_msg_size: `1024`
+- Local WebAuthn demo (`http://localhost:8765` in `k_proxy`) succeeded:
+  - register: `ok=true`, `username=alice`, `credential_count=1`
+  - login/auth: `ok=true`, `username=alice`, `authenticated=true`
 - `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`
@@ -126,7 +137,8 @@ Thread-safety expectation:
 - Qubes AppVM baseline is now up: `k_client`, `k_proxy`, `k_server` can start and have terminals running.
 
 Implication:
-- We cannot currently confirm live FIDO2 connectivity from this host.
+- Live FIDO2 connectivity from `k_proxy` to ChromeCard is confirmed over USB HID/CTAPHID.
+- Local browser WebAuthn register/login flow is confirmed working in `k_proxy`.
 - We cannot currently run the documented firmware build/flash flow.
 
 Session note (2026-04-24):
@@ -134,6 +146,10 @@ Session note (2026-04-24):
 - AppVM template decision recorded: use `debian-13-xfce` for `k_client`, `k_proxy`, and `k_server`.
 - VM start attempt failed with Xen toolstack error: `libxenlight have failed to create new domain 'k_client'`.
 - VM start blocker was resolved by reducing VM memory to `400` MiB; all three AppVMs now start.
+- Runtime check from VMs: Debian `13.4` and Python `3.13.5`; `k_proxy` still shows `no hidraw devices`.
+- After USB assignment to `k_proxy`, `/dev/hidraw0` and `/dev/hidraw1` appeared.
+- CTAP probe re-run succeeded with detected ChromeCard device and valid CTAP2 `getInfo` response.
+- Local WebAuthn demo completed successfully for user `alice` (register + login).
 
 ## Known FIDO2 Transport Boundary
 
diff --git a/Workplan.md b/Workplan.md
index 1d18c43..0c3e3b8 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -129,6 +129,11 @@ Exit criteria:
 Exit criteria:
 - Register and login both complete with card interaction prompts.
 
+Status (2026-04-24):
+- Completed in `k_proxy` using `http://localhost:8765`.
+- Registration result: `ok=true`, `username=alice`, `credential_count=1`.
+- Authentication result: `ok=true`, `username=alice`, `authenticated=true`.
+
 ## Phase 5: Implement Proxy Auth + Session Reuse
 
 1. Authenticate via card once per session window.

From 37600548ace32ce484ac396655636093302d6601 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Fri, 24 Apr 2026 10:30:40 +0200
Subject: [PATCH 07/24] Start Phase 5 proxy/server session reuse prototype

---
 PHASE5_RUNBOOK.md |  80 ++++++++++++
 Setup.md          |  10 +-
 Workplan.md       |  12 ++
 k_proxy_app.py    | 306 ++++++++++++++++++++++++++++++++++++++++++++++
 k_server_app.py   | 106 ++++++++++++++++
 5 files changed, 510 insertions(+), 4 deletions(-)
 create mode 100644 PHASE5_RUNBOOK.md
 create mode 100644 k_proxy_app.py
 create mode 100644 k_server_app.py

diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md
new file mode 100644
index 0000000..81dbd79
--- /dev/null
+++ b/PHASE5_RUNBOOK.md
@@ -0,0 +1,80 @@
+# Phase 5 Runbook (Session Reuse Prototype)
+
+This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse testing.
+
+## What This Prototype Covers
+
+- `k_proxy` creates short-lived sessions.
+- Session creation uses a card-presence check (`fido2_probe.py --json`) as the current auth gate.
+- Valid sessions can repeatedly access a protected `k_server` counter endpoint without re-running card auth each request.
+- Session status and logout/invalidation paths are implemented.
+
+## Start Services
+
+In `k_server` VM:
+
+```bash
+python3 /home/user/chromecard/k_server_app.py --host 127.0.0.1 --port 8780 --proxy-token dev-proxy-token
+```
+
+In `k_proxy` VM:
+
+```bash
+python3 /home/user/chromecard/k_proxy_app.py \
+  --host 127.0.0.1 \
+  --port 8770 \
+  --session-ttl 300 \
+  --server-base-url http://127.0.0.1:8780 \
+  --proxy-token dev-proxy-token
+```
+
+## Test Flow
+
+Create a session (runs auth gate once):
+
+```bash
+curl -sS -X POST http://127.0.0.1:8770/session/login \
+  -H 'Content-Type: application/json' \
+  -d '{"username":"alice"}'
+```
+
+Copy `session_token` from response, then:
+
+```bash
+TOKEN=''
+```
+
+Check session:
+
+```bash
+curl -sS -X POST http://127.0.0.1:8770/session/status \
+  -H "Authorization: Bearer $TOKEN"
+```
+
+Call protected resource multiple times (should not require new login):
+
+```bash
+curl -sS -X POST http://127.0.0.1:8770/resource/counter \
+  -H "Authorization: Bearer $TOKEN"
+curl -sS -X POST http://127.0.0.1:8770/resource/counter \
+  -H "Authorization: Bearer $TOKEN"
+```
+
+Logout/invalidate:
+
+```bash
+curl -sS -X POST http://127.0.0.1:8770/session/logout \
+  -H "Authorization: Bearer $TOKEN"
+```
+
+Re-check after logout (should fail with 401):
+
+```bash
+curl -i -X POST http://127.0.0.1:8770/resource/counter \
+  -H "Authorization: Bearer $TOKEN"
+```
+
+## Current Limitation
+
+- 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.
diff --git a/Setup.md b/Setup.md
index a97f2d9..715b521 100644
--- a/Setup.md
+++ b/Setup.md
@@ -129,6 +129,10 @@ Thread-safety expectation:
 - Local WebAuthn demo (`http://localhost:8765` in `k_proxy`) succeeded:
   - register: `ok=true`, `username=alice`, `credential_count=1`
   - login/auth: `ok=true`, `username=alice`, `authenticated=true`
+- Phase 5 prototype services are now available:
+  - `/home/user/chromecard/k_proxy_app.py`
+  - `/home/user/chromecard/k_server_app.py`
+  - `/home/user/chromecard/PHASE5_RUNBOOK.md`
 - `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`
@@ -150,6 +154,7 @@ Session note (2026-04-24):
 - After USB assignment to `k_proxy`, `/dev/hidraw0` and `/dev/hidraw1` appeared.
 - CTAP probe re-run succeeded with detected ChromeCard device and valid CTAP2 `getInfo` response.
 - Local WebAuthn demo completed successfully for user `alice` (register + login).
+- Phase 5 starter implementation added with session TTL, logout/invalidation, and proxy->server protected counter forwarding.
 
 ## Known FIDO2 Transport Boundary
 
@@ -204,13 +209,10 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06
 
 ## 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.
+- Upgrade Phase 5 auth gate from card-presence probe to full WebAuthn assertion verification for session creation.
 - 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
index 0c3e3b8..6f624f0 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -153,6 +153,18 @@ Exit criteria:
 - Repeated authorized requests do not require card interaction until session expiry.
 - Expired/invalid sessions are correctly rejected.
 
+Status (2026-04-24):
+- Started with a runnable prototype:
+  - `/home/user/chromecard/k_proxy_app.py`
+  - `/home/user/chromecard/k_server_app.py`
+  - `/home/user/chromecard/PHASE5_RUNBOOK.md`
+- Implemented in prototype:
+  - session create/status/logout endpoints in `k_proxy`
+  - TTL-based server-side session store with expiry garbage collection
+  - protected monotonic counter endpoint in `k_server` with thread-safe increments
+  - proxy forwarding from `k_proxy` to `k_server` using a shared upstream token
+- Current auth gate for session creation is card-presence probe (`fido2_probe.py --json`), pending upgrade to full assertion verification path.
+
 ## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server`
 
 1. Protected dummy resource.
diff --git a/k_proxy_app.py b/k_proxy_app.py
new file mode 100644
index 0000000..ce0a79a
--- /dev/null
+++ b/k_proxy_app.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""
+Minimal k_proxy service for Phase 5 bring-up.
+
+Behavior:
+- Creates short-lived sessions after a card-presence check.
+- Reuses valid sessions to access k_server protected counter endpoint.
+- Supports session status and logout.
+
+Notes:
+- Session login uses `fido2_probe.py --json` command success as auth gate for now.
+- This is a Phase 5 starter and not a final production auth design.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import secrets
+import subprocess
+import threading
+import time
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import Any
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
+from urllib.request import Request, urlopen
+
+
+@dataclass
+class Session:
+    username: str
+    expires_at: float
+
+
+class ProxyState:
+    def __init__(
+        self,
+        session_ttl_s: int,
+        auth_command: str,
+        server_base_url: str,
+        proxy_token: str,
+    ):
+        self.session_ttl_s = session_ttl_s
+        self.auth_command = auth_command
+        self.server_base_url = server_base_url.rstrip("/")
+        self.proxy_token = proxy_token
+        self.lock = threading.Lock()
+        self.sessions: dict[str, Session] = {}
+
+    def _now(self) -> float:
+        return time.time()
+
+    def _gc_locked(self) -> None:
+        now = self._now()
+        dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now]
+        for token in dead:
+            del self.sessions[token]
+
+    def create_session(self, username: str) -> tuple[str, float]:
+        token = secrets.token_urlsafe(32)
+        now = self._now()
+        expires_at = now + self.session_ttl_s
+        with self.lock:
+            self._gc_locked()
+            self.sessions[token] = Session(username=username, expires_at=expires_at)
+        return token, expires_at
+
+    def get_session(self, token: str) -> Session | None:
+        with self.lock:
+            self._gc_locked()
+            return self.sessions.get(token)
+
+    def invalidate_session(self, token: str) -> bool:
+        with self.lock:
+            return self.sessions.pop(token, None) is not None
+
+    def active_session_count(self) -> int:
+        with self.lock:
+            self._gc_locked()
+            return len(self.sessions)
+
+    def authenticate_with_card(self) -> tuple[bool, str]:
+        try:
+            proc = subprocess.run(
+                self.auth_command,
+                shell=True,
+                capture_output=True,
+                text=True,
+                timeout=10,
+                check=False,
+            )
+        except Exception as exc:
+            return False, f"auth command failed: {exc}"
+
+        if proc.returncode != 0:
+            stderr = proc.stderr.strip()
+            stdout = proc.stdout.strip()
+            details = stderr if stderr else stdout
+            return False, details or f"auth command exit code {proc.returncode}"
+
+        return True, "card presence check succeeded"
+
+    def fetch_counter(self) -> tuple[int, dict[str, Any]]:
+        url = f"{self.server_base_url}/resource/counter"
+        req = Request(url, method="POST")
+        req.add_header("X-Proxy-Token", self.proxy_token)
+        req.add_header("Content-Type", "application/json")
+        body = b"{}"
+        try:
+            with urlopen(req, data=body, timeout=5) as resp:
+                data = json.loads(resp.read().decode("utf-8"))
+                return resp.status, data
+        except HTTPError as exc:
+            try:
+                data = json.loads(exc.read().decode("utf-8"))
+            except Exception:
+                data = {"ok": False, "error": f"server http error {exc.code}"}
+            return exc.code, data
+        except URLError as exc:
+            return 502, {"ok": False, "error": f"server unavailable: {exc.reason}"}
+        except Exception as exc:
+            return 502, {"ok": False, "error": f"server call failed: {exc}"}
+
+
+class Handler(BaseHTTPRequestHandler):
+    state: ProxyState
+
+    def _json(self, status: int, payload: dict[str, Any]) -> None:
+        body = json.dumps(payload).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 _read_json(self) -> dict[str, Any]:
+        length = int(self.headers.get("Content-Length", "0"))
+        raw = self.rfile.read(length)
+        if not raw:
+            return {}
+        return json.loads(raw.decode("utf-8"))
+
+    def _bearer_token(self) -> str | None:
+        value = self.headers.get("Authorization", "")
+        if not value.startswith("Bearer "):
+            return None
+        token = value[7:].strip()
+        return token or None
+
+    def _require_session(self) -> tuple[str, Session] | None:
+        token = self._bearer_token()
+        if not token:
+            self._json(401, {"ok": False, "error": "missing bearer token"})
+            return None
+        session = self.state.get_session(token)
+        if not session:
+            self._json(401, {"ok": False, "error": "invalid or expired session"})
+            return None
+        return token, session
+
+    def do_GET(self) -> None:  # noqa: N802
+        path = urlparse(self.path).path
+        if path == "/health":
+            self._json(
+                200,
+                {
+                    "ok": True,
+                    "service": "k_proxy",
+                    "active_sessions": self.state.active_session_count(),
+                    "time": int(time.time()),
+                },
+            )
+            return
+        self.send_error(404)
+
+    def do_POST(self) -> None:  # noqa: N802
+        path = urlparse(self.path).path
+        if path == "/session/login":
+            self._session_login()
+            return
+        if path == "/session/status":
+            self._session_status()
+            return
+        if path == "/session/logout":
+            self._session_logout()
+            return
+        if path == "/resource/counter":
+            self._resource_counter()
+            return
+        self.send_error(404)
+
+    def _session_login(self) -> None:
+        try:
+            data = self._read_json()
+        except Exception:
+            self._json(400, {"ok": False, "error": "invalid json"})
+            return
+
+        username = str(data.get("username", "")).strip()
+        if not username:
+            self._json(400, {"ok": False, "error": "username required"})
+            return
+
+        ok, message = self.state.authenticate_with_card()
+        if not ok:
+            self._json(401, {"ok": False, "error": "card auth failed", "details": message})
+            return
+
+        token, expires_at = self.state.create_session(username)
+        self._json(
+            200,
+            {
+                "ok": True,
+                "username": username,
+                "session_token": token,
+                "expires_at": int(expires_at),
+                "ttl_seconds": self.state.session_ttl_s,
+                "auth_mode": "card_presence_probe",
+            },
+        )
+
+    def _session_status(self) -> None:
+        got = self._require_session()
+        if not got:
+            return
+        _, session = got
+        self._json(
+            200,
+            {
+                "ok": True,
+                "username": session.username,
+                "expires_at": int(session.expires_at),
+                "seconds_remaining": max(0, int(session.expires_at - time.time())),
+            },
+        )
+
+    def _session_logout(self) -> None:
+        token = self._bearer_token()
+        if not token:
+            self._json(401, {"ok": False, "error": "missing bearer token"})
+            return
+        removed = self.state.invalidate_session(token)
+        self._json(200, {"ok": True, "invalidated": removed})
+
+    def _resource_counter(self) -> None:
+        got = self._require_session()
+        if not got:
+            return
+        _, session = got
+        status, upstream = self.state.fetch_counter()
+        if status != 200:
+            self._json(status, {"ok": False, "error": "upstream failed", "upstream": upstream})
+            return
+        self._json(
+            200,
+            {
+                "ok": True,
+                "username": session.username,
+                "session_reused": True,
+                "upstream": upstream,
+            },
+        )
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Run k_proxy session gateway")
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=8770)
+    parser.add_argument("--session-ttl", type=int, default=300, help="Session TTL in seconds")
+    parser.add_argument(
+        "--auth-command",
+        default="python3 /home/user/chromecard/fido2_probe.py --json",
+        help="Command used for session creation auth gate",
+    )
+    parser.add_argument(
+        "--server-base-url",
+        default="http://127.0.0.1:8780",
+        help="Base URL for k_server",
+    )
+    parser.add_argument(
+        "--proxy-token",
+        default="dev-proxy-token",
+        help="Shared token to authorize requests to k_server",
+    )
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    state = ProxyState(
+        session_ttl_s=args.session_ttl,
+        auth_command=args.auth_command,
+        server_base_url=args.server_base_url,
+        proxy_token=args.proxy_token,
+    )
+    Handler.state = state
+    server = ThreadingHTTPServer((args.host, args.port), Handler)
+    print(f"k_proxy listening on http://{args.host}:{args.port}")
+    server.serve_forever()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/k_server_app.py b/k_server_app.py
new file mode 100644
index 0000000..2a8e7c9
--- /dev/null
+++ b/k_server_app.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""
+Minimal k_server service for Phase 5/5.5 bring-up.
+
+Behavior:
+- Exposes a protected monotonic counter endpoint.
+- Accepts only requests from k_proxy via a shared proxy token header.
+- Uses thread-safe counter increments.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import Any
+from urllib.parse import urlparse
+
+
+class ServerState:
+    def __init__(self, proxy_token: str):
+        self.proxy_token = proxy_token
+        self.counter = 0
+        self.lock = threading.Lock()
+
+    def next_counter(self) -> int:
+        with self.lock:
+            self.counter += 1
+            return self.counter
+
+
+class Handler(BaseHTTPRequestHandler):
+    state: ServerState
+
+    def _json(self, status: int, payload: dict[str, Any]) -> None:
+        body = json.dumps(payload).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 _is_proxy_authorized(self) -> bool:
+        return self.headers.get("X-Proxy-Token") == self.state.proxy_token
+
+    def do_GET(self) -> None:  # noqa: N802
+        path = urlparse(self.path).path
+        if path == "/health":
+            self._json(
+                200,
+                {
+                    "ok": True,
+                    "service": "k_server",
+                    "time": int(time.time()),
+                },
+            )
+            return
+        self.send_error(404)
+
+    def do_POST(self) -> None:  # noqa: N802
+        path = urlparse(self.path).path
+        if path != "/resource/counter":
+            self.send_error(404)
+            return
+        if not self._is_proxy_authorized():
+            self._json(401, {"ok": False, "error": "unauthorized proxy"})
+            return
+
+        value = self.state.next_counter()
+        self._json(
+            200,
+            {
+                "ok": True,
+                "resource": "counter",
+                "value": value,
+                "time": int(time.time()),
+            },
+        )
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Run k_server counter service")
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=8780)
+    parser.add_argument(
+        "--proxy-token",
+        default="dev-proxy-token",
+        help="Shared token expected in X-Proxy-Token from k_proxy",
+    )
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    state = ServerState(proxy_token=args.proxy_token)
+    Handler.state = state
+    server = ThreadingHTTPServer((args.host, args.port), Handler)
+    print(f"k_server listening on http://{args.host}:{args.port}")
+    server.serve_forever()
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

From 6db7a7e217f831be79afaec979728ae3fe39f1a1 Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Sat, 25 Apr 2026 01:12:47 +0200
Subject: [PATCH 08/24] Update Qubes chain status docs

---
 Setup.md    | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 Workplan.md |  92 +++++++++++++++++++++++++++++++
 2 files changed, 244 insertions(+)

diff --git a/Setup.md b/Setup.md
index 715b521..2f0123c 100644
--- a/Setup.md
+++ b/Setup.md
@@ -133,6 +133,10 @@ Thread-safety expectation:
   - `/home/user/chromecard/k_proxy_app.py`
   - `/home/user/chromecard/k_server_app.py`
   - `/home/user/chromecard/PHASE5_RUNBOOK.md`
+- Remote VM access is now available via SSH/SCP aliases:
+  - command execution: `ssh  `
+  - file copy to VM home: `scp  :~`
+  - validated hosts: `k_client`, `k_proxy`, `k_server`
 - `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`
@@ -156,6 +160,154 @@ Session note (2026-04-24):
 - Local WebAuthn demo completed successfully for user `alice` (register + login).
 - Phase 5 starter implementation added with session TTL, logout/invalidation, and proxy->server protected counter forwarding.
 
+Session note (2026-04-24, doc maintenance):
+- Top-level Markdown files were re-scanned: `PHASE5_RUNBOOK.md`, `Setup.md`, `Workplan.md`.
+- `PHASE5_RUNBOOK.md` remains consistent with the current Phase 5 prototype paths and flow.
+- No plan/setup drift was found requiring behavioral changes; docs remain aligned.
+- SSH-based VM operation was validated for `k_client`, `k_proxy`, `k_server` (Debian `13.4` confirmed remotely).
+- SCP file transfer to `k_proxy` home directory was validated with read-back.
+
+Session note (2026-04-24, remote flow diagnostics):
+- VM script staging gap found: `/home/user/chromecard/k_proxy_app.py`, `k_server_app.py`, and helper files were missing on AppVMs and were copied via `scp`.
+- Services were started in VMs and verified locally:
+  - `k_proxy` local health OK on `127.0.0.1:8770` and `127.0.0.1:8771`
+  - `k_server` local health OK on `127.0.0.1:8780`
+- Verified VM IPs during this run:
+  - `k_proxy`: `10.137.0.12`
+  - `k_server`: `10.137.0.13`
+  - `k_client`: `10.137.0.16`
+- Current chain failure is network pathing/firewall:
+- `k_client -> k_proxy` (`10.137.0.12:8771`) times out.
+- `k_proxy -> k_server` (`10.137.0.13:8780`) times out.
+- Proxy returns upstream error payload: `server unavailable: timed out`.
+
+Session note (2026-04-24, markdown re-scan):
+- Re-read top-level workspace Markdown files: `Setup.md`, `Workplan.md`, `PHASE5_RUNBOOK.md`.
+- Re-skimmed source-tree reference docs in `CR_SDK_CK-main`, including `BUILD.md`, `README.md`, `README_HOST.md`, `RELEASE.md`, and `distribute_bundle.md`.
+- Current workspace docs remain aligned with the verified execution record.
+- Source-tree doc drift remains unchanged:
+  - `README_HOST.md` still points to `./scripts/fido2_probe.py` and `./scripts/webauthn_local_demo.py`.
+  - Active workspace policy continues to treat those paths as historical; maintained helper paths remain `/home/user/chromecard/fido2_probe.py` and `/home/user/chromecard/webauthn_local_demo.py`.
+- Source-tree build docs continue to describe a full SDK layout with `mvp`, `setup`, `components`, and `samples`, which is still not present in the current local checkout snapshot.
+
+Session note (2026-04-24, policy retry):
+- Markdown re-scan was retried after local policy changes.
+- Re-running the workspace doc scan with a non-login shell completed cleanly, without the earlier SSH/socat startup noise in command output.
+
+Session note (2026-04-24, chain probe retry):
+- Re-probed the Qubes access path for `k_client -> k_proxy -> k_server`.
+- Local forwarded SSH listener ports still exist on the host:
+  - `0.0.0.0:2222` -> `qrexec-client-vm 'k_client' qubes.ConnectTCP+22`
+  - `0.0.0.0:2223` -> `qrexec-client-vm 'k_proxy' qubes.ConnectTCP+22`
+  - `0.0.0.0:2224` -> `qrexec-client-vm 'k_server' qubes.ConnectTCP+22`
+- These forwarded SSH ports currently fail immediately:
+  - `ssh k_client` / `ssh k_proxy` / `ssh k_server` close immediately on localhost forwarded ports.
+  - Direct `qrexec-client-vm  qubes.ConnectTCP+22` returns `Request refused`.
+- Chain ports are currently blocked at the same qrexec layer:
+  - `qrexec-client-vm k_proxy qubes.ConnectTCP+8770` -> `Request refused`
+  - `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
+- This means the current blocker is active qrexec policy/service refusal for `qubes.ConnectTCP`, not the Python service code in `k_proxy_app.py` or `k_server_app.py`.
+- Separate SSH config issue remains on the host:
+  - `/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` is still owned `root:root` but mode `777`, which causes OpenSSH to reject it as insecure on the normal login-shell path.
+
+Session note (2026-04-25, post-restart probe):
+- Correct client-facing proxy port is `8771` for the current split-VM chain checks.
+- SSH to `k_proxy` is working again.
+- `k_proxy` card visibility is restored after VM restart and card reconnect:
+  - `/dev/hidraw0` and `/dev/hidraw1` are present in `k_proxy`
+- Current service state after restart:
+  - `k_proxy` has no listener on `127.0.0.1:8771`
+  - `k_server` has no listener on `127.0.0.1:8780`
+- Current qrexec chain state after restart:
+  - `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
+  - `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
+- Practical meaning:
+  - SSH and card attachment recovered
+  - phase-5 app services are not currently running in the VMs
+  - qrexec forwarding for the chain ports is still being refused
+
+Session note (2026-04-25, service restart):
+- `k_server_app.py` was restarted successfully in `k_server`:
+  - PID `1320`
+  - listening on `127.0.0.1:8780`
+  - `/health` returns `{"ok": true, "service": "k_server", ...}`
+- `k_proxy_app.py` was restarted successfully in `k_proxy`:
+  - PID `2774`
+  - listening on `127.0.0.1:8771`
+  - `/health` returns `{"ok": true, "service": "k_proxy", "active_sessions": 0, ...}`
+- Despite local service recovery, qrexec forwarding is still denied:
+  - `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
+  - `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
+
+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`.
+- Forwarders start and bind locally:
+  - in `k_client`: `qvm-connect-tcp 8771:k_proxy:8771` binds `localhost:8771`
+  - in `k_proxy`: `qvm-connect-tcp 8780:k_server:8780` binds `localhost:8780`
+- But the actual client->proxy connection is still refused when used:
+  - `k_client` forward log shows `Request refused`
+  - `socat` reports child exit status `126` and `Connection reset by peer`
+- Local login on `k_proxy` reaches the app but fails on the auth dependency:
+  - `POST /session/login` to `http://127.0.0.1:8771` returns `401`
+  - details: `Missing dependency: python-fido2 ... No module named 'fido2'`
+- `k_server` was not reached during this login test; current `k_server.log` only shows `/health`.
+
+Session note (2026-04-25, after python3-fido2 install):
+- `k_proxy` was restarted after `python3-fido2` installation and now listens again on `127.0.0.1:8771`.
+- The previous Python import blocker is resolved; local login now reaches the CTAP probe path.
+- Current local login result on `k_proxy`:
+  - `{"ok": false, "error": "card auth failed", "details": "No CTAP HID devices found."}`
+- Current forwarded login result from `k_client` is still not completing:
+  - `curl http://127.0.0.1:8771/session/login` -> `Empty reply from server`
+  - `qvm_connect_8771.log` still shows repeated `Request refused` and child exit status `126`
+- Practical meaning:
+  - Python dependency issue in `k_proxy` is fixed
+  - card access inside `k_proxy` is currently missing again at CTAP/HID level
+  - `k_client -> k_proxy` qrexec forwarding is still effectively denied/refused
+
+Session note (2026-04-25, card reattached):
+- Card visibility in `k_proxy` is restored again:
+  - `/dev/hidraw0` and `/dev/hidraw1` present
+  - `fido2_probe.py --list` detects ChromeCard on `/dev/hidraw0`
+- Local login on `k_proxy` now succeeds again:
+  - `POST /session/login` on `127.0.0.1:8771` returns `200`
+  - session creation for user `alice` succeeded
+- Remaining failure is isolated to the client-facing qrexec path:
+  - `k_client` -> `localhost:8771` through `qvm-connect-tcp` still returns `Empty reply from server`
+  - `qvm_connect_8771.log` still shows `Request refused`
+
+Session note (2026-04-25, clean forward retest):
+- Re-ran both forwards and exercised each hop immediately after local bind.
+- `k_proxy -> k_server`:
+  - `qvm-connect-tcp 8780:k_server:8780` binds `localhost:8780` in `k_proxy`
+  - first real `POST /resource/counter` through that forward returns `Empty reply from server`
+  - `qvm_connect_8780.log` then records `Request refused` with child exit status `126`
+- `k_client -> k_proxy`:
+  - `qvm-connect-tcp 8771:k_proxy:8771` binds `localhost:8771` in `k_client`
+  - first real `POST /session/login` through that forward returns `Empty reply from server`
+  - `qvm_connect_8771.log` records `Request refused` with child exit status `126`
+- Conclusion from this retest:
+  - both forwards fail in the same way
+  - local bind succeeds, but the actual qrexec `qubes.ConnectTCP` request is refused when the first connection is attempted
+
+Session note (2026-04-25, dom0 policy fix validated):
+- After changing dom0 policy to use explicit destination VMs instead of `@default` for `qubes.ConnectTCP`, both forwards now work.
+- Verified hop 1:
+  - in `k_proxy`, `POST http://127.0.0.1:8780/resource/counter` with `X-Proxy-Token: dev-proxy-token` succeeds
+  - response included counter value `1`
+- Verified hop 2:
+  - in `k_client`, `POST http://127.0.0.1:8771/session/login` succeeds
+  - session token is returned through the `k_client -> k_proxy` forward
+- Verified full end-to-end flow from `k_client`:
+  - login succeeded and returned session token
+  - `POST /session/status` succeeded
+  - `POST /resource/counter` succeeded twice with upstream values `2` and `3`
+  - `POST /session/logout` succeeded
+  - post-logout `POST /resource/counter` correctly returned `401 invalid or expired session`
+- Current conclusion:
+  - `k_client -> k_proxy -> k_server` chain is operational
+  - session reuse and logout behavior are working in the current prototype
+
 ## Known FIDO2 Transport Boundary
 
 - FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.
diff --git a/Workplan.md b/Workplan.md
index 6f624f0..d5f1b78 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -10,6 +10,7 @@ This is the execution plan for making ChromeCard FIDO2 development and validatio
 - 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 AppVMs based on `debian-13-xfce`: `k_client`, `k_proxy`, `k_server`.
 - Current authenticator link is card->`k_proxy` (USB), but architecture must allow migration to wireless phone-mediated validation.
+- VM execution path is SSH-first for experiments: `ssh  ` and `scp  :~`.
 
 ## Goals
 
@@ -58,6 +59,13 @@ Exit criteria:
 Exit criteria:
 - Policy matches intended chain and is test-verified.
 
+Status (2026-04-24, remote diagnostics):
+- Confirmed active blocker remains Phase 1 network policy/pathing.
+- Evidence from live VM probes:
+  - `k_client (10.137.0.16) -> k_proxy (10.137.0.12:8771)`: TCP timeout.
+  - `k_proxy (10.137.0.12) -> k_server (10.137.0.13:8780)`: upstream timeout.
+- Local service health inside each VM is good, so failure is inter-VM reachability, not local process startup.
+
 ## Phase 2: TLS Certificates and Service Endpoints
 
 1. Certificate model.
@@ -251,6 +259,79 @@ Exit criteria:
 - Re-scan relevant `.md` files before each new execution cycle and reconcile drift.
 - Record date-stamped session notes when priorities or blockers change.
 
+Status (2026-04-24, markdown maintenance):
+- Re-scanned the active workspace Markdown set and the main source-tree reference docs.
+- No workplan phase change was required from this pass.
+- Ongoing documentation watch item remains path drift in `CR_SDK_CK-main/README_HOST.md`, which still uses historical `./scripts/...` helper locations instead of workspace-root helper paths.
+- Operational note: the markdown scan path now runs cleanly after policy adjustment when invoked without a login shell.
+
+Status (2026-04-24, chain probe retry):
+- Phase 1 remains blocked, but the failure point is now narrowed further:
+  - current refusal occurs at Qubes `qubes.ConnectTCP` policy/service evaluation for ports `22`, `8770`, and `8780`
+  - this happens before any end-to-end app-level request can be retried
+- Practical implication:
+  - do not spend time on `k_proxy_app.py` / `k_server_app.py` request handling until qrexec forwarding is permitting the intended hops again
+  - next recovery action is to fix/activate the relevant Qubes `qubes.ConnectTCP` policy and then re-run the qrexec bridge checks before testing HTTP flow
+
+Status (2026-04-25, post-restart probe):
+- Corrected the client-facing proxy port reference to `8771`.
+- SSH access to `k_proxy` and card visibility recovered after VM restart.
+- New immediate blockers are:
+  - `k_proxy` service not listening on `127.0.0.1:8771`
+  - `k_server` service not listening on `127.0.0.1:8780`
+  - qrexec forwarding for `8771` and `8780` still returns `Request refused`
+- Next retry should start services first, then re-test qrexec forwarding and only then attempt end-to-end client flow.
+
+Status (2026-04-25, service restart):
+- Local VM services are running again on the intended loopback ports:
+  - `k_server`: `127.0.0.1:8780`
+  - `k_proxy`: `127.0.0.1:8771`
+- Phase 1 remains blocked specifically by qrexec policy/forwarding refusal on those ports.
+- Next action is no longer app startup; it is fixing the `qubes.ConnectTCP` allow path for `8771` and `8780`.
+
+Status (2026-04-25, in-VM forwarding test):
+- Verified that using `qvm-connect-tcp` inside the source VMs still does not complete the client->proxy hop:
+  - bind succeeds locally, but first real connection gets `Request refused`
+- Independent app-layer blocker also found in `k_proxy`:
+  - `python-fido2` is missing there, so local `/session/login` currently fails before card auth can succeed
+- Current ordered blockers:
+  - first: effective Qubes/qrexec allow path for `k_client -> k_proxy:8771`
+  - second: install `python-fido2` in `k_proxy`
+  - third: re-test end-to-end login and then proxy->server counter flow
+
+Status (2026-04-25, after python3-fido2 install):
+- `python3-fido2` blocker in `k_proxy` is resolved.
+- Updated ordered blockers:
+  - first: effective Qubes/qrexec allow path for `k_client -> k_proxy:8771`
+  - second: restore CTAP HID device visibility/access in `k_proxy` (`No CTAP HID devices found`)
+  - third: re-test end-to-end login and then proxy->server counter flow
+
+Status (2026-04-25, card reattached):
+- CTAP HID visibility/access in `k_proxy` is restored.
+- Local proxy login is working again with the attached card.
+- The only currently confirmed blocker for the end-to-end path is the `k_client -> k_proxy:8771` qrexec/`qvm-connect-tcp` refusal.
+
+Status (2026-04-25, clean forward retest):
+- The retest shows the same qrexec failure mode on both hops, not just the client-facing one.
+- Updated blocker statement:
+  - effective `qubes.ConnectTCP` allow path is failing for both
+    - `k_client -> k_proxy:8771`
+    - `k_proxy -> k_server:8780`
+- App services and card path are currently good; forwarding remains the single active system blocker.
+
+Status (2026-04-25, dom0 policy fix validated):
+- The explicit-destination dom0 `qubes.ConnectTCP` policy fix resolved forwarding on both hops.
+- Current verified working chain:
+  - `k_client -> k_proxy:8771`
+  - `k_proxy -> k_server:8780`
+- Current verified prototype behavior:
+  - session login works from `k_client`
+  - session status works
+  - protected counter flow reaches `k_server`
+  - session reuse avoids re-login for repeated counter calls
+  - logout invalidates the session and subsequent protected access returns `401`
+- Immediate networking blocker is cleared.
+
 Exit criteria:
 - New team member can follow docs end-to-end without path or tooling ambiguity.
 
@@ -284,3 +365,14 @@ Exit criteria:
 - 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).
+
+## Session Maintenance Notes (2026-04-24)
+
+- Top-level Markdown review completed for `PHASE5_RUNBOOK.md`, `Setup.md`, and `Workplan.md`.
+- Current execution plan remains in sync with the Phase 5 runbook:
+  - prototype services at `/home/user/chromecard/k_proxy_app.py` and `/home/user/chromecard/k_server_app.py`
+  - run sequence documented in `/home/user/chromecard/PHASE5_RUNBOOK.md`
+- No phase ordering or blocker changes were required from this review pass.
+- Remote execution support is now active and validated:
+  - `ssh` command execution works for `k_client`, `k_proxy`, `k_server`
+  - `scp` push to VM home works (validated on `k_proxy`)

From 4b0b126bf921df454843ac4f7d41dcb6b8c1692d Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Sat, 25 Apr 2026 01:29:37 +0200
Subject: [PATCH 09/24] Add Phase 2 HTTPS prototype and runbook updates

---
 .gitignore               |   1 +
 PHASE5_RUNBOOK.md        |  82 ++++++++++++++++++--
 Setup.md                 |  40 +++++++++-
 Workplan.md              |  39 +++++++++-
 generate_phase2_certs.py | 157 +++++++++++++++++++++++++++++++++++++++
 k_proxy_app.py           |  29 +++++++-
 k_server_app.py          |  15 +++-
 7 files changed, 350 insertions(+), 13 deletions(-)
 create mode 100644 generate_phase2_certs.py

diff --git a/.gitignore b/.gitignore
index 2c82249..abaa3f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 .codex/
 __pycache__/
 *.pyc
+tls/
 
 # Keep firmware SDK tree out of this workspace-tracking repo
 CR_SDK_CK-main/
diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md
index 81dbd79..17e90fb 100644
--- a/PHASE5_RUNBOOK.md
+++ b/PHASE5_RUNBOOK.md
@@ -2,6 +2,8 @@
 
 This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse testing.
 
+Last updated: 2026-04-25
+
 ## What This Prototype Covers
 
 - `k_proxy` creates short-lived sessions.
@@ -9,15 +11,26 @@ This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse
 - Valid sessions can repeatedly access a protected `k_server` counter endpoint without re-running card auth each request.
 - Session status and logout/invalidation paths are implemented.
 
+## Modes
+
+There are two useful ways to run this prototype:
+
+- Same-VM quickstart: `k_proxy` and `k_server` run on one VM for app-local testing.
+- Split-VM chain: `k_proxy` runs in `k_proxy`, `k_server` runs in `k_server`, and the Qubes forwarding layer must permit the chain.
+
 ## Start Services
 
-In `k_server` VM:
+### Same-VM quickstart
+
+This matches the code defaults and is useful for basic app behavior only.
+
+In the chosen VM:
 
 ```bash
 python3 /home/user/chromecard/k_server_app.py --host 127.0.0.1 --port 8780 --proxy-token dev-proxy-token
 ```
 
-In `k_proxy` VM:
+In the same VM:
 
 ```bash
 python3 /home/user/chromecard/k_proxy_app.py \
@@ -28,12 +41,64 @@ python3 /home/user/chromecard/k_proxy_app.py \
   --proxy-token dev-proxy-token
 ```
 
+### Split-VM chain
+
+This is the current Qubes target shape.
+
+In `k_server` VM:
+
+```bash
+python3 /home/user/chromecard/k_server_app.py \
+  --host 127.0.0.1 \
+  --port 8780 \
+  --proxy-token dev-proxy-token \
+  --tls-certfile /home/user/chromecard/tls/phase2/k_server.crt \
+  --tls-keyfile /home/user/chromecard/tls/phase2/k_server.key
+```
+
+In `k_proxy` VM:
+
+```bash
+qvm-connect-tcp 9780:k_server:8780
+```
+
+Notes:
+
+```bash
+python3 /home/user/chromecard/k_proxy_app.py \
+  --host 127.0.0.1 \
+  --port 8771 \
+  --session-ttl 300 \
+  --server-base-url https://127.0.0.1:9780 \
+  --server-ca-file /home/user/chromecard/tls/phase2/ca.crt \
+  --proxy-token dev-proxy-token \
+  --tls-certfile /home/user/chromecard/tls/phase2/k_proxy.crt \
+  --tls-keyfile /home/user/chromecard/tls/phase2/k_proxy.key
+```
+
+In `k_client` VM:
+
+```bash
+qvm-connect-tcp 9771:k_proxy:8771
+```
+
+Notes:
+
+- Current validated split-VM path is `k_client localhost:9771 -> k_proxy localhost:8771 -> k_proxy localhost:9780 forward -> k_server localhost:8780`.
+- Use `--cacert /home/user/chromecard/tls/phase2/ca.crt` for TLS verification in `curl`-based checks.
+- Raw VM-IP routing is not the validated path for the current prototype.
+
 ## Test Flow
 
+Use the proxy port that matches the mode you started:
+
+- Same-VM quickstart: `8770`
+- Split-VM chain: `9771` from `k_client`, `8771` inside `k_proxy`
+
 Create a session (runs auth gate once):
 
 ```bash
-curl -sS -X POST http://127.0.0.1:8770/session/login \
+curl -sS -X POST http://127.0.0.1:/session/login \
   -H 'Content-Type: application/json' \
   -d '{"username":"alice"}'
 ```
@@ -47,30 +112,30 @@ TOKEN=''
 Check session:
 
 ```bash
-curl -sS -X POST http://127.0.0.1:8770/session/status \
+curl -sS -X POST http://127.0.0.1:/session/status \
   -H "Authorization: Bearer $TOKEN"
 ```
 
 Call protected resource multiple times (should not require new login):
 
 ```bash
-curl -sS -X POST http://127.0.0.1:8770/resource/counter \
+curl -sS -X POST http://127.0.0.1:/resource/counter \
   -H "Authorization: Bearer $TOKEN"
-curl -sS -X POST http://127.0.0.1:8770/resource/counter \
+curl -sS -X POST http://127.0.0.1:/resource/counter \
   -H "Authorization: Bearer $TOKEN"
 ```
 
 Logout/invalidate:
 
 ```bash
-curl -sS -X POST http://127.0.0.1:8770/session/logout \
+curl -sS -X POST http://127.0.0.1:/session/logout \
   -H "Authorization: Bearer $TOKEN"
 ```
 
 Re-check after logout (should fail with 401):
 
 ```bash
-curl -i -X POST http://127.0.0.1:8770/resource/counter \
+curl -i -X POST http://127.0.0.1:/resource/counter \
   -H "Authorization: Bearer $TOKEN"
 ```
 
@@ -78,3 +143,4 @@ curl -i -X POST http://127.0.0.1:8770/resource/counter \
 
 - 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.
+- For the split-VM chain, the current blocker is not the Python prototype logic; it is refused `qubes.ConnectTCP` forwarding for the chain ports.
diff --git a/Setup.md b/Setup.md
index 2f0123c..f914dc9 100644
--- a/Setup.md
+++ b/Setup.md
@@ -1,6 +1,6 @@
 # Setup
 
-Last updated: 2026-04-24
+Last updated: 2026-04-25
 
 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.
@@ -239,6 +239,44 @@ Session note (2026-04-25, service restart):
   - `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
   - `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
 
+Session note (2026-04-25, markdown refresh):
+- Re-read the active workspace markdown files:
+  - `Setup.md`
+  - `Workplan.md`
+  - `PHASE5_RUNBOOK.md`
+- Corrected the Phase 5 runbook to distinguish the old same-VM quickstart from the current split-VM chain usage.
+- Current documented client-facing proxy port for split-VM tests is `8771`.
+- Current documented blocker remains unchanged:
+  - local service health inside `k_proxy` and `k_server` is good
+  - inter-VM forwarding via `qubes.ConnectTCP` is still refused
+
+Session note (2026-04-25, Phase 2 HTTPS bring-up):
+- Added direct TLS support to:
+  - `/home/user/chromecard/k_proxy_app.py`
+  - `/home/user/chromecard/k_server_app.py`
+- Added local certificate generator:
+  - `/home/user/chromecard/generate_phase2_certs.py`
+- Generated local CA and service certs at:
+  - `/home/user/chromecard/tls/phase2/ca.crt`
+  - `/home/user/chromecard/tls/phase2/k_proxy.crt`
+  - `/home/user/chromecard/tls/phase2/k_server.crt`
+- Certificate generation was corrected to include subject key identifier and authority key identifier so Python TLS verification succeeds.
+- Current validated HTTPS shape is Qubes-localhost forwarding, not raw VM-IP routing:
+  - in `k_client`: `qvm-connect-tcp 9771:k_proxy:8771`
+  - in `k_proxy`: `qvm-connect-tcp 9780:k_server:8780`
+  - `k_proxy` listens on `https://127.0.0.1:8771`
+  - `k_server` listens on `https://127.0.0.1:8780`
+  - `k_proxy` upstream is `https://127.0.0.1:9780`
+- Verified HTTPS checks:
+  - `k_client -> k_proxy` `/health` over TLS succeeds with `--cacert /home/user/chromecard/tls/phase2/ca.crt`
+  - `k_proxy -> k_server` `/health` and `/resource/counter` over TLS succeed through the `9780` forwarder
+  - end-to-end `k_client -> k_proxy -> k_server` login + session reuse succeeded over HTTPS
+- End-to-end verified results:
+  - login returned `ok=true` for `alice`
+  - first protected counter call returned value `1`
+  - second protected counter call returned value `2`
+  - session status remained valid after reuse
+
 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`.
 - Forwarders start and bind locally:
diff --git a/Workplan.md b/Workplan.md
index d5f1b78..8a33b30 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -1,6 +1,6 @@
 # Workplan
 
-Last updated: 2026-04-24
+Last updated: 2026-04-25
 
 This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
 
@@ -66,6 +66,16 @@ Status (2026-04-24, remote diagnostics):
   - `k_proxy (10.137.0.12) -> k_server (10.137.0.13:8780)`: upstream timeout.
 - Local service health inside each VM is good, so failure is inter-VM reachability, not local process startup.
 
+Status (2026-04-25, after restart and service recovery):
+- Refined blocker: this is currently a qrexec/`qubes.ConnectTCP` refusal problem, not an app-local listener problem.
+- Current evidence:
+  - `k_proxy` local `/health` is up on `127.0.0.1:8771`
+  - `k_server` local `/health` is up on `127.0.0.1:8780`
+  - `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
+  - `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
+- Immediate next action for Phase 1:
+  - verify and fix the dom0 policy/mechanism that should permit `qubes.ConnectTCP` forwarding for the chain ports
+
 ## Phase 2: TLS Certificates and Service Endpoints
 
 1. Certificate model.
@@ -84,6 +94,19 @@ Exit criteria:
 - Mutual TLS trust decisions are documented and tested.
 - HTTPS calls succeed on both links with expected cert validation.
 
+Status (2026-04-25):
+- Implemented HTTPS listeners in both prototype services.
+- Added local CA + service certificate generation in `generate_phase2_certs.py`.
+- Verified the working Qubes path is localhost forwarding plus TLS:
+  - `k_client` local `9771` forwards to `k_proxy:8771`
+  - `k_proxy` local `9780` forwards to `k_server:8780`
+- Verified cert validation on both hops using the generated CA.
+- Verified end-to-end HTTPS flow:
+  - `k_client -> k_proxy` login over TLS
+  - `k_proxy -> k_server` protected counter call over TLS
+  - session reuse still works across repeated protected requests
+- Phase 2 is now effectively complete for the current prototype shape.
+
 ## Phase 2.5: Define State Ownership and Concurrency Model
 
 1. State ownership.
@@ -100,6 +123,13 @@ Exit criteria:
 Exit criteria:
 - Architecture clearly documents state authority and race-free update rules.
 
+Next action (2026-04-25):
+- Move into Phase 2.5 and make the current prototype decisions explicit:
+  - authority for session state remains `k_proxy`
+  - `k_server` remains authority for the protected counter/resource state
+  - localhost Qubes forwarders are part of the active runtime model for the two TLS hops
+  - define concurrency assumptions and limits around session store, forwarders, and counter access
+
 ## Phase 3: Recover Basic Device Visibility on `k_proxy` (Blocking)
 
 1. Verify physical + USB enumeration path.
@@ -173,6 +203,13 @@ Status (2026-04-24):
   - proxy forwarding from `k_proxy` to `k_server` using a shared upstream token
 - Current auth gate for session creation is card-presence probe (`fido2_probe.py --json`), pending upgrade to full assertion verification path.
 
+Status (2026-04-25):
+- Prototype services were re-started successfully after VM restart.
+- Current split-VM test shape is:
+  - `k_proxy` listening on `127.0.0.1:8771`
+  - `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.
+
 ## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server`
 
 1. Protected dummy resource.
diff --git a/generate_phase2_certs.py b/generate_phase2_certs.py
new file mode 100644
index 0000000..24cd87b
--- /dev/null
+++ b/generate_phase2_certs.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""
+Generate a small local CA plus leaf certificates for Phase 2 HTTPS testing.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ipaddress
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
+
+
+def build_name(common_name: str) -> x509.Name:
+    return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
+
+
+def new_private_key() -> rsa.RSAPrivateKey:
+    return rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+
+def write_private_key(path: Path, key: rsa.RSAPrivateKey) -> None:
+    path.write_bytes(
+        key.private_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PrivateFormat.TraditionalOpenSSL,
+            encryption_algorithm=serialization.NoEncryption(),
+        )
+    )
+
+
+def write_cert(path: Path, cert: x509.Certificate) -> None:
+    path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
+
+
+def parse_sans(names: list[str]) -> list[x509.GeneralName]:
+    sans: list[x509.GeneralName] = []
+    seen = set()
+    for value in names:
+        if value in seen:
+            continue
+        seen.add(value)
+        try:
+            sans.append(x509.IPAddress(ipaddress.ip_address(value)))
+        except ValueError:
+            sans.append(x509.DNSName(value))
+    return sans
+
+
+def issue_ca(common_name: str, valid_days: int) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
+    now = datetime.now(timezone.utc)
+    key = new_private_key()
+    subject = issuer = build_name(common_name)
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now - timedelta(minutes=5))
+        .not_valid_after(now + timedelta(days=valid_days))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
+        .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False)
+        .add_extension(x509.KeyUsage(digital_signature=True, key_encipherment=False, key_cert_sign=True, crl_sign=True, content_commitment=False, data_encipherment=False, key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
+        .sign(key, hashes.SHA256())
+    )
+    return key, cert
+
+
+def issue_leaf(
+    ca_key: rsa.RSAPrivateKey,
+    ca_cert: x509.Certificate,
+    common_name: str,
+    san_values: list[str],
+    valid_days: int,
+) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
+    now = datetime.now(timezone.utc)
+    key = new_private_key()
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(build_name(common_name))
+        .issuer_name(ca_cert.subject)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now - timedelta(minutes=5))
+        .not_valid_after(now + timedelta(days=valid_days))
+        .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(parse_sans(san_values)), critical=False)
+        .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
+        .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
+        .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
+        .add_extension(x509.KeyUsage(digital_signature=True, key_encipherment=True, key_cert_sign=False, crl_sign=False, content_commitment=False, data_encipherment=False, key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
+        .sign(ca_key, hashes.SHA256())
+    )
+    return key, cert
+
+
+def emit_leaf_bundle(
+    out_dir: Path,
+    leaf_name: str,
+    ca_key: rsa.RSAPrivateKey,
+    ca_cert: x509.Certificate,
+    san_values: list[str],
+    valid_days: int,
+) -> None:
+    key, cert = issue_leaf(ca_key, ca_cert, leaf_name, san_values, valid_days)
+    write_private_key(out_dir / f"{leaf_name}.key", key)
+    write_cert(out_dir / f"{leaf_name}.crt", cert)
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Generate local CA and Phase 2 service certificates")
+    parser.add_argument("--out-dir", default="tls/phase2")
+    parser.add_argument("--valid-days", type=int, default=30)
+    parser.add_argument("--ca-common-name", default="ChromeCard Phase2 Local CA")
+    parser.add_argument(
+        "--proxy-san",
+        action="append",
+        default=[],
+        help="Extra SAN for k_proxy certificate; may be repeated",
+    )
+    parser.add_argument(
+        "--server-san",
+        action="append",
+        default=[],
+        help="Extra SAN for k_server certificate; may be repeated",
+    )
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    out_dir = Path(args.out_dir)
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    ca_key, ca_cert = issue_ca(args.ca_common_name, args.valid_days)
+    write_private_key(out_dir / "ca.key", ca_key)
+    write_cert(out_dir / "ca.crt", ca_cert)
+
+    proxy_sans = ["localhost", "127.0.0.1", "k_proxy", *args.proxy_san]
+    server_sans = ["localhost", "127.0.0.1", "k_server", *args.server_san]
+
+    emit_leaf_bundle(out_dir, "k_proxy", ca_key, ca_cert, proxy_sans, args.valid_days)
+    emit_leaf_bundle(out_dir, "k_server", ca_key, ca_cert, server_sans, args.valid_days)
+
+    print(f"Generated CA and leaf certificates in {out_dir}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/k_proxy_app.py b/k_proxy_app.py
index ce0a79a..019c712 100644
--- a/k_proxy_app.py
+++ b/k_proxy_app.py
@@ -17,6 +17,7 @@ from __future__ import annotations
 import argparse
 import json
 import secrets
+import ssl
 import subprocess
 import threading
 import time
@@ -40,11 +41,13 @@ class ProxyState:
         session_ttl_s: int,
         auth_command: str,
         server_base_url: str,
+        server_ca_file: str | None,
         proxy_token: str,
     ):
         self.session_ttl_s = session_ttl_s
         self.auth_command = auth_command
         self.server_base_url = server_base_url.rstrip("/")
+        self.server_ca_file = server_ca_file
         self.proxy_token = proxy_token
         self.lock = threading.Lock()
         self.sessions: dict[str, Session] = {}
@@ -108,8 +111,11 @@ class ProxyState:
         req.add_header("X-Proxy-Token", self.proxy_token)
         req.add_header("Content-Type", "application/json")
         body = b"{}"
+        ssl_context = None
+        if self.server_base_url.startswith("https://"):
+            ssl_context = ssl.create_default_context(cafile=self.server_ca_file)
         try:
-            with urlopen(req, data=body, timeout=5) as resp:
+            with urlopen(req, data=body, timeout=5, context=ssl_context) as resp:
                 data = json.loads(resp.read().decode("utf-8"))
                 return resp.status, data
         except HTTPError as exc:
@@ -268,6 +274,8 @@ def parse_args() -> argparse.Namespace:
     parser = argparse.ArgumentParser(description="Run k_proxy session gateway")
     parser.add_argument("--host", default="127.0.0.1")
     parser.add_argument("--port", type=int, default=8770)
+    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("--session-ttl", type=int, default=300, help="Session TTL in seconds")
     parser.add_argument(
         "--auth-command",
@@ -279,6 +287,10 @@ def parse_args() -> argparse.Namespace:
         default="http://127.0.0.1:8780",
         help="Base URL for k_server",
     )
+    parser.add_argument(
+        "--server-ca-file",
+        help="CA certificate used to verify HTTPS certificate presented by k_server",
+    )
     parser.add_argument(
         "--proxy-token",
         default="dev-proxy-token",
@@ -289,15 +301,28 @@ def parse_args() -> argparse.Namespace:
 
 def main() -> int:
     args = parse_args()
+    if bool(args.tls_certfile) != bool(args.tls_keyfile):
+        raise SystemExit("Both --tls-certfile and --tls-keyfile are required to enable HTTPS")
+    if args.server_base_url.startswith("https://") and not args.server_ca_file:
+        raise SystemExit("--server-ca-file is required when --server-base-url uses https")
+
     state = ProxyState(
         session_ttl_s=args.session_ttl,
         auth_command=args.auth_command,
         server_base_url=args.server_base_url,
+        server_ca_file=args.server_ca_file,
         proxy_token=args.proxy_token,
     )
     Handler.state = state
     server = ThreadingHTTPServer((args.host, args.port), Handler)
-    print(f"k_proxy listening on http://{args.host}:{args.port}")
+    scheme = "http"
+    if args.tls_certfile:
+        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        context.load_cert_chain(certfile=args.tls_certfile, keyfile=args.tls_keyfile)
+        server.socket = context.wrap_socket(server.socket, server_side=True)
+        scheme = "https"
+
+    print(f"k_proxy listening on {scheme}://{args.host}:{args.port}")
     server.serve_forever()
     return 0
 
diff --git a/k_server_app.py b/k_server_app.py
index 2a8e7c9..afe3868 100644
--- a/k_server_app.py
+++ b/k_server_app.py
@@ -12,6 +12,7 @@ from __future__ import annotations
 
 import argparse
 import json
+import ssl
 import threading
 import time
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -84,6 +85,8 @@ def parse_args() -> argparse.Namespace:
     parser = argparse.ArgumentParser(description="Run k_server counter service")
     parser.add_argument("--host", default="127.0.0.1")
     parser.add_argument("--port", type=int, default=8780)
+    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(
         "--proxy-token",
         default="dev-proxy-token",
@@ -94,10 +97,20 @@ def parse_args() -> argparse.Namespace:
 
 def main() -> int:
     args = parse_args()
+    if bool(args.tls_certfile) != bool(args.tls_keyfile):
+        raise SystemExit("Both --tls-certfile and --tls-keyfile are required to enable HTTPS")
+
     state = ServerState(proxy_token=args.proxy_token)
     Handler.state = state
     server = ThreadingHTTPServer((args.host, args.port), Handler)
-    print(f"k_server listening on http://{args.host}:{args.port}")
+    scheme = "http"
+    if args.tls_certfile:
+        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        context.load_cert_chain(certfile=args.tls_certfile, keyfile=args.tls_keyfile)
+        server.socket = context.wrap_socket(server.socket, server_side=True)
+        scheme = "https"
+
+    print(f"k_server listening on {scheme}://{args.host}:{args.port}")
     server.serve_forever()
     return 0
 

From 46fb878f8d875f90fee937e9f478143826d017cd Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Sat, 25 Apr 2026 01:33:16 +0200
Subject: [PATCH 10/24] Document Phase 2.5 ownership and concurrency

---
 PHASE5_RUNBOOK.md | 26 +++++++++++++++++++-------
 Setup.md          | 27 +++++++++++++++++++++++++++
 Workplan.md       | 19 +++++++++++++++++++
 3 files changed, 65 insertions(+), 7 deletions(-)

diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md
index 17e90fb..90f0a9c 100644
--- a/PHASE5_RUNBOOK.md
+++ b/PHASE5_RUNBOOK.md
@@ -88,6 +88,17 @@ Notes:
 - Use `--cacert /home/user/chromecard/tls/phase2/ca.crt` for TLS verification in `curl`-based checks.
 - Raw VM-IP routing is not the validated path for the current prototype.
 
+## Ownership And Concurrency
+
+- `k_proxy` is authoritative for session state.
+- `k_server` is authoritative for the protected counter state.
+- Sessions are in-memory only in `k_proxy` and are lost on proxy restart.
+- The protected counter is in-memory only in `k_server` and resets on server restart.
+- Both services use `ThreadingHTTPServer`.
+- `k_proxy` guards its session store with a single process-local lock.
+- `k_server` guards counter increments with a single process-local lock.
+- Qubes localhost forwarders are transport plumbing only; they are not a source of state authority.
+
 ## Test Flow
 
 Use the proxy port that matches the mode you started:
@@ -98,7 +109,7 @@ Use the proxy port that matches the mode you started:
 Create a session (runs auth gate once):
 
 ```bash
-curl -sS -X POST http://127.0.0.1:/session/login \
+curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/session/login \
   -H 'Content-Type: application/json' \
   -d '{"username":"alice"}'
 ```
@@ -112,30 +123,30 @@ TOKEN=''
 Check session:
 
 ```bash
-curl -sS -X POST http://127.0.0.1:/session/status \
+curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/session/status \
   -H "Authorization: Bearer $TOKEN"
 ```
 
 Call protected resource multiple times (should not require new login):
 
 ```bash
-curl -sS -X POST http://127.0.0.1:/resource/counter \
+curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/resource/counter \
   -H "Authorization: Bearer $TOKEN"
-curl -sS -X POST http://127.0.0.1:/resource/counter \
+curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/resource/counter \
   -H "Authorization: Bearer $TOKEN"
 ```
 
 Logout/invalidate:
 
 ```bash
-curl -sS -X POST http://127.0.0.1:/session/logout \
+curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/session/logout \
   -H "Authorization: Bearer $TOKEN"
 ```
 
 Re-check after logout (should fail with 401):
 
 ```bash
-curl -i -X POST http://127.0.0.1:/resource/counter \
+curl -i --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/resource/counter \
   -H "Authorization: Bearer $TOKEN"
 ```
 
@@ -143,4 +154,5 @@ curl -i -X POST http://127.0.0.1:/resource/counter \
 
 - 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.
-- For the split-VM chain, the current blocker is not the Python prototype logic; it is refused `qubes.ConnectTCP` forwarding for the chain ports.
+- Session and counter state are currently process-local only; restart loses state.
+- Upstream trust still relies on a shared static `X-Proxy-Token`.
diff --git a/Setup.md b/Setup.md
index f914dc9..484ae49 100644
--- a/Setup.md
+++ b/Setup.md
@@ -277,6 +277,33 @@ Session note (2026-04-25, Phase 2 HTTPS bring-up):
   - second protected counter call returned value `2`
   - session status remained valid after reuse
 
+Session note (2026-04-25, Phase 2.5 ownership and concurrency):
+- Current prototype state ownership is now explicit:
+  - `k_proxy` is authoritative for session state
+  - `k_server` is authoritative for protected resource state
+  - `k_client` is not authoritative for either session validity or counter/resource state
+- Current session model in `k_proxy`:
+  - server-side in-memory session store only
+  - opaque bearer token generated by `secrets.token_urlsafe(32)`
+  - per-session fields are `username` and `expires_at`
+  - expiry is enforced in `k_proxy`; `k_server` does not validate client sessions directly
+- Current resource model in `k_server`:
+  - in-memory monotonic counter guarded by a lock
+  - access allowed only when request arrives from `k_proxy` with the expected `X-Proxy-Token`
+- Current concurrency model in code:
+  - both services use `ThreadingHTTPServer`
+  - `k_proxy` protects session-map mutations and garbage collection with a single lock
+  - `k_server` protects counter increments with a single lock
+  - TLS verification and upstream fetches happen outside the session lock in `k_proxy`
+- Current runtime assumptions and limits:
+  - Qubes localhost forwarders are treated as transport plumbing, not as state authorities
+  - if `k_proxy` restarts, in-memory sessions are lost
+  - if `k_server` restarts, the in-memory counter resets
+  - the current shared `X-Proxy-Token` is a prototype trust mechanism, not a final authorization design
+- Practical meaning:
+  - race-free behavior is currently defined for session CRUD and counter increments inside one process per VM
+  - persistence, distributed session authority, and multi-proxy/multi-server coordination are not implemented yet
+
 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`.
 - Forwarders start and bind locally:
diff --git a/Workplan.md b/Workplan.md
index 8a33b30..80c13e6 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -130,6 +130,25 @@ Next action (2026-04-25):
   - localhost Qubes forwarders are part of the active runtime model for the two TLS hops
   - define concurrency assumptions and limits around session store, forwarders, and counter access
 
+Status (2026-04-25):
+- Current ownership model is now explicit:
+  - `k_proxy` is authoritative for session creation, expiry, lookup, and logout
+  - `k_server` is authoritative for the protected monotonic counter
+  - `k_client` is a client only; it holds bearer tokens but is not a state authority
+- Current validation boundary is explicit:
+  - `k_proxy` validates bearer tokens against its in-memory session store
+  - `k_server` trusts only requests that arrive with the configured `X-Proxy-Token`
+  - `k_server` does not currently validate end-user session tokens directly
+- Current concurrency strategy is explicit:
+  - `k_proxy` uses `ThreadingHTTPServer` plus one lock around the in-memory session map
+  - `k_server` uses `ThreadingHTTPServer` plus one lock around counter increments
+  - upstream HTTPS calls from `k_proxy` are made outside the session-store lock
+- Current runtime limits are explicit:
+  - sessions are process-local and disappear on `k_proxy` restart
+  - counter state is process-local and resets on `k_server` restart
+  - transport relies on Qubes localhost forwarders `9771` and `9780`
+- Phase 2.5 is complete for the current prototype shape.
+
 ## Phase 3: Recover Basic Device Visibility on `k_proxy` (Blocking)
 
 1. Verify physical + USB enumeration path.

From 4893eb831273fb10c937c87221a41172b93f7a2a Mon Sep 17 00:00:00 2001
From: "Morten V. Christiansen" 
Date: Sat, 25 Apr 2026 01:42:03 +0200
Subject: [PATCH 11/24] Add Phase 6 client portal and enrollment flow

---
 Setup.md           |  41 ++++
 Workplan.md        |  23 +++
 k_client_portal.py | 483 +++++++++++++++++++++++++++++++++++++++++++++
 k_proxy_app.py     | 117 +++++++++++
 4 files changed, 664 insertions(+)
 create mode 100644 k_client_portal.py

diff --git a/Setup.md b/Setup.md
index 484ae49..187fe40 100644
--- a/Setup.md
+++ b/Setup.md
@@ -304,6 +304,47 @@ Session note (2026-04-25, Phase 2.5 ownership and concurrency):
   - race-free behavior is currently defined for session CRUD and counter increments inside one process per VM
   - persistence, distributed session authority, and multi-proxy/multi-server coordination are not implemented yet
 
+Session note (2026-04-25, Phase 6 client portal prototype):
+- Added browser-facing client process:
+  - `/home/user/chromecard/k_client_portal.py`
+- Current Phase 6 prototype shape:
+  - portal runs in `k_client` on `http://127.0.0.1:8766`
+  - portal keeps local enrolled username state in `k_client`
+  - portal calls `k_proxy` over the validated TLS forward `https://127.0.0.1:9771`
+- Current local enrollment model:
+  - enrollment is a client-local username selection stored by the portal
+  - no dedicated server-side enrollment API exists yet
+- Verified portal API flow in `k_client`:
+  - `GET /health` returns `ok=true`
+  - `POST /api/enroll` with `alice` succeeds
+  - `POST /api/login` succeeds and returns a proxy session token
+  - `POST /api/status` succeeds
+  - `POST /api/resource/counter` succeeds twice with upstream values `3` and `4`
+  - `POST /api/logout` succeeds
+- Current implication:
+  - `k_client` now has a concrete client-side process instead of only runbook curls
+  - browser-facing flow is now available through the local portal
+  - next hardening step is to replace client-local enrollment with the intended enrollment contract and decide whether browser traffic should eventually talk to `k_proxy` directly or continue through a local client portal
+
+Session note (2026-04-25, Phase 6 enrollment contract):
+- Added proxy-side enrollment API and storage:
+  - `POST /enroll/register`
+  - `GET /enroll/status?username=`
+  - persisted prototype store at `/home/user/chromecard/k_proxy_enrollments.json` in `k_proxy`
+- Current enrollment authority is now `k_proxy`, not the `k_client` portal.
+- Current portal behavior:
+  - portal enrollment calls `k_proxy` over TLS
+  - portal keeps only a preferred local username for convenience
+  - portal login now depends on proxy-side enrollment existing
+- Verified behavior:
+  - direct proxy login for unenrolled `bob` returns `{"ok": false, "error": "user not enrolled", ...}`
+  - portal enrollment of `alice` succeeds and persists in proxy-side enrollment storage
+  - proxy enrollment status for `alice` returns `ok=true`
+  - portal login and protected counter access still succeed after enrollment
+- Practical meaning:
+  - Phase 6 now has a real `k_client -> k_proxy` enrollment request path
+  - the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly
+
 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`.
 - Forwarders start and bind locally:
diff --git a/Workplan.md b/Workplan.md
index 80c13e6..f28638b 100644
--- a/Workplan.md
+++ b/Workplan.md
@@ -262,6 +262,29 @@ Exit criteria:
 Exit criteria:
 - Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`.
 
+Status (2026-04-25):
+- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
+- Current prototype flow:
+  - browser talks to local portal on `k_client`
+  - portal stores only a preferred username locally
+  - portal calls `k_proxy` over `https://127.0.0.1:9771`
+  - `k_proxy` continues to authenticate with the card and forward to `k_server`
+- Verified end-to-end through the portal:
+  - enroll `alice`
+  - login succeeds
+  - session status succeeds
+  - protected counter succeeds repeatedly with session reuse
+  - logout succeeds
+- Enrollment contract progress:
+  - `k_proxy` now exposes prototype enrollment endpoints
+  - proxy-side enrollment storage exists and is checked before login is allowed
+  - `k_client` portal enrollment now routes to `k_proxy` over TLS instead of remaining client-local only
+- Phase 6 is materially further along for the current prototype shape:
+  - client-side process exists
+  - login/resource flow is integrated
+  - enrollment now has a real client->proxy path
+  - final enrollment semantics and UI shape are still provisional
+
 ## Phase 6.5: Concurrency and Multi-Client Test Setup
 
 1. Single-VM concurrency tests.
diff --git a/k_client_portal.py b/k_client_portal.py
new file mode 100644
index 0000000..f50d420
--- /dev/null
+++ b/k_client_portal.py
@@ -0,0 +1,483 @@
+#!/usr/bin/env python3
+"""
+Minimal browser-facing client portal for Phase 6 bring-up.
+
+This runs in k_client, keeps a local preferred username, and talks to k_proxy
+over the localhost-forwarded TLS endpoint.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import ssl
+import threading
+import time
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlparse
+from urllib.request import Request, urlopen
+
+
+HTML = """
+
+
+  
+  
+  ChromeCard Client Portal
+  
+
+
+  
+
+

ChromeCard Client Portal

+

+ Prototype browser flow for k_client. Enrollment state lives here. Login, + session status, counter access, and logout go through the current k_proxy TLS API. +

+
+ +
+
+

Enrollment

+ + +
+ +
+
+
Enrolled user: none
+
Session active: no
+
+
+ +
+

Session Flow

+
+ + + + +
+
+
+ +

+  
+ + + + +""" + + +@dataclass +class EnrollmentRecord: + username: str + + +class ClientState: + def __init__(self, proxy_base_url: str, proxy_ca_file: str | None, enroll_db: Path): + self.proxy_base_url = proxy_base_url.rstrip("/") + self.proxy_ca_file = proxy_ca_file + self.enroll_db = enroll_db + self.lock = threading.Lock() + self.preferred_enrollment: EnrollmentRecord | None = None + self.session_token: str | None = None + self.session_expires_at: int | None = None + self._load_preferred_enrollment() + + def _ssl_context(self): + if self.proxy_base_url.startswith("https://"): + return ssl.create_default_context(cafile=self.proxy_ca_file) + return None + + def _proxy_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]: + req = Request(f"{self.proxy_base_url}{path}", method=method) + req.add_header("Content-Type", "application/json") + token = self.get_session_token() + if token: + req.add_header("Authorization", f"Bearer {token}") + body = json.dumps(payload or {}).encode("utf-8") + try: + with urlopen(req, data=body, timeout=10, context=self._ssl_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"proxy http error {exc.code}"} + except URLError as exc: + return 502, {"ok": False, "error": f"proxy unavailable: {exc.reason}"} + except Exception as exc: + return 502, {"ok": False, "error": f"proxy call failed: {exc}"} + + def _load_preferred_enrollment(self) -> None: + if not self.enroll_db.exists(): + return + try: + data = json.loads(self.enroll_db.read_text()) + username = str(data.get("username", "")).strip() + if username: + self.preferred_enrollment = EnrollmentRecord(username=username) + except Exception: + self.preferred_enrollment = None + + def _save_preferred_enrollment_locked(self) -> None: + self.enroll_db.parent.mkdir(parents=True, exist_ok=True) + payload = {"username": self.preferred_enrollment.username if self.preferred_enrollment else None} + self.enroll_db.write_text(json.dumps(payload, indent=2) + "\n") + + def enroll(self, username: str) -> dict[str, Any]: + username = username.strip() + if not username: + return {"ok": False, "error": "username required"} + status, data = self._proxy_json("POST", "/enroll/register", {"username": username}) + if status != 200: + return data + with self.lock: + self.preferred_enrollment = EnrollmentRecord(username=username) + self._save_preferred_enrollment_locked() + self.session_token = None + self.session_expires_at = None + return { + "ok": True, + "enrolled_username": username, + "proxy_enrollment": data, + } + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return { + "ok": True, + "enrolled_username": self.preferred_enrollment.username if self.preferred_enrollment else None, + "session_active": bool(self.session_token), + "session_expires_at": self.session_expires_at, + "proxy_base_url": self.proxy_base_url, + } + + def get_session_token(self) -> str | None: + with self.lock: + return self.session_token + + def login(self) -> tuple[int, dict[str, Any]]: + with self.lock: + if not self.preferred_enrollment: + return 400, {"ok": False, "error": "no enrolled user"} + username = self.preferred_enrollment.username + + status, data = self._proxy_json("POST", "/session/login", {"username": username}) + if status == 200 and data.get("session_token"): + with self.lock: + self.session_token = data["session_token"] + self.session_expires_at = int(data.get("expires_at", 0)) or None + return status, data + + def status(self) -> tuple[int, dict[str, Any]]: + return self._proxy_json("POST", "/session/status") + + def counter(self) -> tuple[int, dict[str, Any]]: + return self._proxy_json("POST", "/resource/counter") + + def logout(self) -> tuple[int, dict[str, Any]]: + status, data = self._proxy_json("POST", "/session/logout") + if status == 200: + with self.lock: + self.session_token = None + self.session_expires_at = None + return status, data + + +class Handler(BaseHTTPRequestHandler): + state: ClientState + + def _json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).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 _html(self, body: str) -> None: + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/": + self._html(HTML) + return + if path == "/health": + self._json(200, {"ok": True, "service": "k_client_portal", "time": int(time.time())}) + return + if path == "/api/client/state": + self._json(200, self.state.snapshot()) + return + self.send_error(404) + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/api/enroll": + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + result = self.state.enroll(str(data.get("username", ""))) + self._json(200 if result.get("ok") else 400, result) + return + if path == "/api/login": + status, data = self.state.login() + self._json(status, data) + return + if path == "/api/status": + status, data = self.state.status() + self._json(status, data) + return + if path == "/api/resource/counter": + status, data = self.state.counter() + self._json(status, data) + return + if path == "/api/logout": + status, data = self.state.logout() + self._json(status, data) + return + self.send_error(404) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run browser-facing client portal in k_client") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8766) + parser.add_argument("--proxy-base-url", default="https://127.0.0.1:9771") + parser.add_argument("--proxy-ca-file", help="CA certificate used to verify k_proxy HTTPS certificate") + parser.add_argument("--enroll-db", default="/home/user/chromecard/k_client_enrollment.json") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.proxy_base_url.startswith("https://") and not args.proxy_ca_file: + raise SystemExit("--proxy-ca-file is required when --proxy-base-url uses https") + + Handler.state = ClientState( + proxy_base_url=args.proxy_base_url, + proxy_ca_file=args.proxy_ca_file, + enroll_db=Path(args.enroll_db), + ) + server = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"k_client_portal listening on http://{args.host}:{args.port}") + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/k_proxy_app.py b/k_proxy_app.py index 019c712..68fa94f 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -23,6 +23,7 @@ import threading import time from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlparse @@ -35,6 +36,12 @@ class Session: expires_at: float +@dataclass +class Enrollment: + username: str + enrolled_at: int + + class ProxyState: def __init__( self, @@ -43,14 +50,18 @@ class ProxyState: server_base_url: str, server_ca_file: str | None, proxy_token: str, + enrollment_db: Path, ): self.session_ttl_s = session_ttl_s self.auth_command = auth_command self.server_base_url = server_base_url.rstrip("/") self.server_ca_file = server_ca_file self.proxy_token = proxy_token + self.enrollment_db = enrollment_db self.lock = threading.Lock() self.sessions: dict[str, Session] = {} + self.enrollments: dict[str, Enrollment] = {} + self._load_enrollments() def _now(self) -> float: return time.time() @@ -84,6 +95,48 @@ class ProxyState: self._gc_locked() return len(self.sessions) + def _load_enrollments(self) -> None: + if not self.enrollment_db.exists(): + return + try: + payload = json.loads(self.enrollment_db.read_text()) + users = payload.get("users", []) + for item in users: + username = str(item.get("username", "")).strip() + if not username: + continue + enrolled_at = int(item.get("enrolled_at", int(self._now()))) + self.enrollments[username] = Enrollment(username=username, enrolled_at=enrolled_at) + except Exception: + self.enrollments = {} + + def _save_enrollments_locked(self) -> None: + self.enrollment_db.parent.mkdir(parents=True, exist_ok=True) + users = [ + {"username": enrollment.username, "enrolled_at": enrollment.enrolled_at} + for enrollment in sorted(self.enrollments.values(), key=lambda item: item.username) + ] + self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n") + + def register_enrollment(self, username: str) -> tuple[bool, Enrollment]: + username = username.strip() + enrolled_at = int(self._now()) + with self.lock: + existing = self.enrollments.get(username) + if existing: + return False, existing + enrollment = Enrollment(username=username, enrolled_at=enrolled_at) + self.enrollments[username] = enrollment + self._save_enrollments_locked() + return True, enrollment + + def get_enrollment(self, username: str) -> Enrollment | None: + with self.lock: + return self.enrollments.get(username.strip()) + + def has_enrollment(self, username: str) -> bool: + return self.get_enrollment(username) is not None + def authenticate_with_card(self) -> tuple[bool, str]: try: proc = subprocess.run( @@ -179,6 +232,9 @@ class Handler(BaseHTTPRequestHandler): }, ) return + if path.startswith("/enroll/status"): + self._enroll_status() + return self.send_error(404) def do_POST(self) -> None: # noqa: N802 @@ -186,6 +242,9 @@ class Handler(BaseHTTPRequestHandler): if path == "/session/login": self._session_login() return + if path == "/enroll/register": + self._enroll_register() + return if path == "/session/status": self._session_status() return @@ -208,6 +267,9 @@ class Handler(BaseHTTPRequestHandler): if not username: self._json(400, {"ok": False, "error": "username required"}) return + if not self.state.has_enrollment(username): + self._json(403, {"ok": False, "error": "user not enrolled", "username": username}) + return ok, message = self.state.authenticate_with_card() if not ok: @@ -227,6 +289,55 @@ class Handler(BaseHTTPRequestHandler): }, ) + def _enroll_register(self) -> None: + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + + username = str(data.get("username", "")).strip() + if not username: + self._json(400, {"ok": False, "error": "username required"}) + return + + created, enrollment = self.state.register_enrollment(username) + self._json( + 200, + { + "ok": True, + "username": enrollment.username, + "enrolled_at": enrollment.enrolled_at, + "created": created, + }, + ) + + def _enroll_status(self) -> None: + parsed = urlparse(self.path) + query = {} + if parsed.query: + for chunk in parsed.query.split("&"): + if "=" not in chunk: + continue + key, value = chunk.split("=", 1) + query[key] = value + username = query.get("username", "").strip() + if not username: + self._json(400, {"ok": False, "error": "username query required"}) + return + enrollment = self.state.get_enrollment(username) + if not enrollment: + self._json(404, {"ok": False, "error": "user not enrolled", "username": username}) + return + self._json( + 200, + { + "ok": True, + "username": enrollment.username, + "enrolled_at": enrollment.enrolled_at, + }, + ) + def _session_status(self) -> None: got = self._require_session() if not got: @@ -296,6 +407,11 @@ def parse_args() -> argparse.Namespace: default="dev-proxy-token", help="Shared token to authorize requests to k_server", ) + parser.add_argument( + "--enrollment-db", + default="/home/user/chromecard/k_proxy_enrollments.json", + help="JSON file used to persist enrolled usernames for the prototype", + ) return parser.parse_args() @@ -312,6 +428,7 @@ def main() -> int: server_base_url=args.server_base_url, server_ca_file=args.server_ca_file, proxy_token=args.proxy_token, + enrollment_db=Path(args.enrollment_db), ) Handler.state = state server = ThreadingHTTPServer((args.host, args.port), Handler) From d0d27a0896c144b94c8b9aac68f898575d36764a Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 01:47:26 +0200 Subject: [PATCH 12/24] Move browser portal to k_proxy --- Setup.md | 14 +++ Workplan.md | 14 +-- k_client_portal.py | 183 ++++------------------------- k_proxy_app.py | 279 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 167 deletions(-) diff --git a/Setup.md b/Setup.md index 187fe40..1dea768 100644 --- a/Setup.md +++ b/Setup.md @@ -345,6 +345,20 @@ Session note (2026-04-25, Phase 6 enrollment contract): - Phase 6 now has a real `k_client -> k_proxy` enrollment request path - the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly +Session note (2026-04-25, browser target moved to k_proxy): +- `k_proxy` now serves the browser-facing portal UI directly on `/` over `https://127.0.0.1:9771`. +- `k_client_portal.py` is now a temporary bridge page: + - it points users to `https://127.0.0.1:9771/` + - it is no longer the primary browser target +- Verified direct browser/API target behavior from `k_client`: + - `GET https://127.0.0.1:9771/` returns the proxy portal HTML + - `GET https://127.0.0.1:9771/health` returns `ok=true` + - direct `POST /enroll/register` for `carol` succeeds + - direct `POST /session/login` for `carol` succeeds +- Current implication: + - browser traffic is now intended to go straight to `k_proxy` + - the `k_client` portal remains only as a temporary bridge/compatibility layer + 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`. - Forwarders start and bind locally: diff --git a/Workplan.md b/Workplan.md index f28638b..11fb3a3 100644 --- a/Workplan.md +++ b/Workplan.md @@ -265,9 +265,8 @@ Exit criteria: Status (2026-04-25): - Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`. - Current prototype flow: - - browser talks to local portal on `k_client` - - portal stores only a preferred username locally - - portal calls `k_proxy` over `https://127.0.0.1:9771` + - browser now targets `k_proxy` directly over `https://127.0.0.1:9771` + - `k_client_portal.py` remains only as a temporary bridge page - `k_proxy` continues to authenticate with the card and forward to `k_server` - Verified end-to-end through the portal: - enroll `alice` @@ -278,12 +277,13 @@ Status (2026-04-25): - Enrollment contract progress: - `k_proxy` now exposes prototype enrollment endpoints - proxy-side enrollment storage exists and is checked before login is allowed - - `k_client` portal enrollment now routes to `k_proxy` over TLS instead of remaining client-local only + - direct browser/API traffic can now use those proxy endpoints without going through the local bridge - Phase 6 is materially further along for the current prototype shape: - - client-side process exists - - login/resource flow is integrated + - direct browser target is on `k_proxy` + - login/resource flow is integrated on the direct proxy path - enrollment now has a real client->proxy path - - final enrollment semantics and UI shape are still provisional + - the `k_client` bridge remains only for transition/compatibility + - final enrollment semantics are still provisional ## Phase 6.5: Concurrency and Multi-Client Test Setup diff --git a/k_client_portal.py b/k_client_portal.py index f50d420..1622eaa 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -27,35 +27,34 @@ HTML = """ - ChromeCard Client Portal + ChromeCard Client Bridge + + +
+
+

ChromeCard Proxy Portal

+

+ 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

+ + +
+ + +
+
+
Stored username: none
+
Session active: no
+
+
+ +
+

Session Flow

+
+ + + + +
+
+
+ +

+  
+ + + + +""" + + @dataclass class Session: username: str @@ -194,6 +462,14 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _html(self, body: str) -> None: + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + def _read_json(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length) @@ -221,6 +497,9 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path + if path == "/": + self._html(HTML) + return if path == "/health": self._json( 200, From 2448956946f025ecb5627981a1026f2dd2c51a77 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 10:25:40 +0200 Subject: [PATCH 13/24] Add CTAP probe and update phase docs --- PHASE5_RUNBOOK.md | 48 +++ Setup.md | 134 +++++++- Workplan.md | 110 ++++++- k_proxy_app.py | 597 +++++++++++++++++++++++++++++++---- k_server_app.py | 7 + phase5_chain_regression.sh | 180 +++++++++++ phase65_concurrency_probe.py | 188 +++++++++++ raw_ctap_probe.py | 311 ++++++++++++++++++ 8 files changed, 1507 insertions(+), 68 deletions(-) create mode 100755 phase5_chain_regression.sh create mode 100644 phase65_concurrency_probe.py create mode 100644 raw_ctap_probe.py diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index 90f0a9c..ed59da4 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -150,9 +150,57 @@ curl -i --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0 -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 - 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. - Session and counter state are currently process-local only; restart loses state. - 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 diff --git a/Setup.md b/Setup.md index 1dea768..c1d4175 100644 --- a/Setup.md +++ b/Setup.md @@ -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` - 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=` + - `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): - Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`. - 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 - 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 - 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` - Then: - `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. - `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 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`. - 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`. - Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds). diff --git a/Workplan.md b/Workplan.md index 11fb3a3..3f6a73f 100644 --- a/Workplan.md +++ b/Workplan.md @@ -42,7 +42,7 @@ This is the execution plan for making ChromeCard FIDO2 development and validatio Exit criteria: - 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. - 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: - 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 1. Certificate model. @@ -227,7 +237,23 @@ Status (2026-04-25): - Current split-VM test shape is: - `k_proxy` listening on `127.0.0.1:8771` - `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` @@ -245,6 +271,14 @@ Exit criteria: - Authorized requests obtain consistent increasing values. - 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 1. Enrollment process in `k_client`. @@ -257,6 +291,15 @@ Exit criteria: 3. Browser flow in `k_client`. - 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. Exit criteria: @@ -285,6 +328,61 @@ Status (2026-04-25): - the `k_client` bridge remains only for transition/compatibility - 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 1. Single-VM concurrency tests. @@ -434,6 +532,14 @@ Exit criteria: Exit criteria: - `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 - Exact observed behavior on reconnect attempts (USB/hidraw/probe). diff --git a/k_proxy_app.py b/k_proxy_app.py index f70c4ed..907bce4 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -3,19 +3,24 @@ Minimal k_proxy service for Phase 5 bring-up. 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. -- Supports session status and logout. +- Supports enrollment, session status, and logout. Notes: -- Session login uses `fido2_probe.py --json` command success as auth gate for now. -- This is a Phase 5 starter and not a final production auth design. +- Default runtime still uses the legacy card-presence probe gate. +- 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 import argparse +import base64 +import http.client import json +import queue +import re import secrets import ssl import subprocess @@ -29,6 +34,24 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlparse 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 = """ @@ -146,7 +169,7 @@ HTML = """

ChromeCard Proxy Portal

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.

@@ -155,9 +178,14 @@ HTML = """

Enrollment

+ +
+ + +
Stored username: none
@@ -185,6 +213,7 @@ HTML = """ const EXP_KEY = "chromecard.proxy.expires_at"; const logNode = document.getElementById("log"); const usernameNode = document.getElementById("username"); + const displayNameNode = document.getElementById("displayName"); const storedUserNode = document.getElementById("storedUser"); const sessionActiveNode = document.getElementById("sessionActive"); @@ -226,7 +255,8 @@ HTML = """ document.getElementById("enrollBtn").addEventListener("click", async () => { try { 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); syncState(); log("Enrollment updated", data); @@ -242,11 +272,55 @@ HTML = """ const data = await resp.json(); if (!resp.ok) throw new Error(JSON.stringify(data)); log("Enrollment status", data); + if (data.display_name) { + displayNameNode.value = data.display_name; + } } catch (err) { 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 () => { try { const username = usernameNode.value.trim() || getStoredUser(); @@ -307,30 +381,149 @@ class Session: @dataclass class Enrollment: 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: def __init__( self, session_ttl_s: int, + auth_mode: str, auth_command: str, server_base_url: str, server_ca_file: str | None, + server_max_connections: int, proxy_token: str, enrollment_db: Path, + rp_id: str, + rp_name: str, + origin: str, ): self.session_ttl_s = session_ttl_s + self.auth_mode = auth_mode self.auth_command = auth_command self.server_base_url = server_base_url.rstrip("/") self.server_ca_file = server_ca_file self.proxy_token = proxy_token self.enrollment_db = enrollment_db + self.rp_id = rp_id + self.origin = origin self.lock = threading.Lock() self.sessions: dict[str, Session] = {} self.enrollments: dict[str, Enrollment] = {} + self.rp = PublicKeyCredentialRpEntity(id=rp_id, name=rp_name) + self.fido_server = Fido2Server(self.rp) + self.client_data_collector = ( + ProxyClientDataCollector(origin=origin, rp_id=rp_id) if ProxyClientDataCollector else None + ) + self.upstream = UpstreamPool( + server_base_url=self.server_base_url, + server_ca_file=self.server_ca_file, + max_connections=server_max_connections, + ) 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: return time.time() @@ -373,39 +566,156 @@ class ProxyState: username = str(item.get("username", "")).strip() if not username: continue - enrolled_at = int(item.get("enrolled_at", int(self._now()))) - self.enrollments[username] = Enrollment(username=username, enrolled_at=enrolled_at) + created_at = int(item.get("created_at", item.get("enrolled_at", int(self._now())))) + 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: self.enrollments = {} def _save_enrollments_locked(self) -> None: self.enrollment_db.parent.mkdir(parents=True, exist_ok=True) 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) ] self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n") - def register_enrollment(self, username: str) -> tuple[bool, Enrollment]: - username = username.strip() - enrolled_at = int(self._now()) + def _new_fido_client(self) -> Fido2Client: + try: + device = next(CtapHidDevice.list_devices()) + except StopIteration as exc: + raise RuntimeError("no CTAP HID devices found") from exc + # 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: - existing = self.enrollments.get(username) + existing = self.enrollments.get(canonical) if existing: - return False, existing - enrollment = Enrollment(username=username, enrolled_at=enrolled_at) - self.enrollments[username] = enrollment + raise FileExistsError("user already enrolled") + enrollment = Enrollment( + username=canonical, + display_name=pretty, + created_at=now, + updated_at=now, + ) + self.enrollments[canonical] = enrollment 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: + try: + canonical = normalize_username(username) + except ValueError: + return None with self.lock: - return self.enrollments.get(username.strip()) + return self.enrollments.get(canonical) def has_enrollment(self, username: str) -> bool: 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: proc = subprocess.run( self.auth_command, @@ -426,33 +736,107 @@ class ProxyState: return True, "card presence check succeeded" - def fetch_counter(self) -> tuple[int, dict[str, Any]]: - url = f"{self.server_base_url}/resource/counter" - req = Request(url, method="POST") - req.add_header("X-Proxy-Token", self.proxy_token) - req.add_header("Content-Type", "application/json") - body = b"{}" - ssl_context = None - if self.server_base_url.startswith("https://"): - ssl_context = ssl.create_default_context(cafile=self.server_ca_file) + def _authenticate_with_direct_fido2(self, username: str) -> tuple[bool, str]: + enrollment = self.get_enrollment(username) + if not enrollment: + return False, "user not enrolled" + if not enrollment.credential_data_b64: + return False, "user has no registered credential" try: - with urlopen(req, data=body, timeout=5, context=ssl_context) as resp: - data = json.loads(resp.read().decode("utf-8")) - return resp.status, data - except HTTPError as exc: + credential = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64)) + # Keep UV explicitly discouraged here. On the current card/library stack, + # asking for stronger UV flows immediately trips PIN/UV capability errors. + 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: - data = json.loads(exc.read().decode("utf-8")) + data = json.loads(raw.decode("utf-8")) if raw else {} except Exception: - data = {"ok": False, "error": f"server http error {exc.code}"} - return exc.code, data - except URLError as exc: - return 502, {"ok": False, "error": f"server unavailable: {exc.reason}"} + data = {"ok": False, "error": f"server http error {resp.status}"} + return resp.status, data + except (http.client.HTTPException, OSError, ssl.SSLError) as exc: + return 502, {"ok": False, "error": f"server unavailable: {exc}"} except Exception as exc: return 502, {"ok": False, "error": f"server call failed: {exc}"} + finally: + self._release(conn, reusable) class Handler(BaseHTTPRequestHandler): state: ProxyState + protocol_version = "HTTP/1.1" def _json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") @@ -477,6 +861,11 @@ class Handler(BaseHTTPRequestHandler): return {} 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: value = self.headers.get("Authorization", "") if not value.startswith("Bearer "): @@ -514,6 +903,9 @@ class Handler(BaseHTTPRequestHandler): if path.startswith("/enroll/status"): self._enroll_status() return + if path == "/enroll/list": + self._enroll_list() + return self.send_error(404) def do_POST(self) -> None: # noqa: N802 @@ -524,6 +916,12 @@ class Handler(BaseHTTPRequestHandler): if path == "/enroll/register": self._enroll_register() return + if path == "/enroll/update": + self._enroll_update() + return + if path == "/enroll/delete": + self._enroll_delete() + return if path == "/session/status": self._session_status() return @@ -542,15 +940,16 @@ class Handler(BaseHTTPRequestHandler): self._json(400, {"ok": False, "error": "invalid json"}) return - username = str(data.get("username", "")).strip() - if not username: - self._json(400, {"ok": False, "error": "username required"}) + try: + username = normalize_username(str(data.get("username", ""))) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) return if not self.state.has_enrollment(username): self._json(403, {"ok": False, "error": "user not enrolled", "username": username}) return - ok, message = self.state.authenticate_with_card() + ok, message = self.state.authenticate_with_card(username) if not ok: self._json(401, {"ok": False, "error": "card auth failed", "details": message}) return @@ -564,7 +963,7 @@ class Handler(BaseHTTPRequestHandler): "session_token": token, "expires_at": int(expires_at), "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"}) return - username = str(data.get("username", "")).strip() - if not username: - self._json(400, {"ok": False, "error": "username required"}) + try: + enrollment = self.state.register_enrollment( + 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 - created, enrollment = self.state.register_enrollment(username) - self._json( - 200, - { - "ok": True, - "username": enrollment.username, - "enrolled_at": enrollment.enrolled_at, - "created": created, - }, - ) + self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at)) + + def _enroll_update(self) -> None: + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + 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: parsed = urlparse(self.path) @@ -608,16 +1043,14 @@ class Handler(BaseHTTPRequestHandler): if not enrollment: self._json(404, {"ok": False, "error": "user not enrolled", "username": username}) return - self._json( - 200, - { - "ok": True, - "username": enrollment.username, - "enrolled_at": enrollment.enrolled_at, - }, - ) + self._json(200, enrollment_payload(enrollment)) + + def _enroll_list(self) -> None: + users = [enrollment_payload(item) for item in self.state.list_enrollments()] + self._json(200, {"ok": True, "users": users}) def _session_status(self) -> None: + self._discard_request_body() got = self._require_session() if not got: return @@ -633,6 +1066,7 @@ class Handler(BaseHTTPRequestHandler): ) def _session_logout(self) -> None: + self._discard_request_body() token = self._bearer_token() if not token: self._json(401, {"ok": False, "error": "missing bearer token"}) @@ -641,6 +1075,7 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "invalidated": removed}) def _resource_counter(self) -> None: + self._discard_request_body() got = self._require_session() if not got: 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-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( + "--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( "--auth-command", 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( "--server-base-url", @@ -681,6 +1137,12 @@ def parse_args() -> argparse.Namespace: "--server-ca-file", 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( "--proxy-token", default="dev-proxy-token", @@ -703,11 +1165,16 @@ def main() -> int: state = ProxyState( session_ttl_s=args.session_ttl, + auth_mode=args.auth_mode, auth_command=args.auth_command, server_base_url=args.server_base_url, server_ca_file=args.server_ca_file, + server_max_connections=args.server_max_connections, proxy_token=args.proxy_token, enrollment_db=Path(args.enrollment_db), + rp_id=args.rp_id, + rp_name=args.rp_name, + origin=args.origin, ) Handler.state = state server = ThreadingHTTPServer((args.host, args.port), Handler) diff --git a/k_server_app.py b/k_server_app.py index afe3868..4831a33 100644 --- a/k_server_app.py +++ b/k_server_app.py @@ -34,6 +34,7 @@ class ServerState: class Handler(BaseHTTPRequestHandler): state: ServerState + protocol_version = "HTTP/1.1" def _json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") @@ -43,6 +44,11 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() 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: return self.headers.get("X-Proxy-Token") == self.state.proxy_token @@ -65,6 +71,7 @@ class Handler(BaseHTTPRequestHandler): if path != "/resource/counter": self.send_error(404) return + self._discard_request_body() if not self._is_proxy_authorized(): self._json(401, {"ok": False, "error": "unauthorized proxy"}) return diff --git a/phase5_chain_regression.sh b/phase5_chain_regression.sh new file mode 100755 index 0000000..dc8dbd5 --- /dev/null +++ b/phase5_chain_regression.sh @@ -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 diff --git a/phase65_concurrency_probe.py b/phase65_concurrency_probe.py new file mode 100644 index 0000000..00924c4 --- /dev/null +++ b/phase65_concurrency_probe.py @@ -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()) diff --git a/raw_ctap_probe.py b/raw_ctap_probe.py new file mode 100644 index 0000000..951a29f --- /dev/null +++ b/raw_ctap_probe.py @@ -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()) From 689587629a4a2687c44dd18801c5023cfa7e51b5 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 15:49:52 +0200 Subject: [PATCH 14/24] Harden direct auth path and regression helper --- PHASE5_RUNBOOK.md | 16 ++- Setup.md | 40 ++++++- Workplan.md | 26 +++-- ctaphid_init_probe.py | 74 +++++++++++++ k_proxy_app.py | 207 ++++++++++++++++++++++++++++--------- phase5_chain_regression.sh | 56 +++++++++- raw_ctap_probe.py | 28 +++-- 7 files changed, 373 insertions(+), 74 deletions(-) create mode 100644 ctaphid_init_probe.py diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index ed59da4..f61cc51 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -203,4 +203,18 @@ Verified result on 2026-04-25: - `/dev/hidraw0` and `/dev/hidraw1` are visible in `k_proxy` again - `/dev/hidraw0` opens successfully as the normal user, but `/dev/hidraw1` is still permission-denied - raw `makeCredential` still shows no card prompt, so the hang is before the firmware confirmation UI - - next step is to identify which hidraw interface `python-fido2` is selecting + - hidraw inspection confirms `/dev/hidraw0` is the real FIDO interface and `/dev/hidraw1` is a separate vendor HID interface + - manual CTAPHID `INIT` written directly to `/dev/hidraw0` gets no reply at all within `3s` + - rerunning `webauthn_local_demo.py` inside `k_proxy` also shows no card prompt on register + - next step is to recover the USB/Qubes transport path before retrying direct auth + - after a full power cycle and reattach, manual CTAPHID `INIT` replies again and `webauthn_local_demo.py` registration succeeds again + - direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeds again after pressing `yes` on the card + - `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and explicit `/dev/hidraw0` + - after additional fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, `/enroll/register` now succeeds again for `directtest` + - `/session/login` for `directtest` now also succeeds after card confirmation and returns `auth_mode: "fido2_assertion"` + - `/session/status` succeeds + - protected `/resource/counter` succeeds again through `k_proxy -> k_server` + - `/session/logout` succeeds + - post-logout protected access returns `401` + - temporary direct-mode hidraw lifetime logging was removed again after diagnosis + - `phase5_chain_regression.sh` now supports card-interactive direct auth via `--interactive-card --expect-auth-mode fido2_assertion` diff --git a/Setup.md b/Setup.md index c1d4175..c0b39a8 100644 --- a/Setup.md +++ b/Setup.md @@ -544,6 +544,42 @@ Session note (2026-04-25, direct FIDO2 auth attempt): - direct node-open check confirms `/dev/hidraw0` is readable as the normal user - `/dev/hidraw1` still returns `PermissionError: [Errno 13] Permission denied` - raw `makeCredential` probe still produced no on-card registration prompt, so the host path is hanging before the firmware Yes/No UI + - hidraw mapping confirms `/dev/hidraw0` is the FIDO interface: + - report descriptor begins with usage page `0xF1D0` + - `get_descriptor('/dev/hidraw0')` returns `report_size_in=64`, `report_size_out=64` + - `/dev/hidraw1` is a separate vendor HID interface with usage page `0xFF00` + - stale Python probes holding `/dev/hidraw0` were cleared, but behavior did not change + - a manual CTAPHID `INIT` packet sent directly to `/dev/hidraw0` writes successfully and still gets no response within `3s` + - this places the current blocker below `python-fido2`: raw HID traffic is not getting a CTAPHID reply after the latest reattach + - `webauthn_local_demo.py` was re-run inside `k_proxy` after reattach and still produced no card prompt on register + - that confirms the current failure is below both the browser WebAuthn path and the direct `python-fido2` path + - after a full power cycle and reattach, manual CTAPHID `INIT` on `/dev/hidraw0` started replying again + - `webauthn_local_demo.py` register in `k_proxy` then succeeded again, confirming the card transport was recovered by the power cycle + - direct host-side registration via `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeded again after pressing `yes` on the card + - returned credential material included: + - `fmt="none"` + - credential id `7986cfcf45663f625eb7fc7b52640d83cf3d0e8a6627eeadaba3126406b1e0b8` + - this confirms the recovered direct path now reaches the real card confirmation UI and completes CTAP2 `makeCredential` + - `k_proxy_app.py --auth-mode fido2-direct` was then patched to: + - use low-level CTAP2 instead of the higher-level `Fido2Client` registration/assertion calls + - open the explicit FIDO node `/dev/hidraw0` instead of scanning devices + - cache the direct device handle instead of reopening it for each operation + - current remaining blocker: + - was narrowed through repeated retries to a mix of hidraw node disappearance, older `python-fido2` response-mapping requirements, and CTAP payload-shape mismatches + - latest verified state: + - after reattach with healthy CTAPHID `INIT`, real app registration through `k_proxy_app.py --auth-mode fido2-direct` now succeeds + - `/enroll/register` for `directtest` returned `ok=true` and `has_credential=true` + - real app login through `/session/login` for `directtest` also now succeeds after card confirmation + - returned `auth_mode` is `fido2_assertion` + - session status succeeds + - protected `/resource/counter` access succeeds again through `k_proxy -> k_server` + - logout succeeds + - post-logout protected access returns `401` + - temporary direct-mode hidraw lifetime logging has been removed again after diagnosis + - `/home/user/chromecard/phase5_chain_regression.sh` now supports the direct-auth baseline via: + - `--interactive-card` + - `--login-timeout` + - `--expect-auth-mode fido2_assertion` - Practical outcome for this session: - the experimental direct mode is kept in code for follow-up work - the deployed `k_proxy` service was restored to default `probe` mode @@ -616,7 +652,7 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="06 - `/dev/hidraw*` must exist in `k_proxy` - `fido2_probe.py --list` must detect the card before the raw Yes/No probe can continue - Identify why the host probe hangs before card UI even with `/dev/hidraw0` readable: - - determine which hidraw interface `python-fido2` is selecting on `k_proxy` - - determine whether the blocked path is on the second HID interface or in the Qubes USB mediation layer + - determine why CTAPHID `INIT` on the correct FIDO hidraw node receives no reply after reattach + - likely recovery targets are the Qubes USB mediation path, a fresh USB reassign, or a `k_proxy` VM/device reset - Precise ownership split of session/user state between `k_proxy` and `k_server`. - Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds). diff --git a/Workplan.md b/Workplan.md index 3f6a73f..eb7ea69 100644 --- a/Workplan.md +++ b/Workplan.md @@ -254,6 +254,12 @@ Status (2026-04-25): - `/home/user/chromecard/raw_ctap_probe.py` now exists for lower-level CTAP2 probing with keepalive/error logging - latest retry result: after reattaching the card, `k_proxy` again exposes `/dev/hidraw0` and `/dev/hidraw1`, but raw `makeCredential` still reaches no Yes/No card prompt - `/dev/hidraw0` opens successfully as the normal user; `/dev/hidraw1` is still permission-denied + - manual CTAPHID testing now shows `/dev/hidraw0` is the correct FIDO interface and a direct `INIT` write gets no response at all + - rerunning `webauthn_local_demo.py` inside `k_proxy` also still gives no card prompt, so the current break is below both browser WebAuthn and direct host probes + - after a full power cycle and reattach, manual CTAPHID `INIT` replies again and browser registration in `webauthn_local_demo.py` succeeds again + - direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` now also succeeds again after card confirmation + - `k_proxy_app.py --auth-mode fido2-direct` has been moved onto low-level CTAP2 and explicit `/dev/hidraw0` + - after repeated fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, real app registration now succeeds for `directtest` ## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server` @@ -293,14 +299,18 @@ Status (2026-04-25): - Browser traffic goes only to `k_proxy`. Immediate next action: -- Determine which hidraw interface the host CTAP stack is actually selecting on `k_proxy`. -- Verify which interface is blocked: - - map `/dev/hidraw0` and `/dev/hidraw1` to their USB/HID descriptors - - determine whether `python-fido2` is trying to use the permission-blocked interface -- Then retry: - - `ssh k_proxy "python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost"` -- Stop before the raw probe and tell the user explicitly to press `yes` or `no` on the card. -- Validate end-to-end login to `k_server` resource through proxy chain. +Immediate next action: +- Preserve the now-working direct auth path and record it as the current baseline. +- Verified end-to-end state: + - direct `/enroll/register` succeeds for `directtest` + - direct `/session/login` succeeds for `directtest` + - `/session/status` succeeds + - protected `/resource/counter` succeeds through `k_proxy -> k_server` + - `/session/logout` succeeds + - post-logout protected access returns `401` +- Next work should be cleanup/hardening: + - decide whether to keep `directtest` enrollment + - rerun `phase5_chain_regression.sh --interactive-card --expect-auth-mode fido2_assertion` against the current direct-auth baseline Exit criteria: - Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. diff --git a/ctaphid_init_probe.py b/ctaphid_init_probe.py new file mode 100644 index 0000000..69a8cf9 --- /dev/null +++ b/ctaphid_init_probe.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Manual CTAPHID INIT probe for a specific hidraw node. + +This bypasses python-fido2's device bootstrap so we can see whether the raw HID +transport itself exchanges packets on the expected FIDO interface. +""" + +from __future__ import annotations + +import argparse +import os +import secrets +import select +import struct +import sys +from pathlib import Path + + +CTAPHID_INIT = 0x06 +TYPE_INIT = 0x80 +BROADCAST_CID = 0xFFFFFFFF + + +def build_init_packet(nonce: bytes) -> bytes: + frame = struct.pack(">IBH", BROADCAST_CID, TYPE_INIT | CTAPHID_INIT, len(nonce)) + nonce + return b"\0" + frame.ljust(64, b"\0") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Manual CTAPHID INIT probe") + parser.add_argument("--device-path", default="/dev/hidraw0") + parser.add_argument("--timeout", type=float, default=3.0) + args = parser.parse_args() + + path = Path(args.device_path) + if not path.exists(): + print(f"missing device: {path}", file=sys.stderr) + return 2 + + nonce = secrets.token_bytes(8) + packet = build_init_packet(nonce) + print(f"device={path}") + print(f"nonce={nonce.hex()}") + print(f"write_len={len(packet)}") + print(f"write_hex={packet.hex()}") + + fd = os.open(str(path), os.O_RDWR) + try: + written = os.write(fd, packet) + print(f"written={written}") + poller = select.poll() + poller.register(fd, select.POLLIN) + events = poller.poll(int(args.timeout * 1000)) + print(f"events={events}") + if not events: + print("timeout_waiting_for_response") + return 1 + response = os.read(fd, 64) + print(f"read_len={len(response)}") + print(f"read_hex={response.hex()}") + if len(response) >= 24: + cid, cmd, bc = struct.unpack(">IBH", response[:7]) + print(f"resp_cid=0x{cid:08x}") + print(f"resp_cmd=0x{cmd:02x}") + print(f"resp_bc={bc}") + print(f"resp_payload={response[7:7+bc].hex()}") + return 0 + finally: + os.close(fd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/k_proxy_app.py b/k_proxy_app.py index 907bce4..346c82e 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -34,23 +34,29 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen +import fido2.features from fido2.client import Fido2Client, UserInteraction, verify_rp_id +from fido2.ctap2 import Ctap2 from fido2.hid import CtapHidDevice +from fido2.hid.linux import get_descriptor, open_connection from fido2.server import Fido2Server from fido2.webauthn import ( AttestedCredentialData, + AttestationObject, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + AuthenticationResponse, + CollectedClientData, PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, + RegistrationResponse, UserVerificationRequirement, ) -try: - from fido2.client import ClientDataCollector, CollectedClientData -except ImportError: - ClientDataCollector = None - CollectedClientData = None +if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None: + fido2.features.webauthn_json_mapping.enabled = True HTML = """ @@ -420,6 +426,46 @@ def b64u_decode(data: str) -> bytes: return base64.urlsafe_b64decode((data + pad).encode("ascii")) +def direct_ctap_key_params() -> list[dict[str, Any]]: + # Match the raw probe's narrower algorithm set. The broader default list from + # Fido2Server.register_begin was still hitting post-confirmation I/O errors. + return [ + {"type": "public-key", "alg": -7}, + {"type": "public-key", "alg": -257}, + ] + + +def direct_ctap_rp(rp: PublicKeyCredentialRpEntity) -> dict[str, Any]: + return {"id": rp.id, "name": rp.name} + + +def direct_ctap_user(user: PublicKeyCredentialUserEntity) -> dict[str, Any]: + user_id = user.id + if isinstance(user_id, bytes): + # Match the raw probe's ASCII user-id shape rather than sending opaque + # binary bytes into the card path. + user_id = user_id.hex().encode("ascii") + return { + "id": user_id, + "name": user.name, + "displayName": user.display_name or user.name, + } + + +def direct_ctap_allow_list( + creds: list[Any] | None, +) -> list[dict[str, Any]] | None: + if not creds: + return None + out: list[dict[str, Any]] = [] + for cred in creds: + cred_id = getattr(cred, "id", None) + if cred_id is None and isinstance(cred, dict): + cred_id = cred.get("id") + out.append({"type": "public-key", "id": cred_id}) + return out + + def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) -> dict[str, Any]: payload: dict[str, Any] = { "ok": True, @@ -434,41 +480,6 @@ def enrollment_payload(enrollment: "Enrollment", *, created: bool | None = None) return payload -if ClientDataCollector is not None and CollectedClientData is not None: - - class ProxyClientDataCollector(ClientDataCollector): - def __init__(self, origin: str, rp_id: str): - if not verify_rp_id(rp_id, origin): - raise ValueError(f"origin {origin!r} is not valid for rp_id {rp_id!r}") - self.origin = origin - self.rp_id = rp_id - - def collect_client_data( - self, - options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions, - ) -> tuple[CollectedClientData, str]: - if isinstance(options, PublicKeyCredentialCreationOptions): - request_type = "webauthn.create" - requested_rp_id = options.rp.id - challenge = options.challenge - elif isinstance(options, PublicKeyCredentialRequestOptions): - request_type = "webauthn.get" - requested_rp_id = options.rp_id - challenge = options.challenge - else: - raise TypeError(f"unsupported options type: {type(options)!r}") - if requested_rp_id != self.rp_id: - raise ValueError(f"rp_id mismatch: expected {self.rp_id}, got {requested_rp_id}") - return CollectedClientData.create( - type=request_type, - challenge=challenge, - origin=self.origin, - ), self.rp_id - -else: - ProxyClientDataCollector = None - - class ProxyUserInteraction(UserInteraction): def prompt_up(self) -> None: print("Touch the ChromeCard to continue...", flush=True) @@ -493,6 +504,7 @@ class ProxyState: rp_id: str, rp_name: str, origin: str, + direct_device_path: str, ): self.session_ttl_s = session_ttl_s self.auth_mode = auth_mode @@ -503,14 +515,15 @@ class ProxyState: self.enrollment_db = enrollment_db self.rp_id = rp_id self.origin = origin + self.direct_device_path = direct_device_path self.lock = threading.Lock() + self.direct_device_lock = threading.RLock() + self.direct_device: CtapHidDevice | None = None self.sessions: dict[str, Session] = {} self.enrollments: dict[str, Enrollment] = {} self.rp = PublicKeyCredentialRpEntity(id=rp_id, name=rp_name) self.fido_server = Fido2Server(self.rp) - self.client_data_collector = ( - ProxyClientDataCollector(origin=origin, rp_id=rp_id) if ProxyClientDataCollector else None - ) + self.client_data_collector = None self.upstream = UpstreamPool( server_base_url=self.server_base_url, server_ca_file=self.server_ca_file, @@ -595,16 +608,61 @@ class ProxyState: self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n") def _new_fido_client(self) -> Fido2Client: - try: - device = next(CtapHidDevice.list_devices()) - except StopIteration as exc: - raise RuntimeError("no CTAP HID devices found") from exc + device = self._get_direct_device() # Newer python-fido2 builds accept a custom client-data collector, while the # VM-side package still expects an origin string plus verifier callback. if self.client_data_collector is not None: return Fido2Client(device, self.client_data_collector, ProxyUserInteraction()) return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction()) + def _open_direct_device(self) -> CtapHidDevice: + descriptor = get_descriptor(self.direct_device_path) + return CtapHidDevice(descriptor, open_connection(descriptor)) + + def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice: + with self.direct_device_lock: + if force_reopen and self.direct_device is not None: + try: + self.direct_device.close() + except Exception: + pass + self.direct_device = None + if self.direct_device is None: + self.direct_device = self._open_direct_device() + return self.direct_device + + def _with_direct_ctap2(self, action): + with self.direct_device_lock: + last_exc: Exception | None = None + for reopen in (False, True): + try: + device = self._get_direct_device(force_reopen=reopen) + return action(Ctap2(device)) + except Exception as exc: + last_exc = exc + try: + if self.direct_device is not None: + self.direct_device.close() + except Exception: + pass + self.direct_device = None + assert last_exc is not None + raise last_exc + + def _collect_client_data( + self, + request_type: str, + options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions, + ) -> CollectedClientData: + requested_rp_id = options.rp.id if isinstance(options, PublicKeyCredentialCreationOptions) else options.rp_id + if requested_rp_id != self.rp_id: + raise RuntimeError(f"rp_id mismatch: expected {self.rp_id}, got {requested_rp_id}") + return CollectedClientData.create( + type=request_type, + challenge=options.challenge, + origin=self.origin, + ) + def _user_entity(self, username: str, display_name: str | None, user_id: bytes) -> PublicKeyCredentialUserEntity: return PublicKeyCredentialUserEntity( id=user_id, @@ -646,9 +704,30 @@ class ProxyState: user_verification=UserVerificationRequirement.DISCOURAGED, ) try: + client_data = self._collect_client_data("webauthn.create", options.public_key) + attestation = self._with_direct_ctap2( + lambda ctap2: ctap2.make_credential( + client_data_hash=client_data.hash, + rp=direct_ctap_rp(options.public_key.rp), + user=direct_ctap_user(options.public_key.user), + key_params=direct_ctap_key_params(), + exclude_list=direct_ctap_allow_list(options.public_key.exclude_credentials), + options={"rk": False, "uv": False}, + ) + ) auth_data = self.fido_server.register_complete( state, - self._new_fido_client().make_credential(options.public_key), + RegistrationResponse( + id=attestation.auth_data.credential_data.credential_id, + response=AuthenticatorAttestationResponse( + client_data=client_data, + attestation_object=AttestationObject.create( + attestation.fmt, + attestation.auth_data, + attestation.att_stmt, + ), + ), + ), ) except Exception as exc: raise RuntimeError(f"card registration failed: {exc}") from exc @@ -750,9 +829,29 @@ class ProxyState: [credential], user_verification=UserVerificationRequirement.DISCOURAGED, ) - selection = self._new_fido_client().get_assertion(options.public_key) - assertion = selection.get_response(0) - self.fido_server.authenticate_complete(state, [credential], assertion) + client_data = self._collect_client_data("webauthn.get", options.public_key) + assertion = self._with_direct_ctap2( + lambda ctap2: ctap2.get_assertion( + rp_id=options.public_key.rp_id, + client_data_hash=client_data.hash, + allow_list=direct_ctap_allow_list(options.public_key.allow_credentials), + options={"up": True, "uv": False}, + ) + ) + response = assertion.assertions[0] if getattr(assertion, "assertions", None) else assertion + self.fido_server.authenticate_complete( + state, + [credential], + AuthenticationResponse( + id=response.credential["id"], + response=AuthenticatorAssertionResponse( + client_data=client_data, + authenticator_data=response.auth_data, + signature=response.signature, + user_handle=response.user.get("id") if response.user else None, + ), + ), + ) except Exception as exc: return False, f"assertion verification failed: {exc}" return True, "assertion verified" @@ -1153,6 +1252,11 @@ def parse_args() -> argparse.Namespace: default="/home/user/chromecard/k_proxy_enrollments.json", help="JSON file used to persist enrolled usernames for the prototype", ) + parser.add_argument( + "--direct-device-path", + default="/dev/hidraw0", + help="Explicit hidraw path used for direct FIDO2 mode", + ) return parser.parse_args() @@ -1175,6 +1279,7 @@ def main() -> int: rp_id=args.rp_id, rp_name=args.rp_name, origin=args.origin, + direct_device_path=args.direct_device_path, ) Handler.state = state server = ThreadingHTTPServer((args.host, args.port), Handler) diff --git a/phase5_chain_regression.sh b/phase5_chain_regression.sh index dc8dbd5..7203731 100755 --- a/phase5_chain_regression.sh +++ b/phase5_chain_regression.sh @@ -8,6 +8,10 @@ USERNAME="${USERNAME:-alice}" REQUESTS="${REQUESTS:-20}" PARALLELISM="${PARALLELISM:-8}" CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}" +LOGIN_TIMEOUT="${LOGIN_TIMEOUT:-90}" +INTERACTIVE_CARD="${INTERACTIVE_CARD:-0}" +EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE:-}" +SSH_CONFIG="${SSH_CONFIG:-/home/user/.ssh/config}" usage() { cat <<'EOF' @@ -24,6 +28,10 @@ Options: --requests N Number of counter requests to issue --parallelism N Number of concurrent workers --connect-timeout SEC SSH connect timeout + --login-timeout SEC Timeout for the interactive login request (default: 90) + --interactive-card Print card-confirmation instructions before login + --expect-auth-mode NAME Require login response auth_mode to match + --ssh-config PATH SSH config file to use (default: /home/user/.ssh/config) -h, --help Show this help text EOF } @@ -58,6 +66,22 @@ while [[ $# -gt 0 ]]; do CONNECT_TIMEOUT="$2" shift 2 ;; + --login-timeout) + LOGIN_TIMEOUT="$2" + shift 2 + ;; + --interactive-card) + INTERACTIVE_CARD=1 + shift + ;; + --expect-auth-mode) + EXPECT_AUTH_MODE="$2" + shift 2 + ;; + --ssh-config) + SSH_CONFIG="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -70,7 +94,16 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "${INTERACTIVE_CARD}" == "1" ]]; then + cat <= 1") @@ -103,7 +140,7 @@ if parallelism < 1: ctx = ssl.create_default_context(cafile=ca_file) -def post_json(path: str, payload: dict | None = None, token: str | None = None): +def post_json(path: str, payload: dict | None = None, token: str | None = None, timeout: int = 10): data = None if payload is None else json.dumps(payload).encode("utf-8") headers = {} if payload is not None: @@ -117,7 +154,7 @@ def post_json(path: str, payload: dict | None = None, token: str | None = None): method="POST", ) try: - with urllib.request.urlopen(req, context=ctx, timeout=10) as resp: + with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8") @@ -127,10 +164,23 @@ def post_json(path: str, payload: dict | None = None, token: str | None = None): payload = {"ok": False, "error": body} return exc.code, payload -status, login = post_json("/session/login", {"username": username}) +status, login = post_json("/session/login", {"username": username}, timeout=login_timeout) if status != 200 or "session_token" not in login: print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login})) raise SystemExit(1) +if expect_auth_mode and login.get("auth_mode") != expect_auth_mode: + print( + json.dumps( + { + "ok": False, + "stage": "login", + "error": "unexpected auth_mode", + "expected": expect_auth_mode, + "response": login, + } + ) + ) + raise SystemExit(1) token = login["session_token"] values = [] diff --git a/raw_ctap_probe.py b/raw_ctap_probe.py index 951a29f..8c35935 100644 --- a/raw_ctap_probe.py +++ b/raw_ctap_probe.py @@ -22,6 +22,7 @@ try: from fido2.ctap import CtapError from fido2.ctap2 import Ctap2 from fido2.hid import CtapHidDevice + from fido2.hid.linux import get_descriptor, open_connection except Exception as exc: print("Missing dependency: python-fido2", file=sys.stderr) print("Install with: python3 -m pip install fido2", file=sys.stderr) @@ -66,6 +67,18 @@ def get_ctap2(dev: CtapHidDevice) -> Ctap2: return Ctap2(dev) +def get_device(index: int, device_path: str | None) -> CtapHidDevice: + if device_path: + descriptor = get_descriptor(device_path) + return CtapHidDevice(descriptor, open_connection(descriptor)) + devs = list_devices() + if not devs: + raise SystemExit("No CTAP HID devices found.") + if index < 0 or index >= len(devs): + raise SystemExit(f"Invalid --index {index}; found {len(devs)} device(s).") + return devs[index] + + def print_json(payload: dict[str, Any]) -> None: print(json.dumps(payload, indent=2, default=_json_default)) @@ -251,6 +264,10 @@ def do_get_assertion(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[s def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Low-level CTAP2 host probe") parser.add_argument("--index", type=int, default=0, help="Device index from --list output") + parser.add_argument( + "--device-path", + help="Use a specific hidraw node such as /dev/hidraw0 instead of scanning all devices", + ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("list", help="List CTAP HID devices") @@ -277,8 +294,8 @@ def main() -> int: parser = build_parser() args = parser.parse_args() - devs = list_devices() if args.command == "list": + devs = list_devices() print_json( { "devices": [describe_device(dev) for dev in devs], @@ -286,14 +303,7 @@ def main() -> int: ) return 0 if devs else 1 - if not devs: - print("No CTAP HID devices found.", file=sys.stderr) - return 1 - if args.index < 0 or args.index >= len(devs): - print(f"Invalid --index {args.index}; found {len(devs)} device(s).", file=sys.stderr) - return 2 - - dev = devs[args.index] + dev = get_device(args.index, args.device_path) device_meta = describe_device(dev) ctap2 = get_ctap2(dev) From 1d85c21d7fab07e05338b7bfbff048bc379d3c5a Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 15:56:50 +0200 Subject: [PATCH 15/24] Add k_client browser flow demo --- PHASE5_RUNBOOK.md | 9 ++ Setup.md | 11 ++ Workplan.md | 4 +- k_client_portal.py | 338 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 343 insertions(+), 19 deletions(-) diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index f61cc51..ede6b07 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -4,6 +4,15 @@ This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse Last updated: 2026-04-25 +Related browser demo: + +- `k_client_portal.py` can now be used in `k_client` at `http://127.0.0.1:8766` to show: + - registration + - login with card approval/denial + - protected `k_server` counter access + - logout + - explicit "k_server was not called" behavior when login is denied + ## What This Prototype Covers - `k_proxy` creates short-lived sessions. diff --git a/Setup.md b/Setup.md index c0b39a8..b3f79f3 100644 --- a/Setup.md +++ b/Setup.md @@ -359,6 +359,17 @@ Session note (2026-04-25, browser target moved 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 +Session note (2026-04-25, k_client browser flow page): +- `k_client_portal.py` now also serves a local browser demo page again on `http://127.0.0.1:8766` inside `k_client`. +- The page is useful as an operator/demo surface: + - register user + - login with card approval or denial in `k_proxy` + - call the protected `k_server` counter + - logout +- It also makes the negative path explicit: + - if login is denied on the card, the page reports that `k_server` was not called +- Primary browser-facing app logic still lives on `k_proxy`, but the `k_client` page is now a concrete demo/control surface rather than just a redirect. + Session note (2026-04-25, provisional enrollment hardening): - The enrollment contract in `k_proxy` is now explicit but provisional. - Current prototype enrollment rules: diff --git a/Workplan.md b/Workplan.md index eb7ea69..6dd0fc7 100644 --- a/Workplan.md +++ b/Workplan.md @@ -319,7 +319,7 @@ Status (2026-04-25): - Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`. - Current prototype flow: - browser now targets `k_proxy` directly over `https://127.0.0.1:9771` - - `k_client_portal.py` remains only as a temporary bridge page + - `k_client_portal.py` also serves a local browser flow page on `http://127.0.0.1:8766` - `k_proxy` continues to authenticate with the card and forward to `k_server` - Verified end-to-end through the portal: - enroll `alice` @@ -335,7 +335,7 @@ Status (2026-04-25): - direct browser target is on `k_proxy` - login/resource flow is integrated on the direct proxy path - enrollment now has a real client->proxy path - - the `k_client` bridge remains only for transition/compatibility + - the `k_client` page is now a usable demo/operator surface in addition to the direct proxy path - final enrollment semantics are still provisional Status (2026-04-25, enrollment hardening): diff --git a/k_client_portal.py b/k_client_portal.py index 1622eaa..df8cbee 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -27,7 +27,7 @@ HTML = """ - ChromeCard Client Bridge + ChromeCard Client Flow
-
-

ChromeCard Client Bridge

+
+

ChromeCard Client Flow

- Browser traffic should now target `k_proxy` directly at `https://127.0.0.1:9771/`. - This local service remains only as a temporary bridge and compatibility shim. + This page runs in `k_client` and drives the real split-VM flow: + register a user, ask the card in `k_proxy` for approval, and then call + the protected counter on `k_server` only if auth succeeds.

- -

     
+ +
+
+
+
+ Browser: k_client + Card: k_proxy + Resource: k_server +
+ + + +
+ + + + + + +
+ +
+ Registration: press yes on the card to enroll. + Login: press yes to allow the identity check, or + no to deny it. If login is denied, this page will + show that `k_server` was not called. +
+ +
+
+
1
+
+ Register user
+ Creates or refreshes the enrolled identity in `k_proxy`. +
+
+
+
2
+
+ Authenticate with the card
+ `k_proxy` asks the card for approval. Press `yes` to continue or `no` to reject. +
+
+
+
3
+
+ Call `k_server`
+ The protected counter is only reached when login created a valid session. +
+
+
+
+ +
+
+

Client State

+
Enrolled user: unknown
+
Session: unknown
+
Expires: unknown
+
+
+

Flow Result

+
No flow run yet.
+
+
+
+ +
+

Event Log

+

+      
+
From e57f8a446fb05573d5495876661f89e0a4c5c0de Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 19:29:28 +0200 Subject: [PATCH 16/24] Improve portal enrollment controls and direct hidraw selection --- PHASE5_RUNBOOK.md | 4 +- Setup.md | 8 +++ Workplan.md | 5 +- k_client_portal.py | 167 ++++++++++++++++++++++++++++++++++++++++++--- k_proxy_app.py | 25 ++++++- 5 files changed, 197 insertions(+), 12 deletions(-) diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index ede6b07..d5f4446 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -8,6 +8,8 @@ Related browser demo: - `k_client_portal.py` can now be used in `k_client` at `http://127.0.0.1:8766` to show: - registration + - current registered-user list from `k_proxy` + - unregister from the browser page - login with card approval/denial - protected `k_server` counter access - logout @@ -218,7 +220,7 @@ Verified result on 2026-04-25: - next step is to recover the USB/Qubes transport path before retrying direct auth - after a full power cycle and reattach, manual CTAPHID `INIT` replies again and `webauthn_local_demo.py` registration succeeds again - direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeds again after pressing `yes` on the card - - `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and explicit `/dev/hidraw0` + - `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and to auto-detect the working `/dev/hidraw*` node when the card re-enumerates - after additional fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, `/enroll/register` now succeeds again for `directtest` - `/session/login` for `directtest` now also succeeds after card confirmation and returns `auth_mode: "fido2_assertion"` - `/session/status` succeeds diff --git a/Setup.md b/Setup.md index b3f79f3..80d938b 100644 --- a/Setup.md +++ b/Setup.md @@ -366,6 +366,11 @@ Session note (2026-04-25, k_client browser flow page): - login with card approval or denial in `k_proxy` - call the protected `k_server` counter - logout +- The page now also exposes current proxy enrollment state: + - shows the registered users visible in `k_proxy` + - lets the operator select a listed user into the username field + - lets the operator unregister users from the browser page + - login now uses the current username field instead of only the portal's last remembered user - It also makes the negative path explicit: - if login is denied on the card, the page reports that `k_server` was not called - Primary browser-facing app logic still lives on `k_proxy`, but the `k_client` page is now a concrete demo/control surface rather than just a redirect. @@ -586,6 +591,9 @@ Session note (2026-04-25, direct FIDO2 auth attempt): - protected `/resource/counter` access succeeds again through `k_proxy -> k_server` - logout succeeds - post-logout protected access returns `401` + - direct mode no longer depends on a fixed `/dev/hidraw0` path + - after a later re-enumeration where the card appeared on `/dev/hidraw1`, `k_proxy_app.py` was patched to probe available `/dev/hidraw*` nodes and select the first working CTAPHID device automatically + - browser registration then worked again without changing the configured `--direct-device-path` - temporary direct-mode hidraw lifetime logging has been removed again after diagnosis - `/home/user/chromecard/phase5_chain_regression.sh` now supports the direct-auth baseline via: - `--interactive-card` diff --git a/Workplan.md b/Workplan.md index 6dd0fc7..8be6eef 100644 --- a/Workplan.md +++ b/Workplan.md @@ -258,7 +258,7 @@ Status (2026-04-25): - rerunning `webauthn_local_demo.py` inside `k_proxy` also still gives no card prompt, so the current break is below both browser WebAuthn and direct host probes - after a full power cycle and reattach, manual CTAPHID `INIT` replies again and browser registration in `webauthn_local_demo.py` succeeds again - direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` now also succeeds again after card confirmation - - `k_proxy_app.py --auth-mode fido2-direct` has been moved onto low-level CTAP2 and explicit `/dev/hidraw0` + - `k_proxy_app.py --auth-mode fido2-direct` has been moved onto low-level CTAP2 with hidraw auto-detection; it still accepts `--direct-device-path`, but no longer breaks if the card re-enumerates onto `/dev/hidraw1` - after repeated fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, real app registration now succeeds for `directtest` ## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server` @@ -321,6 +321,9 @@ Status (2026-04-25): - browser now targets `k_proxy` directly over `https://127.0.0.1:9771` - `k_client_portal.py` also serves a local browser flow page on `http://127.0.0.1:8766` - `k_proxy` continues to authenticate with the card and forward to `k_server` + - the `k_client` page now also lists registered users from `k_proxy` + - the `k_client` page can unregister users from the browser + - the portal login action now uses the current username field instead of only the remembered local user - Verified end-to-end through the portal: - enroll `alice` - login succeeds diff --git a/k_client_portal.py b/k_client_portal.py index df8cbee..82f4b13 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -146,6 +146,41 @@ HTML = """ font-size: 0.95rem; color: var(--muted); } + #usersList { + display: grid; + gap: 8px; + margin-top: 12px; + } + .user-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + background: rgba(255,255,255,0.86); + } + .user-meta { + display: grid; + gap: 2px; + } + .user-name { + font-weight: 600; + } + .user-subtle { + color: var(--muted); + font-size: 0.9rem; + } + .user-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .small { + padding: 8px 10px; + font-size: 0.92rem; + } .badge { display: inline-block; padding: 4px 8px; @@ -277,6 +312,11 @@ HTML = """
Session: unknown
Expires: unknown
+
+

Registered Users

+
Loading users...
+
+

Flow Result

No flow run yet.
@@ -298,6 +338,8 @@ HTML = """ const stateUser = document.getElementById("stateUser"); const stateSession = document.getElementById("stateSession"); const stateExpires = document.getElementById("stateExpires"); + const usersSummary = document.getElementById("usersSummary"); + const usersList = document.getElementById("usersList"); const usernameInput = document.getElementById("username"); const buttons = Array.from(document.querySelectorAll("button")); @@ -337,18 +379,70 @@ HTML = """ return data; } + function renderUsers(users) { + usersList.innerHTML = ""; + if (!users.length) { + usersSummary.textContent = "No registered users in k_proxy."; + return; + } + usersSummary.textContent = `${users.length} registered user${users.length === 1 ? "" : "s"} visible in k_proxy.`; + for (const user of users) { + const row = document.createElement("div"); + row.className = "user-row"; + + const meta = document.createElement("div"); + meta.className = "user-meta"; + meta.innerHTML = + `
${user.username}
` + + `
Credential present: ${user.has_credential ? "yes" : "no"}
`; + + const actions = document.createElement("div"); + actions.className = "user-actions"; + + const useBtn = document.createElement("button"); + useBtn.className = "ghost small"; + useBtn.textContent = "Use"; + useBtn.addEventListener("click", () => { + usernameInput.value = user.username; + flowResult.textContent = `Selected user ${user.username}.`; + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "secondary small"; + deleteBtn.textContent = "Unregister"; + deleteBtn.addEventListener("click", async () => { + setBusy(true); + try { await deleteUser(user.username); } finally { setBusy(false); } + }); + + actions.appendChild(useBtn); + actions.appendChild(deleteBtn); + row.appendChild(meta); + row.appendChild(actions); + usersList.appendChild(row); + } + } + + async function refreshUsers() { + const resp = await fetch("/api/enrollments"); + const data = await resp.json(); + renderUsers(data.users || []); + return data; + } + async function registerUser() { hintBox.innerHTML = "Card step: if the card shows a registration prompt, press yes to enroll this user."; const result = await api("/api/enroll", {username: username()}); log("Register user", result); flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed."; await refreshState(); + await refreshUsers(); return result; } async function loginUser() { hintBox.innerHTML = "Card step: if the card shows an authentication prompt, press yes to allow login or no to deny it."; - const result = await api("/api/login", {}); + const result = await api("/api/login", {username: username()}); log("Login", result); await refreshState(); return result; @@ -372,6 +466,21 @@ HTML = """ return result; } + async function deleteUser(usernameToDelete) { + const result = await api("/api/enroll/delete", {username: usernameToDelete}); + log("Unregister user", result); + flowResult.textContent = + result.status === 200 + ? `User ${usernameToDelete} was unregistered.` + : `Could not unregister ${usernameToDelete}.`; + if (result.status === 200 && username() === usernameToDelete) { + usernameInput.value = ""; + } + await refreshState(); + await refreshUsers(); + return result; + } + async function runFlow() { setBusy(true); flowResult.textContent = "Flow running..."; @@ -421,12 +530,13 @@ HTML = """ setBusy(true); try { const state = await refreshState(); - log("State refreshed", state); + const users = await refreshUsers(); + log("State refreshed", {state, users}); } finally { setBusy(false); } }); - refreshState().then((state) => { - log("Client flow page ready", state); + Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => { + log("Client flow page ready", {state, users}); }); @@ -509,6 +619,23 @@ class ClientState: "proxy_enrollment": data, } + def list_enrollments(self) -> tuple[int, dict[str, Any]]: + return self._proxy_json("GET", "/enroll/list") + + def delete_enrollment(self, username: str) -> tuple[int, dict[str, Any]]: + username = username.strip() + if not username: + return 400, {"ok": False, "error": "username required"} + status, data = self._proxy_json("POST", "/enroll/delete", {"username": username}) + if status == 200: + with self.lock: + if self.preferred_enrollment and self.preferred_enrollment.username == username: + self.preferred_enrollment = None + self._save_preferred_enrollment_locked() + self.session_token = None + self.session_expires_at = None + return status, data + def snapshot(self) -> dict[str, Any]: with self.lock: return { @@ -523,15 +650,21 @@ class ClientState: with self.lock: return self.session_token - def login(self) -> tuple[int, dict[str, Any]]: + def login(self, username: str | None = None) -> tuple[int, dict[str, Any]]: + requested = (username or "").strip() with self.lock: - if not self.preferred_enrollment: + if requested: + username = requested + elif self.preferred_enrollment: + username = self.preferred_enrollment.username + else: return 400, {"ok": False, "error": "no enrolled user"} - username = self.preferred_enrollment.username status, data = self._proxy_json("POST", "/session/login", {"username": username}) if status == 200 and data.get("session_token"): with self.lock: + self.preferred_enrollment = EnrollmentRecord(username=username) + self._save_preferred_enrollment_locked() self.session_token = data["session_token"] self.session_expires_at = int(data.get("expires_at", 0)) or None return status, data @@ -588,6 +721,10 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/client/state": self._json(200, self.state.snapshot()) return + if path == "/api/enrollments": + status, data = self.state.list_enrollments() + self._json(status, data) + return self.send_error(404) def do_POST(self) -> None: # noqa: N802 @@ -602,7 +739,21 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result.get("ok") else 400, result) return if path == "/api/login": - status, data = self.state.login() + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + status, data = self.state.login(str(data.get("username", ""))) + self._json(status, data) + return + if path == "/api/enroll/delete": + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + status, data = self.state.delete_enrollment(str(data.get("username", ""))) self._json(status, data) return if path == "/api/status": diff --git a/k_proxy_app.py b/k_proxy_app.py index 346c82e..c411220 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -615,9 +615,30 @@ class ProxyState: return Fido2Client(device, self.client_data_collector, ProxyUserInteraction()) return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction()) + def _direct_device_candidates(self) -> list[str]: + configured = str(self.direct_device_path).strip() + candidates: list[str] = [] + if configured: + candidates.append(configured) + for path in sorted(Path("/dev").glob("hidraw*")): + as_text = str(path) + if as_text not in candidates: + candidates.append(as_text) + return candidates + def _open_direct_device(self) -> CtapHidDevice: - descriptor = get_descriptor(self.direct_device_path) - return CtapHidDevice(descriptor, open_connection(descriptor)) + last_exc: Exception | None = None + for candidate in self._direct_device_candidates(): + try: + descriptor = get_descriptor(candidate) + device = CtapHidDevice(descriptor, open_connection(descriptor)) + self.direct_device_path = candidate + return device + except Exception as exc: + last_exc = exc + if last_exc is None: + raise FileNotFoundError(f"no hidraw devices available for direct auth (configured {self.direct_device_path})") + raise last_exc def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice: with self.direct_device_lock: From bd839ea42df50981efc30a5f3400080aeb050c44 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 21:06:08 +0200 Subject: [PATCH 17/24] Add Playwright portal regression and harden direct auth --- .gitignore | 3 ++ PHASE5_RUNBOOK.md | 23 +++++++++++ Setup.md | 7 ++++ Workplan.md | 2 + k_client_portal.py | 41 +++++++++++++++--- k_proxy_app.py | 45 ++++++++++++++------ package-lock.json | 78 +++++++++++++++++++++++++++++++++++ package.json | 12 ++++++ playwright.config.js | 18 ++++++++ tests/k_client_portal.spec.js | 70 +++++++++++++++++++++++++++++++ 10 files changed, 281 insertions(+), 18 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 tests/k_client_portal.spec.js diff --git a/.gitignore b/.gitignore index abaa3f3..f100d59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ __pycache__/ *.pyc tls/ +node_modules/ +playwright-report/ +test-results/ # Keep firmware SDK tree out of this workspace-tracking repo CR_SDK_CK-main/ diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index d5f4446..08d415c 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -187,11 +187,34 @@ REQUESTS=50 PARALLELISM=12 /home/user/chromecard/phase5_chain_regression.sh /home/user/chromecard/phase5_chain_regression.sh --username alice --client-host k_client ``` +For the browser-facing `k_client` page, use the Playwright regression spec: + +```bash +npm install +npx playwright install +npm run test:k-client +``` + +Notes: + +- default target is `http://127.0.0.1:8766` +- override with `PORTAL_BASE_URL=http://127.0.0.1:8766` +- the spec expects manual card confirmation during register and login +- timeouts can be tuned with `CARD_REGISTRATION_TIMEOUT_MS` and `CARD_LOGIN_TIMEOUT_MS` +- from this host, a forwarded portal URL was used successfully: + - `PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test: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`. +- The Playwright browser regression for `k_client_portal.py` also passed end-to-end: + - register + - login + - protected counter + - logout + - unregister ## Current Limitation diff --git a/Setup.md b/Setup.md index 80d938b..2c062ef 100644 --- a/Setup.md +++ b/Setup.md @@ -371,6 +371,13 @@ Session note (2026-04-25, k_client browser flow page): - lets the operator select a listed user into the username field - lets the operator unregister users from the browser page - login now uses the current username field instead of only the portal's last remembered user +- Added a browser regression harness for the `k_client` page: + - `/home/user/chromecard/tests/k_client_portal.spec.js` + - `/home/user/chromecard/playwright.config.js` + - `/home/user/chromecard/package.json` + - intended flow: register, login, call `k_server`, logout, unregister + - verified passing live on 2026-04-25 from this host via forwarded portal URL: + - `PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client` - It also makes the negative path explicit: - if login is denied on the card, the page reports that `k_server` was not called - Primary browser-facing app logic still lives on `k_proxy`, but the `k_client` page is now a concrete demo/control surface rather than just a redirect. diff --git a/Workplan.md b/Workplan.md index 8be6eef..ded6c01 100644 --- a/Workplan.md +++ b/Workplan.md @@ -324,6 +324,8 @@ Status (2026-04-25): - the `k_client` page now also lists registered users from `k_proxy` - the `k_client` page can unregister users from the browser - the portal login action now uses the current username field instead of only the remembered local user + - a Playwright regression spec now exists for the browser flow in `tests/k_client_portal.spec.js` + - the Playwright browser regression has now passed end-to-end once from this host against a forwarded portal URL - Verified end-to-end through the portal: - enroll `alice` - login succeeds diff --git a/k_client_portal.py b/k_client_portal.py index 82f4b13..b396c13 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -550,10 +550,19 @@ class EnrollmentRecord: class ClientState: - def __init__(self, proxy_base_url: str, proxy_ca_file: str | None, enroll_db: Path): + def __init__( + self, + proxy_base_url: str, + proxy_ca_file: str | None, + enroll_db: Path, + interactive_timeout_s: float = 90.0, + default_timeout_s: float = 10.0, + ): self.proxy_base_url = proxy_base_url.rstrip("/") self.proxy_ca_file = proxy_ca_file self.enroll_db = enroll_db + self.interactive_timeout_s = interactive_timeout_s + self.default_timeout_s = default_timeout_s self.lock = threading.Lock() self.preferred_enrollment: EnrollmentRecord | None = None self.session_token: str | None = None @@ -565,7 +574,14 @@ class ClientState: return ssl.create_default_context(cafile=self.proxy_ca_file) return None - def _proxy_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]: + def _proxy_json( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout_s: float | None = None, + ) -> tuple[int, dict[str, Any]]: req = Request(f"{self.proxy_base_url}{path}", method=method) req.add_header("Content-Type", "application/json") token = self.get_session_token() @@ -573,7 +589,12 @@ class ClientState: req.add_header("Authorization", f"Bearer {token}") body = json.dumps(payload or {}).encode("utf-8") try: - with urlopen(req, data=body, timeout=10, context=self._ssl_context()) as resp: + with urlopen( + req, + data=body, + timeout=timeout_s or self.default_timeout_s, + context=self._ssl_context(), + ) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) except HTTPError as exc: try: @@ -605,7 +626,12 @@ class ClientState: username = username.strip() if not username: return {"ok": False, "error": "username required"} - status, data = self._proxy_json("POST", "/enroll/register", {"username": username}) + status, data = self._proxy_json( + "POST", + "/enroll/register", + {"username": username}, + timeout_s=self.interactive_timeout_s, + ) if status != 200: return data with self.lock: @@ -660,7 +686,12 @@ class ClientState: else: return 400, {"ok": False, "error": "no enrolled user"} - status, data = self._proxy_json("POST", "/session/login", {"username": username}) + status, data = self._proxy_json( + "POST", + "/session/login", + {"username": username}, + timeout_s=self.interactive_timeout_s, + ) if status == 200 and data.get("session_token"): with self.lock: self.preferred_enrollment = EnrollmentRecord(username=username) diff --git a/k_proxy_app.py b/k_proxy_app.py index c411220..f762927 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -516,6 +516,8 @@ class ProxyState: self.rp_id = rp_id self.origin = origin self.direct_device_path = direct_device_path + self.direct_device_configured_path = direct_device_path + self.direct_device_active_path: str | None = None self.lock = threading.Lock() self.direct_device_lock = threading.RLock() self.direct_device: CtapHidDevice | None = None @@ -616,7 +618,7 @@ class ProxyState: return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction()) def _direct_device_candidates(self) -> list[str]: - configured = str(self.direct_device_path).strip() + configured = str(self.direct_device_configured_path).strip() candidates: list[str] = [] if configured: candidates.append(configured) @@ -628,13 +630,20 @@ class ProxyState: def _open_direct_device(self) -> CtapHidDevice: last_exc: Exception | None = None + recoverable: tuple[type[Exception], ...] = (FileNotFoundError, PermissionError) for candidate in self._direct_device_candidates(): try: descriptor = get_descriptor(candidate) device = CtapHidDevice(descriptor, open_connection(descriptor)) - self.direct_device_path = candidate + self.direct_device_active_path = candidate return device except Exception as exc: + # USB re-enumeration can leave stale hidraw paths behind, and some sibling + # nodes are vendor interfaces that are not readable to the normal user. + # Skip those and keep probing for a usable CTAPHID node. + if isinstance(exc, recoverable): + last_exc = exc + continue last_exc = exc if last_exc is None: raise FileNotFoundError(f"no hidraw devices available for direct auth (configured {self.direct_device_path})") @@ -643,15 +652,24 @@ class ProxyState: def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice: with self.direct_device_lock: if force_reopen and self.direct_device is not None: - try: - self.direct_device.close() - except Exception: - pass - self.direct_device = None + self._drop_direct_device_locked() if self.direct_device is None: self.direct_device = self._open_direct_device() return self.direct_device + def _drop_direct_device_locked(self) -> None: + try: + if self.direct_device is not None: + self.direct_device.close() + except Exception: + pass + self.direct_device = None + self.direct_device_active_path = None + + def _drop_direct_device(self) -> None: + with self.direct_device_lock: + self._drop_direct_device_locked() + def _with_direct_ctap2(self, action): with self.direct_device_lock: last_exc: Exception | None = None @@ -661,12 +679,7 @@ class ProxyState: return action(Ctap2(device)) except Exception as exc: last_exc = exc - try: - if self.direct_device is not None: - self.direct_device.close() - except Exception: - pass - self.direct_device = None + self._drop_direct_device_locked() assert last_exc is not None raise last_exc @@ -768,6 +781,9 @@ class ProxyState: with self.lock: self.enrollments[canonical] = enrollment self._save_enrollments_locked() + # Freshly reopen for later assertion flow; some cards do not like immediate + # reuse of the same hidraw handle across makeCredential -> getAssertion. + self._drop_direct_device() return enrollment def register_enrollment(self, username: str, display_name: str | None) -> Enrollment: @@ -843,6 +859,9 @@ class ProxyState: if not enrollment.credential_data_b64: return False, "user has no registered credential" try: + # Start assertion from a fresh device open rather than reusing the + # post-registration handle, which has been flaky on this stack. + self._drop_direct_device() credential = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64)) # Keep UV explicitly discouraged here. On the current card/library stack, # asking for stronger UV flows immediately trips PIN/UV capability errors. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8710030 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "chromecard-browser-regression", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chromecard-browser-regression", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.54.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..17537fe --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "chromecard-browser-regression", + "private": true, + "version": "0.1.0", + "description": "Playwright regression checks for the k_client browser flow", + "scripts": { + "test:k-client": "playwright test tests/k_client_portal.spec.js" + }, + "devDependencies": { + "@playwright/test": "^1.54.2" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..67a2daf --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +// Minimal local Playwright config for the k_client browser flow. +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests", + timeout: 180_000, + expect: { + timeout: 15_000, + }, + use: { + baseURL: process.env.PORTAL_BASE_URL || "http://127.0.0.1:8766", + headless: process.env.PW_HEADLESS === "1", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + reporter: [["list"]], +}); diff --git a/tests/k_client_portal.spec.js b/tests/k_client_portal.spec.js new file mode 100644 index 0000000..424a375 --- /dev/null +++ b/tests/k_client_portal.spec.js @@ -0,0 +1,70 @@ +const { test, expect } = require("@playwright/test"); + +const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || "90000"); +const loginTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || "90000"); + +function uniqueUsername() { + return `pw_${Date.now().toString(36)}`; +} + +async function waitForActionResult(page, action, expectedText, timeoutMs) { + const flowResult = page.locator("#flowResult"); + await action(); + await expect(flowResult).toContainText(expectedText, { timeout: timeoutMs }); +} + +test.describe("k_client portal regression", () => { + test("registers, logs in, reads counter, logs out, and unregisters", async ({ page }) => { + const username = uniqueUsername(); + const usersList = page.locator("#usersList"); + const flowResult = page.locator("#flowResult"); + const sessionLine = page.locator("#stateSession"); + + test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 90_000); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "ChromeCard Client Flow" })).toBeVisible(); + await page.getByLabel("Username").fill(username); + + await test.step("Register user", async () => { + // Card step: press yes on the registration prompt. + await waitForActionResult( + page, + () => page.getByRole("button", { name: "Register User" }).click(), + "User registration succeeded.", + registrationTimeoutMs + ); + await expect(usersList).toContainText(username); + }); + + await test.step("Login", async () => { + // Card step: press yes on the authentication prompt. + await waitForActionResult( + page, + () => page.getByRole("button", { name: "Login" }).click(), + "Login succeeded. You can now call k_server.", + loginTimeoutMs + ); + await expect(sessionLine).toContainText("Session active: yes"); + }); + + await test.step("Call k_server counter", async () => { + await page.getByRole("button", { name: "Call k_server" }).click(); + await expect(flowResult).toContainText("k_server was reached. Counter value:"); + }); + + await test.step("Logout", async () => { + await page.getByRole("button", { name: "Logout" }).click(); + await expect(flowResult).toContainText("Session cleared."); + await expect(sessionLine).toContainText("Session active: no"); + }); + + await test.step("Unregister user", async () => { + const row = usersList.locator(".user-row", { hasText: username }); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: "Unregister" }).click(); + await expect(flowResult).toContainText(`User ${username} was unregistered.`); + await expect(usersList).not.toContainText(username); + }); + }); +}); From 86189793b773e401d1c4ca1c7d661003481381c5 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sun, 26 Apr 2026 07:14:19 +0200 Subject: [PATCH 18/24] inconsistancies resolved --- PHASE5_RUNBOOK.md | 50 +++++++++++++++++------------------------------ Setup.md | 22 ++++++++++++++++++++- Workplan.md | 26 ++++++++++++++++-------- 3 files changed, 57 insertions(+), 41 deletions(-) diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index 08d415c..ead85d2 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -2,7 +2,7 @@ This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse testing. -Last updated: 2026-04-25 +Last updated: 2026-04-26 Related browser demo: @@ -218,37 +218,23 @@ Verified result on 2026-04-25: ## Current Limitation -- 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. -- Session and counter state are currently process-local only; restart loses state. +- The stable deployed baseline still uses card-presence probing, not full assertion verification, for the default auth gate. +- Session and counter state are still process-local only; restart loses state. - 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`: +- Experimental direct FIDO2 mode exists in `k_proxy_app.py` behind `--auth-mode fido2-direct`: + - direct `/enroll/register` now succeeds + - direct `/session/login` now succeeds and returns `auth_mode: "fido2_assertion"` + - direct `/session/status`, `/resource/counter`, and `/session/logout` also succeed end-to-end + - the mode remains optional for now; the deployed service was returned to default `probe` mode so the validated Phase 5 baseline stays reproducible +- Raw CTAP debugging helper 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 - - hidraw inspection confirms `/dev/hidraw0` is the real FIDO interface and `/dev/hidraw1` is a separate vendor HID interface - - manual CTAPHID `INIT` written directly to `/dev/hidraw0` gets no reply at all within `3s` - - rerunning `webauthn_local_demo.py` inside `k_proxy` also shows no card prompt on register - - next step is to recover the USB/Qubes transport path before retrying direct auth - - after a full power cycle and reattach, manual CTAPHID `INIT` replies again and `webauthn_local_demo.py` registration succeeds again - - direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeds again after pressing `yes` on the card - - `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and to auto-detect the working `/dev/hidraw*` node when the card re-enumerates - - after additional fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, `/enroll/register` now succeeds again for `directtest` - - `/session/login` for `directtest` now also succeeds after card confirmation and returns `auth_mode: "fido2_assertion"` - - `/session/status` succeeds - - protected `/resource/counter` succeeds again through `k_proxy -> k_server` - - `/session/logout` succeeds - - post-logout protected access returns `401` - - temporary direct-mode hidraw lifetime logging was removed again after diagnosis - - `phase5_chain_regression.sh` now supports card-interactive direct auth via `--interactive-card --expect-auth-mode fido2_assertion` +- `phase5_chain_regression.sh` now supports card-interactive direct auth via: + - `--interactive-card` + - `--expect-auth-mode fido2_assertion` + +## Current Focus + +- Keep the HTTPS split-VM chain reproducible in default `probe` mode. +- Decide whether `fido2-direct` is ready to become the default deployed auth path. +- Continue Phase 6.5 concurrency work; the active system limit is still higher-fan-out Qubes forwarding on the browser-facing path rather than basic Phase 5 functionality. diff --git a/Setup.md b/Setup.md index 2c062ef..b8eae2e 100644 --- a/Setup.md +++ b/Setup.md @@ -1,6 +1,6 @@ # Setup -Last updated: 2026-04-25 +Last updated: 2026-04-26 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. @@ -611,6 +611,26 @@ Session note (2026-04-25, direct FIDO2 auth attempt): - 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 +Session note (2026-04-26, markdown maintenance re-scan): +- Re-read the maintained workspace markdown set: + - `/home/user/chromecard/Setup.md` + - `/home/user/chromecard/Workplan.md` + - `/home/user/chromecard/PHASE5_RUNBOOK.md` +- Re-checked that the currently referenced runtime artifacts still exist in the workspace: + - `k_proxy_app.py` + - `k_server_app.py` + - `k_client_portal.py` + - `phase5_chain_regression.sh` + - `raw_ctap_probe.py` + - `generate_phase2_certs.py` + - `tls/phase2/ca.crt` + - `tls/phase2/k_proxy.crt` + - `tls/phase2/k_server.crt` +- Current documentation conclusion: + - the workspace still supports the HTTPS localhost-forwarded split-VM chain as the active baseline + - direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime + - the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up + ## Known FIDO2 Transport Boundary - FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT. diff --git a/Workplan.md b/Workplan.md index ded6c01..9c99a22 100644 --- a/Workplan.md +++ b/Workplan.md @@ -1,6 +1,6 @@ # Workplan -Last updated: 2026-04-25 +Last updated: 2026-04-26 This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine. @@ -299,8 +299,7 @@ Status (2026-04-25): - Browser traffic goes only to `k_proxy`. Immediate next action: -Immediate next action: -- Preserve the now-working direct auth path and record it as the current baseline. +- Preserve the now-working direct auth path as a tested option while keeping the default deployed baseline stable. - Verified end-to-end state: - direct `/enroll/register` succeeds for `directtest` - direct `/session/login` succeeds for `directtest` @@ -311,6 +310,7 @@ Immediate next action: - Next work should be cleanup/hardening: - decide whether to keep `directtest` enrollment - rerun `phase5_chain_regression.sh --interactive-card --expect-auth-mode fido2_assertion` against the current direct-auth baseline + - decide when `fido2-direct` should replace `probe` as the default deployed auth mode Exit criteria: - Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`. @@ -549,11 +549,21 @@ Exit criteria: ## 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. +- Treat the default HTTPS split-VM chain as the stable baseline and keep validating it with `/home/user/chromecard/phase5_chain_regression.sh`. +- Push the next engineering cycle toward Phase 6.5 limits: + - reproduce and narrow the `~10` in-flight request ceiling on the browser-facing `k_client -> k_proxy` Qubes forward + - separate Qubes forwarding churn from app-level issues with targeted concurrency probes and log capture +- In parallel, decide whether `--auth-mode fido2-direct` is ready to become the default deployed path or should remain an optional/operator mode. +- Keep the regression helpers as the fast check that transport, auth, session reuse, and counter semantics still hold after each change. + +Status (2026-04-26, markdown maintenance): +- Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files. +- Updated the plan to match the verified state: + - direct FIDO2 auth is no longer the primary blocker because register/login/logout already work in the experimental path + - the main open system limit is concurrency/fan-out on the Qubes-forwarded browser path + - the current planning split is now: + - baseline path: keep `probe` mode stable and reproducible + - follow-up path: decide whether to promote `fido2-direct` ## Inputs Expected During This Session From e7212b49a0a420e09ed3979d2506c82090c1c1bf Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 10:44:43 +0200 Subject: [PATCH 19/24] Add k_proxy unit tests with mocked card and upstream 100 tests covering session management, enrollment CRUD, probe and direct FIDO2 auth routing, UpstreamPool connection handling, and all HTTP endpoints via a live in-process server. Card (FIDO2/CTAP) and k_server are fully mocked so the suite runs locally without hardware or VMs. Also hardens the fido2.features.webauthn_json_mapping import guard to tolerate older python-fido2 versions that lack the attribute. Co-Authored-By: Claude Sonnet 4.6 --- k_proxy_app.py | 7 +- tests/test_k_proxy.py | 804 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 809 insertions(+), 2 deletions(-) create mode 100644 tests/test_k_proxy.py diff --git a/k_proxy_app.py b/k_proxy_app.py index f762927..da8a9cf 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -55,8 +55,11 @@ from fido2.webauthn import ( UserVerificationRequirement, ) -if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None: - fido2.features.webauthn_json_mapping.enabled = True +try: + if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None: + fido2.features.webauthn_json_mapping.enabled = True +except AttributeError: + pass HTML = """ diff --git a/tests/test_k_proxy.py b/tests/test_k_proxy.py new file mode 100644 index 0000000..91f9cf0 --- /dev/null +++ b/tests/test_k_proxy.py @@ -0,0 +1,804 @@ +#!/usr/bin/env python3 +""" +Unit tests for k_proxy_app.py. + +Card (FIDO2/CTAP) and k_server (UpstreamPool) are mocked throughout. +All tests run locally without any Qubes VMs or attached hardware. +""" + +import http.client +import json +import sys +import tempfile +import threading +import time +import unittest +from http.server import ThreadingHTTPServer +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import k_proxy_app as app +from k_proxy_app import ( + AUTH_MODE_FIDO2_DIRECT, + AUTH_MODE_PROBE, + Enrollment, + Handler, + ProxyState, + UpstreamPool, + b64u_decode, + b64u_encode, + enrollment_payload, + normalize_display_name, + normalize_username, +) + + +# ── test helpers ────────────────────────────────────────────────────────────── + +def _make_state(tmp_path, *, auth_mode=AUTH_MODE_PROBE, session_ttl=300): + return ProxyState( + session_ttl_s=session_ttl, + auth_mode=auth_mode, + auth_command="echo ok", + server_base_url="http://127.0.0.1:19999", + server_ca_file=None, + server_max_connections=1, + proxy_token="test-token", + enrollment_db=tmp_path / "enrollments.json", + rp_id="localhost", + rp_name="Test RP", + origin="https://localhost", + direct_device_path="", + ) + + +def _enrollment(username="alice", display_name=None, *, credential_data_b64=None): + now = int(time.time()) + return Enrollment( + username=username, + display_name=display_name, + created_at=now, + updated_at=now, + credential_data_b64=credential_data_b64, + ) + + +# ── pure function tests ─────────────────────────────────────────────────────── + +class TestNormalizeUsername(unittest.TestCase): + def test_simple_valid(self): + self.assertEqual(normalize_username("alice"), "alice") + + def test_strips_and_lowercases(self): + self.assertEqual(normalize_username(" Alice "), "alice") + + def test_valid_with_dots_dashes_underscores(self): + for name in ("alice.smith", "alice-smith", "alice_smith", "a1b"): + with self.subTest(name=name): + self.assertEqual(normalize_username(name), name) + + def test_too_short_raises(self): + with self.assertRaises(ValueError): + normalize_username("ab") + + def test_too_long_raises(self): + with self.assertRaises(ValueError): + normalize_username("a" * 33) + + def test_invalid_chars_raise(self): + for bad in ("Alice!", "al ice", "al@ice", "AB"): + with self.subTest(bad=bad): + with self.assertRaises(ValueError): + normalize_username(bad) + + def test_minimum_length_valid(self): + self.assertEqual(normalize_username("abc"), "abc") + + def test_maximum_length_valid(self): + self.assertEqual(normalize_username("a" * 32), "a" * 32) + + +class TestNormalizeDisplayName(unittest.TestCase): + def test_none_returns_none(self): + self.assertIsNone(normalize_display_name(None)) + + def test_whitespace_only_returns_none(self): + self.assertIsNone(normalize_display_name(" ")) + + def test_strips_whitespace(self): + self.assertEqual(normalize_display_name(" Alice Smith "), "Alice Smith") + + def test_max_length_accepted(self): + self.assertEqual(normalize_display_name("a" * 64), "a" * 64) + + def test_over_max_length_raises(self): + with self.assertRaises(ValueError): + normalize_display_name("a" * 65) + + +class TestBase64Utils(unittest.TestCase): + def test_round_trip(self): + original = b"\x00\x01\x02\xffsome\xffbinary" + self.assertEqual(b64u_decode(b64u_encode(original)), original) + + def test_no_padding_chars_in_output(self): + encoded = b64u_encode(b"x") + self.assertNotIn("=", encoded) + + def test_decode_handles_missing_padding(self): + encoded = b64u_encode(b"hello") + self.assertEqual(b64u_decode(encoded), b"hello") + + +class TestEnrollmentPayload(unittest.TestCase): + def test_basic_fields(self): + e = _enrollment("alice", "Alice Smith") + payload = enrollment_payload(e) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["username"], "alice") + self.assertEqual(payload["display_name"], "Alice Smith") + self.assertFalse(payload["has_credential"]) + + def test_has_credential_true_when_data_present(self): + e = _enrollment(credential_data_b64="abc") + self.assertTrue(enrollment_payload(e)["has_credential"]) + + def test_created_flag_included_when_given(self): + e = _enrollment() + self.assertIn("created", enrollment_payload(e, created=True)) + self.assertNotIn("created", enrollment_payload(e)) + + +# ── session management ──────────────────────────────────────────────────────── + +class TestSessionManagement(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.state = _make_state(Path(self._tmpdir.name)) + + def tearDown(self): + self._tmpdir.cleanup() + + def test_create_returns_token_and_future_expiry(self): + token, expires_at = self.state.create_session("alice") + self.assertIsInstance(token, str) + self.assertGreater(len(token), 16) + self.assertGreater(expires_at, time.time()) + + def test_get_session_returns_correct_username(self): + token, _ = self.state.create_session("alice") + session = self.state.get_session(token) + self.assertIsNotNone(session) + self.assertEqual(session.username, "alice") + + def test_get_session_unknown_token_returns_none(self): + self.assertIsNone(self.state.get_session("not-a-real-token")) + + def test_expired_session_returns_none(self): + state = _make_state(Path(self._tmpdir.name), session_ttl=-1) + token, _ = state.create_session("alice") + self.assertIsNone(state.get_session(token)) + + def test_invalidate_session_removes_it(self): + token, _ = self.state.create_session("alice") + self.assertTrue(self.state.invalidate_session(token)) + self.assertIsNone(self.state.get_session(token)) + + def test_invalidate_unknown_token_returns_false(self): + self.assertFalse(self.state.invalidate_session("ghost")) + + def test_active_session_count_tracks_correctly(self): + self.assertEqual(self.state.active_session_count(), 0) + t1, _ = self.state.create_session("alice") + t2, _ = self.state.create_session("bob") + self.assertEqual(self.state.active_session_count(), 2) + self.state.invalidate_session(t1) + self.assertEqual(self.state.active_session_count(), 1) + + def test_expired_sessions_garbage_collected(self): + state = _make_state(Path(self._tmpdir.name), session_ttl=-1) + state.create_session("alice") + state.create_session("bob") + self.assertEqual(state.active_session_count(), 0) + + def test_tokens_are_unique(self): + tokens = {self.state.create_session("alice")[0] for _ in range(20)} + self.assertEqual(len(tokens), 20) + + def test_uses_direct_fido2_false_in_probe_mode(self): + self.assertFalse(self.state.uses_direct_fido2()) + + def test_uses_direct_fido2_true_in_direct_mode(self): + state = _make_state(Path(self._tmpdir.name), auth_mode=AUTH_MODE_FIDO2_DIRECT) + self.assertTrue(state.uses_direct_fido2()) + + def test_auth_mode_label_probe(self): + self.assertEqual(self.state.auth_mode_label(), "card_presence_probe") + + def test_auth_mode_label_direct(self): + state = _make_state(Path(self._tmpdir.name), auth_mode=AUTH_MODE_FIDO2_DIRECT) + self.assertEqual(state.auth_mode_label(), "fido2_assertion") + + +# ── enrollment management ───────────────────────────────────────────────────── + +class TestEnrollmentManagement(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmpdir.name) + self.state = _make_state(self.tmp_path) + + def tearDown(self): + self._tmpdir.cleanup() + + def test_register_creates_enrollment(self): + e = self.state.register_enrollment("alice", "Alice Smith") + self.assertEqual(e.username, "alice") + self.assertEqual(e.display_name, "Alice Smith") + self.assertTrue(self.state.has_enrollment("alice")) + + def test_register_persists_across_state_reload(self): + self.state.register_enrollment("alice", None) + state2 = _make_state(self.tmp_path) + self.assertTrue(state2.has_enrollment("alice")) + + def test_register_duplicate_raises_file_exists_error(self): + self.state.register_enrollment("alice", None) + with self.assertRaises(FileExistsError): + self.state.register_enrollment("alice", None) + + def test_register_invalid_username_raises_value_error(self): + with self.assertRaises(ValueError): + self.state.register_enrollment("A!", None) + + def test_register_display_name_too_long_raises(self): + with self.assertRaises(ValueError): + self.state.register_enrollment("alice", "x" * 65) + + def test_update_changes_display_name(self): + self.state.register_enrollment("alice", "Old") + updated = self.state.update_enrollment("alice", "New") + self.assertEqual(updated.display_name, "New") + self.assertEqual(self.state.get_enrollment("alice").display_name, "New") + + def test_update_unknown_user_raises_key_error(self): + with self.assertRaises(KeyError): + self.state.update_enrollment("nobody", "Name") + + def test_delete_removes_enrollment(self): + self.state.register_enrollment("alice", None) + self.state.delete_enrollment("alice") + self.assertFalse(self.state.has_enrollment("alice")) + + def test_delete_invalidates_active_sessions(self): + self.state.register_enrollment("alice", None) + token, _ = self.state.create_session("alice") + self.state.delete_enrollment("alice") + self.assertIsNone(self.state.get_session(token)) + + def test_delete_does_not_affect_other_users_sessions(self): + self.state.register_enrollment("alice", None) + self.state.register_enrollment("bob", None) + bob_token, _ = self.state.create_session("bob") + self.state.delete_enrollment("alice") + self.assertIsNotNone(self.state.get_session(bob_token)) + + def test_delete_unknown_user_raises_key_error(self): + with self.assertRaises(KeyError): + self.state.delete_enrollment("nobody") + + def test_list_enrollments_sorted_alphabetically(self): + self.state.register_enrollment("charlie", None) + self.state.register_enrollment("alice", None) + self.state.register_enrollment("bob", None) + names = [e.username for e in self.state.list_enrollments()] + self.assertEqual(names, ["alice", "bob", "charlie"]) + + def test_get_enrollment_found(self): + self.state.register_enrollment("alice", "Alice") + e = self.state.get_enrollment("alice") + self.assertIsNotNone(e) + self.assertEqual(e.username, "alice") + + def test_get_enrollment_not_found_returns_none(self): + self.assertIsNone(self.state.get_enrollment("nobody")) + + def test_get_enrollment_invalid_username_returns_none(self): + self.assertIsNone(self.state.get_enrollment("!bad!")) + + def test_has_enrollment_true(self): + self.state.register_enrollment("alice", None) + self.assertTrue(self.state.has_enrollment("alice")) + + def test_has_enrollment_false(self): + self.assertFalse(self.state.has_enrollment("nobody")) + + def test_register_direct_mode_delegates_to_direct_method(self): + state = _make_state(self.tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + fake = _enrollment("alice", credential_data_b64="cred") + with patch.object(state, "_register_direct_fido2", return_value=fake) as mock_direct: + result = state.register_enrollment("alice", None) + mock_direct.assert_called_once_with("alice", None) + self.assertEqual(result.username, "alice") + + +# ── authentication ──────────────────────────────────────────────────────────── + +class TestProbeAuth(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.state = _make_state(Path(self._tmpdir.name)) + + def tearDown(self): + self._tmpdir.cleanup() + + def _mock_proc(self, returncode, stdout="", stderr=""): + proc = MagicMock() + proc.returncode = returncode + proc.stdout = stdout + proc.stderr = stderr + return proc + + def test_success_when_subprocess_returns_zero(self): + with patch("k_proxy_app.subprocess.run", return_value=self._mock_proc(0, '{"ok": true}')): + ok, _ = self.state.authenticate_with_card("alice") + self.assertTrue(ok) + + def test_failure_when_subprocess_returns_nonzero(self): + with patch("k_proxy_app.subprocess.run", return_value=self._mock_proc(1, stderr="No CTAP HID devices")): + ok, msg = self.state.authenticate_with_card("alice") + self.assertFalse(ok) + self.assertIn("No CTAP HID devices", msg) + + def test_failure_uses_stdout_when_stderr_empty(self): + with patch("k_proxy_app.subprocess.run", return_value=self._mock_proc(2, stdout="probe failed")): + ok, msg = self.state.authenticate_with_card("alice") + self.assertFalse(ok) + self.assertIn("probe failed", msg) + + def test_failure_when_subprocess_raises(self): + with patch("k_proxy_app.subprocess.run", side_effect=TimeoutError("timed out")): + ok, msg = self.state.authenticate_with_card("alice") + self.assertFalse(ok) + self.assertIn("auth command failed", msg) + + +class TestDirectFido2Auth(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.state = _make_state(Path(self._tmpdir.name), auth_mode=AUTH_MODE_FIDO2_DIRECT) + + def tearDown(self): + self._tmpdir.cleanup() + + def test_unenrolled_user_returns_false(self): + ok, msg = self.state.authenticate_with_card("nobody") + self.assertFalse(ok) + self.assertEqual(msg, "user not enrolled") + + def test_enrolled_without_credential_returns_false(self): + self.state.enrollments["alice"] = _enrollment("alice") + ok, msg = self.state.authenticate_with_card("alice") + self.assertFalse(ok) + self.assertEqual(msg, "user has no registered credential") + + def test_exception_from_ctap_returns_false_with_message(self): + self.state.enrollments["alice"] = _enrollment("alice", credential_data_b64="dW5pY29kZQ") + with patch("k_proxy_app.AttestedCredentialData", side_effect=Exception("bad cbor")): + ok, msg = self.state.authenticate_with_card("alice") + self.assertFalse(ok) + self.assertIn("assertion verification failed", msg) + + def test_success_path_with_mocked_internals(self): + self.state.enrollments["alice"] = _enrollment("alice", credential_data_b64=b64u_encode(b"fake_cred")) + + mock_cred = MagicMock() + mock_options = MagicMock() + mock_options.public_key.rp_id = "localhost" + mock_options.public_key.allow_credentials = [] + mock_options.public_key.challenge = b"challenge" + mock_client_data = MagicMock() + mock_client_data.hash = b"hash" + mock_assertion = MagicMock() + mock_assertion.assertions = None + mock_assertion.credential = {"id": b"cred_id"} + mock_assertion.auth_data = b"auth" + mock_assertion.signature = b"sig" + mock_assertion.user = None + + with patch("k_proxy_app.AttestedCredentialData", return_value=mock_cred), \ + patch("k_proxy_app.AuthenticationResponse", return_value=MagicMock()), \ + patch("k_proxy_app.AuthenticatorAssertionResponse", return_value=MagicMock()), \ + patch.object(self.state, "_drop_direct_device"), \ + patch.object(self.state.fido_server, "authenticate_begin", return_value=(mock_options, {})), \ + patch.object(self.state, "_collect_client_data", return_value=mock_client_data), \ + patch.object(self.state, "_with_direct_ctap2", return_value=mock_assertion), \ + patch.object(self.state.fido_server, "authenticate_complete"): + ok, msg = self.state.authenticate_with_card("alice") + + self.assertTrue(ok) + self.assertEqual(msg, "assertion verified") + + +# ── upstream pool ───────────────────────────────────────────────────────────── + +class TestUpstreamPool(unittest.TestCase): + def _pool(self): + return UpstreamPool( + server_base_url="http://127.0.0.1:19999", + server_ca_file=None, + max_connections=2, + ) + + def _mock_response(self, status, body, will_close=True): + resp = MagicMock() + resp.status = status + resp.read.return_value = body + resp.will_close = will_close + return resp + + def test_successful_request_returns_status_and_parsed_json(self): + pool = self._pool() + conn = MagicMock() + conn.getresponse.return_value = self._mock_response(200, b'{"ok": true, "value": 7}') + with patch.object(pool, "_new_connection", return_value=conn): + status, data = pool.request_json("/resource/counter", {"X-Proxy-Token": "tok"}, {}) + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertEqual(data["value"], 7) + + def test_non_200_status_is_returned_as_is(self): + pool = self._pool() + conn = MagicMock() + conn.getresponse.return_value = self._mock_response(403, b'{"ok": false, "error": "forbidden"}') + with patch.object(pool, "_new_connection", return_value=conn): + status, data = pool.request_json("/test", {}, {}) + self.assertEqual(status, 403) + self.assertFalse(data["ok"]) + + def test_oserror_returns_502(self): + pool = self._pool() + conn = MagicMock() + conn.request.side_effect = OSError("connection refused") + with patch.object(pool, "_new_connection", return_value=conn): + status, data = pool.request_json("/test", {}, {}) + self.assertEqual(status, 502) + self.assertIn("server unavailable", data["error"]) + + def test_empty_body_returns_empty_dict(self): + pool = self._pool() + conn = MagicMock() + conn.getresponse.return_value = self._mock_response(200, b"") + with patch.object(pool, "_new_connection", return_value=conn): + status, data = pool.request_json("/test", {}, {}) + self.assertEqual(data, {}) + + def test_connection_reused_when_will_close_false(self): + pool = self._pool() + conn = MagicMock() + conn.getresponse.return_value = self._mock_response(200, b'{"ok": true}', will_close=False) + with patch.object(pool, "_new_connection", return_value=conn) as mock_new: + pool.request_json("/test", {}, {}) + pool.request_json("/test", {}, {}) + self.assertEqual(mock_new.call_count, 1) + self.assertEqual(conn.request.call_count, 2) + + def test_connection_not_reused_when_will_close_true(self): + pool = self._pool() + conn = MagicMock() + conn.getresponse.return_value = self._mock_response(200, b'{"ok": true}', will_close=True) + with patch.object(pool, "_new_connection", return_value=conn) as mock_new: + pool.request_json("/test", {}, {}) + pool.request_json("/test", {}, {}) + self.assertEqual(mock_new.call_count, 2) + + +# ── HTTP handler integration tests ──────────────────────────────────────────── + +class ServerFixture(unittest.TestCase): + """Spins up a real ThreadingHTTPServer backed by a ProxyState with mocked + card and upstream. Card auth and fetch_counter are patched per-test via + patch.object(self.state, ...) or the _login() helper.""" + + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmpdir.name) + self.state = _make_state(self.tmp_path) + Handler.state = self.state + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self.server.server_address[1] + self._thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self._thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self._tmpdir.cleanup() + + # ── request helpers ── + + def _conn(self): + return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + + def _get(self, path): + conn = self._conn() + try: + conn.request("GET", path) + resp = conn.getresponse() + return resp.status, resp.read() + finally: + conn.close() + + def _get_json(self, path): + status, body = self._get(path) + return status, json.loads(body) + + def _post(self, path, payload=None, token=None): + conn = self._conn() + try: + body = json.dumps(payload or {}).encode() + headers = { + "Content-Type": "application/json", + "Content-Length": str(len(body)), + } + if token: + headers["Authorization"] = f"Bearer {token}" + conn.request("POST", path, body=body, headers=headers) + resp = conn.getresponse() + return resp.status, json.loads(resp.read()) + finally: + conn.close() + + def _post_raw(self, path, raw_body): + conn = self._conn() + try: + headers = { + "Content-Type": "application/json", + "Content-Length": str(len(raw_body)), + } + conn.request("POST", path, body=raw_body, headers=headers) + resp = conn.getresponse() + return resp.status, resp.read() + finally: + conn.close() + + # ── state helpers ── + + def _enroll(self, username="alice", display_name=None): + self.state.register_enrollment(username, display_name) + + def _login(self, username="alice"): + """Enroll user and obtain a session token with the card mocked to succeed.""" + self._enroll(username) + with patch.object(self.state, "authenticate_with_card", return_value=(True, "ok")): + status, data = self._post("/session/login", {"username": username}) + self.assertEqual(status, 200, f"login setup failed: {data}") + return data["session_token"] + + +class TestHandlerHealth(ServerFixture): + def test_get_root_returns_html(self): + status, body = self._get("/") + self.assertEqual(status, 200) + self.assertIn(b"ChromeCard", body) + + def test_health_returns_service_info(self): + status, data = self._get_json("/health") + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertEqual(data["service"], "k_proxy") + self.assertIn("active_sessions", data) + + def test_health_reflects_active_session_count(self): + self.state.create_session("alice") + _, data = self._get_json("/health") + self.assertEqual(data["active_sessions"], 1) + + def test_unknown_get_returns_404(self): + status, _ = self._get("/nonexistent") + self.assertEqual(status, 404) + + def test_unknown_post_returns_404(self): + status, _ = self._post_raw("/nonexistent", b"{}") + self.assertEqual(status, 404) + + +class TestHandlerEnrollment(ServerFixture): + def test_register_new_user_returns_200(self): + status, data = self._post("/enroll/register", {"username": "alice", "display_name": "Alice"}) + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertEqual(data["username"], "alice") + self.assertEqual(data["display_name"], "Alice") + + def test_register_duplicate_returns_409(self): + self._enroll("alice") + status, data = self._post("/enroll/register", {"username": "alice"}) + self.assertEqual(status, 409) + self.assertFalse(data["ok"]) + + def test_register_invalid_username_returns_400(self): + status, data = self._post("/enroll/register", {"username": "A!"}) + self.assertEqual(status, 400) + self.assertFalse(data["ok"]) + + def test_register_invalid_json_returns_400(self): + status, _ = self._post_raw("/enroll/register", b"not-json") + self.assertEqual(status, 400) + + def test_enroll_status_found(self): + self._enroll("alice", "Alice Smith") + status, data = self._get_json("/enroll/status?username=alice") + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertEqual(data["display_name"], "Alice Smith") + + def test_enroll_status_not_found_returns_404(self): + status, data = self._get_json("/enroll/status?username=nobody") + self.assertEqual(status, 404) + + def test_enroll_status_missing_param_returns_400(self): + status, data = self._get_json("/enroll/status") + self.assertEqual(status, 400) + + def test_enroll_list_empty(self): + status, data = self._get_json("/enroll/list") + self.assertEqual(status, 200) + self.assertEqual(data["users"], []) + + def test_enroll_list_returns_sorted_users(self): + self._enroll("charlie") + self._enroll("alice") + _, data = self._get_json("/enroll/list") + names = [u["username"] for u in data["users"]] + self.assertEqual(names, ["alice", "charlie"]) + + def test_enroll_update_changes_display_name(self): + self._enroll("alice", "Old") + status, data = self._post("/enroll/update", {"username": "alice", "display_name": "New"}) + self.assertEqual(status, 200) + self.assertEqual(data["display_name"], "New") + + def test_enroll_update_unknown_returns_404(self): + status, _ = self._post("/enroll/update", {"username": "nobody"}) + self.assertEqual(status, 404) + + def test_enroll_delete_returns_200_and_deleted_true(self): + self._enroll("alice") + status, data = self._post("/enroll/delete", {"username": "alice"}) + self.assertEqual(status, 200) + self.assertTrue(data["deleted"]) + self.assertFalse(self.state.has_enrollment("alice")) + + def test_enroll_delete_unknown_returns_404(self): + status, _ = self._post("/enroll/delete", {"username": "nobody"}) + self.assertEqual(status, 404) + + +class TestHandlerSession(ServerFixture): + def test_login_success_returns_token(self): + self._enroll("alice") + with patch.object(self.state, "authenticate_with_card", return_value=(True, "ok")): + status, data = self._post("/session/login", {"username": "alice"}) + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertIn("session_token", data) + self.assertIn("expires_at", data) + self.assertEqual(data["auth_mode"], "card_presence_probe") + + def test_login_unenrolled_user_returns_403(self): + status, data = self._post("/session/login", {"username": "nobody"}) + self.assertEqual(status, 403) + self.assertFalse(data["ok"]) + self.assertIn("not enrolled", data["error"]) + + def test_login_card_failure_returns_401(self): + self._enroll("alice") + with patch.object(self.state, "authenticate_with_card", return_value=(False, "No CTAP devices")): + status, data = self._post("/session/login", {"username": "alice"}) + self.assertEqual(status, 401) + self.assertFalse(data["ok"]) + self.assertIn("card auth failed", data["error"]) + self.assertIn("No CTAP devices", data["details"]) + + def test_login_invalid_username_returns_400(self): + status, data = self._post("/session/login", {"username": "!bad!"}) + self.assertEqual(status, 400) + + def test_session_status_valid_token(self): + token = self._login() + status, data = self._post("/session/status", {}, token=token) + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertEqual(data["username"], "alice") + self.assertIn("expires_at", data) + self.assertGreaterEqual(data["seconds_remaining"], 0) + + def test_session_status_no_token_returns_401(self): + status, data = self._post("/session/status", {}) + self.assertEqual(status, 401) + + def test_session_status_invalid_token_returns_401(self): + status, data = self._post("/session/status", {}, token="bad-token") + self.assertEqual(status, 401) + self.assertIn("invalid or expired", data["error"]) + + def test_logout_valid_token(self): + token = self._login() + status, data = self._post("/session/logout", {}, token=token) + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertTrue(data["invalidated"]) + self.assertIsNone(self.state.get_session(token)) + + def test_logout_invalid_token_returns_200_not_invalidated(self): + status, data = self._post("/session/logout", {}, token="ghost") + self.assertEqual(status, 200) + self.assertFalse(data["invalidated"]) + + def test_logout_no_token_returns_401(self): + status, data = self._post("/session/logout", {}) + self.assertEqual(status, 401) + + def test_session_invalid_after_logout(self): + token = self._login() + self._post("/session/logout", {}, token=token) + status, data = self._post("/session/status", {}, token=token) + self.assertEqual(status, 401) + + def test_multiple_sessions_independent(self): + t1 = self._login("alice") + t2 = self._login("bob") + # logout alice, bob's session still valid + self._post("/session/logout", {}, token=t1) + status, data = self._post("/session/status", {}, token=t2) + self.assertEqual(status, 200) + self.assertEqual(data["username"], "bob") + + +class TestHandlerResource(ServerFixture): + def test_counter_with_valid_session(self): + token = self._login() + with patch.object(self.state, "fetch_counter", return_value=(200, {"ok": True, "value": 5})): + status, data = self._post("/resource/counter", {}, token=token) + self.assertEqual(status, 200) + self.assertTrue(data["ok"]) + self.assertEqual(data["upstream"]["value"], 5) + self.assertEqual(data["username"], "alice") + self.assertTrue(data["session_reused"]) + + def test_counter_no_token_returns_401(self): + status, data = self._post("/resource/counter", {}) + self.assertEqual(status, 401) + + def test_counter_invalid_token_returns_401(self): + status, data = self._post("/resource/counter", {}, token="garbage") + self.assertEqual(status, 401) + + def test_counter_upstream_failure_propagated(self): + token = self._login() + with patch.object(self.state, "fetch_counter", return_value=(502, {"ok": False, "error": "server unavailable"})): + status, data = self._post("/resource/counter", {}, token=token) + self.assertEqual(status, 502) + self.assertFalse(data["ok"]) + self.assertIn("upstream failed", data["error"]) + + def test_counter_returns_upstream_non_200_as_error(self): + token = self._login() + with patch.object(self.state, "fetch_counter", return_value=(403, {"ok": False, "error": "forbidden"})): + status, data = self._post("/resource/counter", {}, token=token) + self.assertEqual(status, 403) + self.assertFalse(data["ok"]) + + def test_counter_session_still_valid_after_call(self): + token = self._login() + with patch.object(self.state, "fetch_counter", return_value=(200, {"ok": True, "value": 1})): + self._post("/resource/counter", {}, token=token) + status, _ = self._post("/session/status", {}, token=token) + self.assertEqual(status, 200) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 2cf44e97df60db15ba99bb43f0a49183c8ea5c2f Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 11:27:57 +0200 Subject: [PATCH 20/24] Refactor all three service files and fix enroll-clears-session bug - Update module docstrings to concise service descriptions - Add _require_json() helper to Handler in k_proxy and k_client_portal, eliminating repetitive try/except JSON-parse blocks in handler methods - Cache SSL context once in ClientState.__init__ instead of per-request - Fix: ClientState.enroll() now calls /session/logout on k_proxy before re-enrolling, so the old server-side session is invalidated rather than left to expire (discovered via live test where re-register after login caused subsequent logout to fail with missing bearer token) - Add targeted comments explaining non-obvious invariants: _gc_locked lock ownership, _with_direct_ctap2 retry-on-reopen, _require_session None convention, will_close connection reuse, HTTP/1.1 body-drain requirement, 90 s interactive timeout margin, and enroll session-clearing rationale Co-Authored-By: Claude Sonnet 4.6 --- k_client_portal.py | 56 +++++++++++++++++++++++++++++--------------- k_proxy_app.py | 58 ++++++++++++++++++++++++++-------------------- k_server_app.py | 12 ++++++---- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/k_client_portal.py b/k_client_portal.py index b396c13..4f185f9 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """ -Minimal browser-facing client portal for Phase 6 bring-up. +k_client_portal — browser-facing portal running in k_client. -This runs in k_client, keeps a local preferred username, and talks to k_proxy -over the localhost-forwarded TLS endpoint. +Serves the single-page UI and thin API shim that delegates every auth and +resource operation to k_proxy over the localhost-forwarded TLS endpoint. +Persists one preferred username locally; all session and enrollment state +lives in k_proxy. """ from __future__ import annotations @@ -561,18 +563,25 @@ class ClientState: self.proxy_base_url = proxy_base_url.rstrip("/") self.proxy_ca_file = proxy_ca_file self.enroll_db = enroll_db + # Registration and login both require a physical card touch, which can + # take up to ~60 s in practice; 90 s gives a generous margin. self.interactive_timeout_s = interactive_timeout_s self.default_timeout_s = default_timeout_s self.lock = threading.Lock() self.preferred_enrollment: EnrollmentRecord | None = None self.session_token: str | None = None self.session_expires_at: int | None = None + # Build the TLS context once; creating it on every request is expensive + # and the CA file doesn't change at runtime. + self._ssl_ctx: ssl.SSLContext | None = ( + ssl.create_default_context(cafile=self.proxy_ca_file) + if proxy_base_url.startswith("https://") + else None + ) self._load_preferred_enrollment() - def _ssl_context(self): - if self.proxy_base_url.startswith("https://"): - return ssl.create_default_context(cafile=self.proxy_ca_file) - return None + def _ssl_context(self) -> ssl.SSLContext | None: + return self._ssl_ctx def _proxy_json( self, @@ -626,6 +635,12 @@ class ClientState: username = username.strip() if not username: return {"ok": False, "error": "username required"} + # Best-effort: invalidate any active session on k_proxy before re-enrolling. + # The new credential will differ from what the old session was issued for. + with self.lock: + old_token = self.session_token + if old_token: + self._proxy_json("POST", "/session/logout") status, data = self._proxy_json( "POST", "/enroll/register", @@ -741,6 +756,15 @@ class Handler(BaseHTTPRequestHandler): return {} return json.loads(raw.decode("utf-8")) + def _require_json(self) -> dict[str, Any] | None: + # Returns None and sends 400 when the body is unparseable; the caller + # should return immediately without sending a second response. + try: + return self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return None + def do_GET(self) -> None: # noqa: N802 path = urlparse(self.path).path if path == "/": @@ -761,28 +785,22 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: # noqa: N802 path = urlparse(self.path).path if path == "/api/enroll": - try: - data = self._read_json() - except Exception: - self._json(400, {"ok": False, "error": "invalid json"}) + data = self._require_json() + if data is None: return result = self.state.enroll(str(data.get("username", ""))) self._json(200 if result.get("ok") else 400, result) return if path == "/api/login": - try: - data = self._read_json() - except Exception: - self._json(400, {"ok": False, "error": "invalid json"}) + data = self._require_json() + if data is None: return status, data = self.state.login(str(data.get("username", ""))) self._json(status, data) return if path == "/api/enroll/delete": - try: - data = self._read_json() - except Exception: - self._json(400, {"ok": False, "error": "invalid json"}) + data = self._require_json() + if data is None: return status, data = self.state.delete_enrollment(str(data.get("username", ""))) self._json(status, data) diff --git a/k_proxy_app.py b/k_proxy_app.py index da8a9cf..211e326 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -1,16 +1,14 @@ #!/usr/bin/env python3 """ -Minimal k_proxy service for Phase 5 bring-up. +k_proxy — session gateway and card authentication bridge. -Behavior: -- Creates short-lived sessions after a card-backed auth gate. -- Reuses valid sessions to access k_server protected counter endpoint. -- Supports enrollment, session status, and logout. +Creates short-lived bearer sessions after a card-backed auth gate, then +proxies authenticated requests to k_server. Enrollment metadata and session +state are both process-local; sessions do not survive a restart. -Notes: -- Default runtime still uses the legacy card-presence probe gate. -- Experimental direct FIDO2 registration/assertion lives behind `--auth-mode fido2-direct`. -- This is still a prototype and not a final production auth design. +Default auth mode is a lightweight card-presence probe (subprocess call to +fido2_probe.py). Pass --auth-mode fido2-direct for real CTAP2 +makeCredential/getAssertion against the attached ChromeCard. """ from __future__ import annotations @@ -546,6 +544,7 @@ class ProxyState: return time.time() def _gc_locked(self) -> None: + # Caller must hold self.lock. now = self._now() dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now] for token in dead: @@ -674,6 +673,9 @@ class ProxyState: self._drop_direct_device_locked() def _with_direct_ctap2(self, action): + # First attempt reuses the cached handle; if it fails (e.g. the card was + # briefly removed or the CTAPHID channel desynchronised), we reopen once + # and retry before propagating the error. with self.direct_device_lock: last_exc: Exception | None = None for reopen in (False, True): @@ -962,6 +964,8 @@ class UpstreamPool: conn.request("POST", full_path, body=body, headers=req_headers) resp = conn.getresponse() raw = resp.read() + # will_close is set by the server when it intends to close the connection + # after this response; reusing such a connection would hit an EOF. reusable = not resp.will_close try: data = json.loads(raw.decode("utf-8")) if raw else {} @@ -1004,10 +1008,20 @@ class Handler(BaseHTTPRequestHandler): return json.loads(raw.decode("utf-8")) def _discard_request_body(self) -> None: + # HTTP/1.1 keep-alive: body must be consumed before the response is sent. length = int(self.headers.get("Content-Length", "0")) if length > 0: self.rfile.read(length) + def _require_json(self) -> dict[str, Any] | None: + # Returns None and sends 400 when the body is unparseable; callers must + # return immediately without sending a second response. + try: + return self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return None + def _bearer_token(self) -> str | None: value = self.headers.get("Authorization", "") if not value.startswith("Bearer "): @@ -1016,6 +1030,8 @@ class Handler(BaseHTTPRequestHandler): return token or None def _require_session(self) -> tuple[str, Session] | None: + # Returns None when auth fails; the 401 has already been sent, so callers + # must return immediately without writing a second response. token = self._bearer_token() if not token: self._json(401, {"ok": False, "error": "missing bearer token"}) @@ -1076,10 +1092,8 @@ class Handler(BaseHTTPRequestHandler): self.send_error(404) def _session_login(self) -> None: - try: - data = self._read_json() - except Exception: - self._json(400, {"ok": False, "error": "invalid json"}) + data = self._require_json() + if data is None: return try: @@ -1110,10 +1124,8 @@ class Handler(BaseHTTPRequestHandler): ) def _enroll_register(self) -> None: - try: - data = self._read_json() - except Exception: - self._json(400, {"ok": False, "error": "invalid json"}) + data = self._require_json() + if data is None: return try: @@ -1134,10 +1146,8 @@ class Handler(BaseHTTPRequestHandler): self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at)) def _enroll_update(self) -> None: - try: - data = self._read_json() - except Exception: - self._json(400, {"ok": False, "error": "invalid json"}) + data = self._require_json() + if data is None: return try: enrollment = self.state.update_enrollment( @@ -1153,10 +1163,8 @@ class Handler(BaseHTTPRequestHandler): 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"}) + data = self._require_json() + if data is None: return try: enrollment = self.state.delete_enrollment(str(data.get("username", ""))) diff --git a/k_server_app.py b/k_server_app.py index 4831a33..b95902a 100644 --- a/k_server_app.py +++ b/k_server_app.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 """ -Minimal k_server service for Phase 5/5.5 bring-up. +k_server — protected resource backend. -Behavior: -- Exposes a protected monotonic counter endpoint. -- Accepts only requests from k_proxy via a shared proxy token header. -- Uses thread-safe counter increments. +Exposes a monotonic counter behind a shared proxy token. Only k_proxy +is expected to reach this service; k_client should have no direct path. +All state is process-local and resets on restart. """ from __future__ import annotations @@ -21,6 +20,7 @@ from urllib.parse import urlparse class ServerState: + # All state is process-local; a restart resets the counter to zero. def __init__(self, proxy_token: str): self.proxy_token = proxy_token self.counter = 0 @@ -45,6 +45,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def _discard_request_body(self) -> None: + # HTTP/1.1 keep-alive: the connection is reused, so the body must be fully + # consumed before we send the response, even for endpoints that ignore it. length = int(self.headers.get("Content-Length", "0")) if length > 0: self.rfile.read(length) From 855b4175bce188033cc55546e4cb99efd65d5488 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 11:29:26 +0200 Subject: [PATCH 21/24] Update Setup.md and Workplan.md for 2026-04-27 session - fido2-direct confirmed as deployed default after full browser flow with real card - Document enroll-clears-session bug fix - Document k_proxy unit test suite (100 tests) - Record current deployed service state and port map Co-Authored-By: Claude Sonnet 4.6 --- Setup.md | 19 ++++++++++++++++++- Workplan.md | 12 +++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Setup.md b/Setup.md index b8eae2e..bd60b21 100644 --- a/Setup.md +++ b/Setup.md @@ -1,6 +1,6 @@ # Setup -Last updated: 2026-04-26 +Last updated: 2026-04-27 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. @@ -611,6 +611,23 @@ Session note (2026-04-25, direct FIDO2 auth attempt): - 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 +Session note (2026-04-27, fido2-direct end-to-end browser validation): +- Deployed all three services (k_server, k_proxy, k_client_portal) in split-VM chain via SSH/SCP. +- k_proxy restarted with --auth-mode fido2-direct. +- Full browser flow verified from k_client at http://127.0.0.1:8766 with real card: + - Register: makeCredential triggered on card, button press confirmed. + - Login: getAssertion triggered on card, button press confirmed. + - Counter: k_server returned incremented value. + - Logout: session correctly invalidated. +- Confirmed: probe mode showed stale directtest enrollment (no credential_data_b64) from earlier session; that is expected. +- Bug found and fixed: clicking Register after Login cleared the client-side session token but left the server-side session alive; fix adds a best-effort /session/logout call to k_proxy before re-enrolling. +- Current deployed service state: + - k_server: https://127.0.0.1:8780, TLS, proxy-token dev-proxy-token + - k_proxy: https://127.0.0.1:8771, TLS, --auth-mode fido2-direct, upstream https://127.0.0.1:9780 + - k_client: http://127.0.0.1:8766, proxy-base-url https://127.0.0.1:9771 + - Forwards: k_proxy 9780->k_server:8780, k_client 9771->k_proxy:8771 +- Unit test suite added: tests/test_k_proxy.py (100 tests, all passing, run locally with python3 -m unittest tests/test_k_proxy.py). + Session note (2026-04-26, markdown maintenance re-scan): - Re-read the maintained workspace markdown set: - `/home/user/chromecard/Setup.md` diff --git a/Workplan.md b/Workplan.md index 9c99a22..0e39763 100644 --- a/Workplan.md +++ b/Workplan.md @@ -1,6 +1,6 @@ # Workplan -Last updated: 2026-04-26 +Last updated: 2026-04-27 This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine. @@ -549,13 +549,19 @@ Exit criteria: ## Current Next Step -- Treat the default HTTPS split-VM chain as the stable baseline and keep validating it with `/home/user/chromecard/phase5_chain_regression.sh`. +- fido2-direct is now the deployed default; keep it as the baseline for regression runs. - Push the next engineering cycle toward Phase 6.5 limits: - reproduce and narrow the `~10` in-flight request ceiling on the browser-facing `k_client -> k_proxy` Qubes forward - separate Qubes forwarding churn from app-level issues with targeted concurrency probes and log capture -- In parallel, decide whether `--auth-mode fido2-direct` is ready to become the default deployed path or should remain an optional/operator mode. - Keep the regression helpers as the fast check that transport, auth, session reuse, and counter semantics still hold after each change. +Status (2026-04-27): +- fido2-direct mode confirmed working end-to-end with real card via browser on k_client. +- Full register → login → counter → logout flow verified with physical card button presses. +- Bug fixed: ClientState.enroll() now calls /session/logout on k_proxy before re-enrolling. +- 100-test unit suite added for k_proxy (tests/test_k_proxy.py); runs locally without card or VMs. +- All three service files refactored and re-deployed. + Status (2026-04-26, markdown maintenance): - Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files. - Updated the plan to match the verified state: From 9d6da53b8f55304fd8c05920cf0f5fdd11c87454 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 13:17:58 +0200 Subject: [PATCH 22/24] Update Workplan current status: phases 6.5/7/9 all externally gated Co-Authored-By: Claude Sonnet 4.6 --- Workplan.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Workplan.md b/Workplan.md index 0e39763..ea39e52 100644 --- a/Workplan.md +++ b/Workplan.md @@ -549,12 +549,6 @@ Exit criteria: ## Current Next Step -- fido2-direct is now the deployed default; keep it as the baseline for regression runs. -- Push the next engineering cycle toward Phase 6.5 limits: - - reproduce and narrow the `~10` in-flight request ceiling on the browser-facing `k_client -> k_proxy` Qubes forward - - separate Qubes forwarding churn from app-level issues with targeted concurrency probes and log capture -- Keep the regression helpers as the fast check that transport, auth, session reuse, and counter semantics still hold after each change. - Status (2026-04-27): - fido2-direct mode confirmed working end-to-end with real card via browser on k_client. - Full register → login → counter → logout flow verified with physical card button presses. @@ -562,6 +556,13 @@ Status (2026-04-27): - 100-test unit suite added for k_proxy (tests/test_k_proxy.py); runs locally without card or VMs. - All three service files refactored and re-deployed. +Phase status (2026-04-27): +- Phase 6.5 (concurrency): deferred. Ceiling (~10 in-flight) is acceptable until multi-card use cases arrive. +- Phase 7 (firmware build/flash): blocked on Chrome Roads (card vendor). No local action until that discussion concludes. +- Phase 9 (phone integration): awaiting go-ahead. When approved: Flutter app (iOS + Android) replaces k_proxy; FIDO2 over WiFi to card; depends on Phase 7 firmware capability. + +No active engineering work is unblocked at this time. Resume when Chrome Roads responds or Phase 9 is approved. + Status (2026-04-26, markdown maintenance): - Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files. - Updated the plan to match the verified state: From 56132528fe4e1edda1b7e669e530353cdac7e0db Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 16:31:07 +0200 Subject: [PATCH 23/24] Add CardEmulator and fix fido2-direct id= constructor bugs tests/card_emulator.py: software emulator of the ChromeCard FIDO2 authenticator. Implements make_credential and get_assertion with real P-256 cryptography and an in-memory credential store. Both methods accept user_confirms=True/False to simulate the card's Yes/No confirmation dialog; False raises CtapError(OPERATION_DENIED). refusing() returns a wrapper that forces user_confirms=False for integration tests that route through _with_direct_ctap2. forget_user() simulates card-side credential removal. Module docstring serves as the usage guide. tests/test_k_proxy.py: 22 new tests across TestCardEmulatorUnit (direct emulator calls) and TestCardEmulatorIntegration (full ProxyState flows covering register, authenticate, user-says-no, forget, two-user isolation, and sign-count monotonicity). k_proxy_app.py: fix two bugs where RegistrationResponse and AuthenticationResponse were constructed with id= instead of raw_id=. Both calls raised TypeError at runtime, silently caught by the surrounding except block, making all fido2-direct register and authenticate calls fail. Co-Authored-By: Claude Sonnet 4.6 --- k_proxy_app.py | 4 +- tests/card_emulator.py | 339 +++++++++++++++++++++++++++++++++++++++++ tests/test_k_proxy.py | 244 +++++++++++++++++++++++++++++ 3 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 tests/card_emulator.py diff --git a/k_proxy_app.py b/k_proxy_app.py index 211e326..ca72ba1 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -757,7 +757,7 @@ class ProxyState: auth_data = self.fido_server.register_complete( state, RegistrationResponse( - id=attestation.auth_data.credential_data.credential_id, + raw_id=attestation.auth_data.credential_data.credential_id, response=AuthenticatorAttestationResponse( client_data=client_data, attestation_object=AttestationObject.create( @@ -888,7 +888,7 @@ class ProxyState: state, [credential], AuthenticationResponse( - id=response.credential["id"], + raw_id=response.credential["id"], response=AuthenticatorAssertionResponse( client_data=client_data, authenticator_data=response.auth_data, diff --git a/tests/card_emulator.py b/tests/card_emulator.py new file mode 100644 index 0000000..a5b7b3f --- /dev/null +++ b/tests/card_emulator.py @@ -0,0 +1,339 @@ +""" +CardEmulator — software emulator of the ChromeCard FIDO2 authenticator +====================================================================== + +What it is +---------- +CardEmulator is a drop-in replacement for the physical ChromeCard in tests. +It implements the two Ctap2 methods that k_proxy_app calls in fido2-direct +mode — make_credential (registration) and get_assertion (authentication) — +using real P-256 cryptography and an in-memory credential store. + +The auth_data layout and COSE key encoding mirror the firmware exactly +(see fido_make_cred.c and fido_get_assertion.c), so fido2.server's +register_complete and authenticate_complete accept the emulator's responses +without any extra patching. + + +Wiring the emulator into a ProxyState test +------------------------------------------ +Two patches are needed: one to replace _with_direct_ctap2 so it hands the +emulator to the lambda instead of opening a real HID device, and one to +suppress _drop_direct_device which would otherwise try to close a real handle. + +A convenience helper for this is provided in test_k_proxy.py: + + def _patch_emulator(state, emulator): + return patch.multiple( + state, + _with_direct_ctap2=lambda fn: fn(emulator), + _drop_direct_device=lambda: None, + ) + +Typical test setup: + + from card_emulator import CardEmulator + from unittest.mock import patch + + emulator = CardEmulator() + state = _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + + with _patch_emulator(state, emulator): + enrollment = state.register_enrollment("alice", "Alice") + ok, msg = state.authenticate_with_card("alice") # True, "assertion verified" + + +User confirmation (Yes / No on the card) +----------------------------------------- +Both make_credential and get_assertion accept a `user_confirms` keyword +argument (default True). Setting it to False raises +CtapError(OPERATION_DENIED), exactly as the firmware does when the user taps +No on the card's confirmation dialog. + +Direct calls — pass the flag explicitly: + + attest = emulator.make_credential( + client_data_hash=..., rp=..., user=..., key_params=..., + user_confirms=False, + ) # raises CtapError(OPERATION_DENIED) + +Integration tests through _with_direct_ctap2 — the lambda that ProxyState +builds cannot inject user_confirms, so use refusing() instead. It returns +a thin wrapper whose methods forward to the emulator with user_confirms=False: + + with _patch_emulator(state, emulator.refusing()): + ok, msg = state.authenticate_with_card("alice") # False + # msg contains "assertion verification failed: CTAP error: OPERATION_DENIED" + + with _patch_emulator(state, emulator.refusing()): + with self.assertRaises(RuntimeError): + state.register_enrollment("bob", None) + # raises RuntimeError("card registration failed: ...") + +refusing() shares the same credential store as the parent emulator, so +credentials registered before or after the call are visible to both. + + +Simulating card-side credential removal +---------------------------------------- +forget_user(username) removes all credentials for that user from the +emulator's store and returns the count removed. Use it to simulate a +factory reset or a deliberate key deletion: + + emulator.forget_user("alice") + ok, msg = state.authenticate_with_card("alice") # False + + +API summary +----------- +CardEmulator() + Create a new emulator with an empty credential store. + +make_credential(client_data_hash, rp, user, key_params, *, user_confirms=True) + Simulate CTAP2 makeCredential. Returns AttestationResponse. + +get_assertion(rp_id, client_data_hash, allow_list, *, user_confirms=True) + Simulate CTAP2 getAssertion. Returns AssertionResponse. + Raises CtapError(NO_CREDENTIALS) if no matching credential is found. + +refusing() -> _RefusingView + Return a wrapper that forces user_confirms=False on every call. + +forget_user(username) -> int + Remove all credentials for username. Returns count removed. + +credential_count() -> int + Total credentials currently in the store. +""" + +from __future__ import annotations + +import hashlib +import os +import struct +from dataclasses import dataclass +from typing import Any, Mapping + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from fido2.ctap import CtapError +from fido2.ctap2 import AssertionResponse, AttestationResponse +from fido2.webauthn import AuthenticatorData + +# AAGUID from fido_make_cred.c +_AAGUID = bytes([ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, +]) + + +def _cose_es256(x: bytes, y: bytes) -> bytes: + """CBOR-encoded COSE ES256 public key, byte-for-byte as the firmware builds it.""" + return ( + bytes([0xA5]) # map(5) + + bytes([0x01, 0x02]) # kty: 2 (EC2) + + bytes([0x03, 0x26]) # alg: -7 (ES256) + + bytes([0x20, 0x01]) # crv (-1): 1 (P-256) + + bytes([0x21, 0x58, 0x20]) + x # x (-2): bstr(32) + + bytes([0x22, 0x58, 0x20]) + y # y (-3): bstr(32) + ) + + +@dataclass +class _Credential: + private_key: ec.EllipticCurvePrivateKey + rp_id_hash: bytes + user_id: bytes + username: str + sign_count: int = 0 + + +class CardEmulator: + """In-process emulator of a ChromeCard FIDO2 authenticator. + + Implements make_credential and get_assertion with the same signatures as + fido2.ctap2.Ctap2, plus forget_user() for test teardown. + """ + + def __init__(self) -> None: + self._creds: dict[bytes, _Credential] = {} + + # ── CTAP2 interface ─────────────────────────────────────────────────────── + + def make_credential( + self, + client_data_hash: bytes, + rp: Mapping[str, Any], + user: Mapping[str, Any], + key_params: list[Mapping[str, Any]], + exclude_list: list[Mapping[str, Any]] | None = None, + extensions: Mapping[str, Any] | None = None, + options: Mapping[str, Any] | None = None, + *, + user_confirms: bool = True, + **_: Any, + ) -> AttestationResponse: + """Simulate makeCredential. + + When user_confirms is False the call raises CtapError(OPERATION_DENIED), + mirroring the firmware's response when the user taps No on the card. + Otherwise a real P-256 keypair is generated, stored, and returned as a + fmt='none' AttestationResponse with a valid COSE ES256 public key. + """ + if not user_confirms: + raise CtapError(CtapError.ERR.OPERATION_DENIED) + + rp_id: str = rp["id"] + rp_id_hash = hashlib.sha256(rp_id.encode()).digest() + + priv = ec.generate_private_key(ec.SECP256R1()) + pub_nums = priv.public_key().public_numbers() + x = pub_nums.x.to_bytes(32, "big") + y = pub_nums.y.to_bytes(32, "big") + + credential_id = os.urandom(32) + + raw_user_id: bytes = user.get("id", b"") # type: ignore[assignment] + if isinstance(raw_user_id, str): + raw_user_id = raw_user_id.encode() + + cred = _Credential( + private_key=priv, + rp_id_hash=rp_id_hash, + user_id=raw_user_id, + username=user.get("name", ""), # type: ignore[arg-type] + ) + + # authData layout matches fido_make_cred.c build_make_credential_response(): + # rpIdHash(32) | flags(1) | signCount(4) | aaguid(16) + # | credIdLen(2) | credId(32) | coseKey(77) + auth_data_bytes = ( + rp_id_hash + + bytes([0x41]) # flags: UP=1, AT=1 + + struct.pack(">I", cred.sign_count) + + _AAGUID + + struct.pack(">H", len(credential_id)) + + credential_id + + _cose_es256(x, y) + ) + cred.sign_count += 1 + self._creds[credential_id] = cred + + return AttestationResponse( + fmt="none", + auth_data=AuthenticatorData(auth_data_bytes), + att_stmt={}, + ) + + def get_assertion( + self, + rp_id: str, + client_data_hash: bytes, + allow_list: list[Mapping[str, Any]] | None = None, + extensions: Mapping[str, Any] | None = None, + options: Mapping[str, Any] | None = None, + *, + user_confirms: bool = True, + **_: Any, + ) -> AssertionResponse: + """Simulate getAssertion. + + When user_confirms is False raises CtapError(OPERATION_DENIED). + Otherwise finds the credential, builds authData (37 bytes, no AT flag), + signs authData || clientDataHash with ECDSA-SHA256 (DER), and returns + an AssertionResponse — byte-for-byte compatible with the firmware output. + """ + if not user_confirms: + raise CtapError(CtapError.ERR.OPERATION_DENIED) + + rp_id_hash = hashlib.sha256(rp_id.encode()).digest() + + if not allow_list: + raise CtapError(CtapError.ERR.NO_CREDENTIALS) + + cred_id: bytes | None = None + cred: _Credential | None = None + for desc in allow_list: + cid: bytes = desc["id"] if isinstance(desc, dict) else getattr(desc, "id") + entry = self._creds.get(cid) + if entry is not None and entry.rp_id_hash == rp_id_hash: + cred_id = cid + cred = entry + break + + if cred is None or cred_id is None: + raise CtapError(CtapError.ERR.NO_CREDENTIALS) + + # authData layout matches fido_get_assertion.c build_get_assertion_response(): + # rpIdHash(32) | flags(1) | signCount(4) + auth_data_bytes = ( + rp_id_hash + + bytes([0x01]) # flags: UP=1 + + struct.pack(">I", cred.sign_count) + ) + cred.sign_count += 1 + + # Signature over authData || clientDataHash, DER-encoded. + # Matches drv_crypto_sign_hash_DER() in the firmware. + sig = cred.private_key.sign( + auth_data_bytes + client_data_hash, + ec.ECDSA(hashes.SHA256()), + ) + + user_field: dict[str, Any] | None = {"id": cred.user_id} if cred.user_id else None + + return AssertionResponse( + credential={"id": cred_id, "type": "public-key"}, + auth_data=AuthenticatorData(auth_data_bytes), + signature=sig, + user=user_field, + ) + + # ── test helpers ────────────────────────────────────────────────────────── + + def refusing(self) -> "_RefusingView": + """Return a view of this emulator that always declines confirmation. + + Use this when the test goes through _with_direct_ctap2, which calls + make_credential / get_assertion via a lambda and cannot inject + user_confirms directly: + + with patch.object(state, "_with_direct_ctap2", + side_effect=lambda fn: fn(emulator.refusing())): + ok, msg = state.authenticate_with_card("alice") + self.assertFalse(ok) + """ + return _RefusingView(self) + + def forget_user(self, username: str) -> int: + """Remove all credentials for *username* from the emulator store. + + Returns the number of credentials removed. Use this to simulate a + card-side credential deletion (factory reset, or deliberate removal). + """ + to_delete = [cid for cid, e in self._creds.items() if e.username == username] + for cid in to_delete: + del self._creds[cid] + return len(to_delete) + + def credential_count(self) -> int: + """Total number of credentials currently in the emulator store.""" + return len(self._creds) + + +class _RefusingView: + """Thin proxy returned by CardEmulator.refusing(). + + Forwards make_credential and get_assertion to the underlying emulator + with user_confirms=False, so every call raises OPERATION_DENIED. + The credential store is shared with the parent emulator. + """ + + def __init__(self, emulator: CardEmulator) -> None: + self._emulator = emulator + + def make_credential(self, **kwargs: Any) -> AttestationResponse: + return self._emulator.make_credential(**kwargs, user_confirms=False) + + def get_assertion(self, **kwargs: Any) -> AssertionResponse: + return self._emulator.get_assertion(**kwargs, user_confirms=False) diff --git a/tests/test_k_proxy.py b/tests/test_k_proxy.py index 91f9cf0..cd010b4 100644 --- a/tests/test_k_proxy.py +++ b/tests/test_k_proxy.py @@ -18,6 +18,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent)) import k_proxy_app as app from k_proxy_app import ( @@ -800,5 +801,248 @@ class TestHandlerResource(ServerFixture): self.assertEqual(status, 200) +# ── card emulator integration tests ────────────────────────────────────────── + +from card_emulator import CardEmulator + + +def _make_direct_state(tmp_path): + return _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + + +def _patch_emulator(state, emulator): + """Return a context manager that wires *emulator* into *state* as the card.""" + return patch.multiple( + state, + _with_direct_ctap2=lambda fn: fn(emulator), + _drop_direct_device=lambda: None, + ) + + +class TestCardEmulatorUnit(unittest.TestCase): + """Direct calls to the emulator — no ProxyState involved.""" + + def setUp(self): + self.emulator = CardEmulator() + + def _register(self, username="alice", rp_id="localhost"): + rp_id_hash = __import__("hashlib").sha256(rp_id.encode()).digest() + return self.emulator.make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": rp_id, "name": "Test RP"}, + user={"id": b"user-id", "name": username, "displayName": username}, + key_params=[{"type": "public-key", "alg": -7}], + ) + + def test_make_credential_returns_none_attestation(self): + attest = self._register() + self.assertEqual(attest.fmt, "none") + self.assertEqual(attest.att_stmt, {}) + + def test_make_credential_stores_credential(self): + self._register() + self.assertEqual(self.emulator.credential_count(), 1) + + def test_make_credential_auth_data_is_attested(self): + attest = self._register() + self.assertTrue(attest.auth_data.is_attested()) + + def test_make_credential_cred_id_is_32_bytes(self): + attest = self._register() + self.assertEqual(len(attest.auth_data.credential_data.credential_id), 32) + + def test_make_credential_user_confirms_false_raises(self): + from fido2.ctap import CtapError + with self.assertRaises(CtapError) as ctx: + self._register() # first register so there's a credential + self.emulator.make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": "localhost", "name": "Test RP"}, + user={"id": b"user-id", "name": "bob", "displayName": "bob"}, + key_params=[{"type": "public-key", "alg": -7}], + user_confirms=False, + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_get_assertion_user_confirms_false_raises(self): + from fido2.ctap import CtapError + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError) as ctx: + self.emulator.get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + user_confirms=False, + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_get_assertion_wrong_rp_raises(self): + from fido2.ctap import CtapError + attest = self._register(rp_id="localhost") + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError): + self.emulator.get_assertion( + rp_id="evil.example", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + + def test_get_assertion_empty_allow_list_raises(self): + from fido2.ctap import CtapError + self._register() + with self.assertRaises(CtapError): + self.emulator.get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=None, + ) + + def test_sign_count_increments_across_assertions(self): + import struct + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + + def _count(assertion): + return struct.unpack(">I", bytes(assertion.auth_data)[33:37])[0] + + a1 = self.emulator.get_assertion("localhost", b"\x01" * 32, + [{"id": cred_id, "type": "public-key"}]) + a2 = self.emulator.get_assertion("localhost", b"\x02" * 32, + [{"id": cred_id, "type": "public-key"}]) + self.assertGreater(_count(a2), _count(a1)) + + def test_forget_user_removes_credential(self): + self._register() + removed = self.emulator.forget_user("alice") + self.assertEqual(removed, 1) + self.assertEqual(self.emulator.credential_count(), 0) + + def test_forget_unknown_user_returns_zero(self): + self._register() + self.assertEqual(self.emulator.forget_user("nobody"), 0) + self.assertEqual(self.emulator.credential_count(), 1) + + def test_refusing_view_make_credential_raises(self): + from fido2.ctap import CtapError + with self.assertRaises(CtapError) as ctx: + self.emulator.refusing().make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": "localhost", "name": "Test RP"}, + user={"id": b"u", "name": "alice", "displayName": "Alice"}, + key_params=[{"type": "public-key", "alg": -7}], + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_refusing_view_get_assertion_raises(self): + from fido2.ctap import CtapError + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError) as ctx: + self.emulator.refusing().get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + +class TestCardEmulatorIntegration(unittest.TestCase): + """Full register → authenticate flow through ProxyState with the emulator.""" + + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmpdir.name) + self.state = _make_direct_state(self.tmp_path) + self.emulator = CardEmulator() + + def tearDown(self): + self._tmpdir.cleanup() + + def _register(self, username="alice", display_name=None): + with _patch_emulator(self.state, self.emulator): + return self.state.register_enrollment(username, display_name) + + def _authenticate(self, username="alice"): + with _patch_emulator(self.state, self.emulator): + return self.state.authenticate_with_card(username) + + def _authenticate_refusing(self, username="alice"): + with _patch_emulator(self.state, self.emulator.refusing()): + return self.state.authenticate_with_card(username) + + def test_register_produces_credential_data(self): + enrollment = self._register("alice", "Alice") + self.assertIsNotNone(enrollment.credential_data_b64) + self.assertEqual(enrollment.username, "alice") + + def test_register_persists_to_disk(self): + self._register("alice") + state2 = _make_direct_state(self.tmp_path) + self.assertTrue(state2.has_enrollment("alice")) + self.assertIsNotNone(state2.get_enrollment("alice").credential_data_b64) + + def test_authenticate_after_register_succeeds(self): + self._register("alice") + ok, msg = self._authenticate("alice") + self.assertTrue(ok) + self.assertEqual(msg, "assertion verified") + + def test_authenticate_user_says_no_fails(self): + self._register("alice") + ok, msg = self._authenticate_refusing("alice") + self.assertFalse(ok) + self.assertIn("assertion verification failed", msg) + + def test_register_user_says_no_fails(self): + with _patch_emulator(self.state, self.emulator.refusing()): + with self.assertRaises(RuntimeError) as ctx: + self.state.register_enrollment("alice", None) + self.assertIn("card registration failed", str(ctx.exception)) + + def test_authenticate_after_forget_fails(self): + self._register("alice") + self.emulator.forget_user("alice") + ok, msg = self._authenticate("alice") + self.assertFalse(ok) + + def test_two_users_independent(self): + self._register("alice") + self._register("bob") + ok_a, _ = self._authenticate("alice") + ok_b, _ = self._authenticate("bob") + self.assertTrue(ok_a) + self.assertTrue(ok_b) + + def test_forget_one_user_leaves_other_intact(self): + self._register("alice") + self._register("bob") + self.emulator.forget_user("alice") + ok_a, _ = self._authenticate("alice") + ok_b, _ = self._authenticate("bob") + self.assertFalse(ok_a) + self.assertTrue(ok_b) + + def test_sign_count_increases_across_logins(self): + import struct + from k_proxy_app import AttestedCredentialData, b64u_decode + self._register("alice") + enrollment = self.state.get_enrollment("alice") + cred_data = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64)) + cred_id = cred_data.credential_id + + sign_counts = [] + for _ in range(3): + assertion = self.emulator.get_assertion( + rp_id=self.state.rp_id, + client_data_hash=b"\xAB" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + sign_counts.append(struct.unpack(">I", bytes(assertion.auth_data)[33:37])[0]) + + self.assertLess(sign_counts[0], sign_counts[1]) + self.assertLess(sign_counts[1], sign_counts[2]) + + if __name__ == "__main__": unittest.main(verbosity=2) From 35c40985dd5027c3c2b3e954b01b365cc73f07de Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Mon, 27 Apr 2026 16:32:09 +0200 Subject: [PATCH 24/24] Update Setup.md and Workplan.md for 2026-04-27 emulator session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records CardEmulator addition, the two fido2-direct id=/raw_id= bug fixes, and the expanded test count (100 → 122). Marks project status unchanged: Phases 7 and 9 remain externally gated. Co-Authored-By: Claude Sonnet 4.6 --- Setup.md | 20 ++++++++++++++++++++ Workplan.md | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Setup.md b/Setup.md index bd60b21..057b019 100644 --- a/Setup.md +++ b/Setup.md @@ -648,6 +648,26 @@ Session note (2026-04-26, markdown maintenance re-scan): - direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime - the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up +Session note (2026-04-27, card emulator and bug fixes): +- Added software emulator of the ChromeCard FIDO2 authenticator: + - `/home/user/chromecard/tests/card_emulator.py` + - implements `make_credential` and `get_assertion` with real P-256 cryptography + - in-memory credential store keyed by credential ID (matching firmware layout) + - auth_data byte layout and COSE key encoding mirror `fido_make_cred.c` / `fido_get_assertion.c` exactly + - `user_confirms=True/False` parameter simulates the card's Yes/No confirmation dialog + - `refusing()` method returns a wrapper that forces `user_confirms=False` for integration test paths + - `forget_user(username)` simulates card-side credential removal + - module docstring is the usage guide +- Fixed two bugs in `k_proxy_app.py` that were silently breaking fido2-direct mode: + - `RegistrationResponse(id=..., ...)` → `RegistrationResponse(raw_id=..., ...)` (fido2 2.2.0 API) + - `AuthenticationResponse(id=..., ...)` → `AuthenticationResponse(raw_id=..., ...)` (same) + - both calls raised `TypeError` at runtime, caught by the surrounding `except`, so register and + authenticate in fido2-direct mode always returned failure without any visible error +- Extended test suite: 22 new tests across `TestCardEmulatorUnit` and `TestCardEmulatorIntegration` + - covers: register, authenticate, user-says-no (register and auth), forget, two-user isolation, + sign-count monotonicity, wrong RP rejection, empty allow-list rejection + - total test count is now 122, all passing locally without card or VMs + ## Known FIDO2 Transport Boundary - FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT. diff --git a/Workplan.md b/Workplan.md index ea39e52..089df63 100644 --- a/Workplan.md +++ b/Workplan.md @@ -553,8 +553,15 @@ Status (2026-04-27): - fido2-direct mode confirmed working end-to-end with real card via browser on k_client. - Full register → login → counter → logout flow verified with physical card button presses. - Bug fixed: ClientState.enroll() now calls /session/logout on k_proxy before re-enrolling. -- 100-test unit suite added for k_proxy (tests/test_k_proxy.py); runs locally without card or VMs. - All three service files refactored and re-deployed. +- Added CardEmulator: software emulator of the ChromeCard FIDO2 authenticator for use in tests. + - real P-256 crypto; auth_data layout mirrors firmware exactly + - user_confirms=True/False simulates card Yes/No; refusing() wrapper for integration test paths + - forget_user() simulates card-side key removal + - module docstring in tests/card_emulator.py is the usage guide +- Fixed two silent fido2-direct bugs: RegistrationResponse and AuthenticationResponse were both + constructed with id= instead of raw_id=; all direct-mode register/authenticate calls were failing. +- Test suite now at 122 tests (was 100), all passing locally without card or VMs. Phase status (2026-04-27): - Phase 6.5 (concurrency): deferred. Ceiling (~10 in-flight) is acceptable until multi-card use cases arrive.