From 8888601f69ad44233e55ba8a71dfe1ab0c9555d2 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Fri, 24 Apr 2026 05:52:44 +0200 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 20/20] 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: