commit 83a6382270e42a0d61fb0d7e2a51f45139b117cc Author: Morten V. Christiansen Date: Wed Apr 29 22:06:14 2026 +0200 Initial commit: chromecard workspace snapshot Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..b1a4215 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Local agent and transient artifacts +.codex +.codex/ +__pycache__/ +*.pyc +tls/ +node_modules/ +playwright-report/ +test-results/ + +# Keep firmware SDK tree out of this workspace-tracking repo +CR_SDK_CK-main/ + +# Flutter/Dart build artifacts +k_phone/.dart_tool/ +k_phone/build/ +k_phone/.flutter-plugins +k_phone/.flutter-plugins-dependencies +k_phone/android/.gradle/ +k_phone/android/local.properties +k_phone/android/app/build/ +k_phone/ios/.symlinks/ +k_phone/ios/Pods/ +k_phone/ios/Flutter/App.framework +k_phone/ios/Flutter/Flutter.framework +k_phone/ios/Flutter/Generated.xcconfig +*.g.dart +*.freezed.dart diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d12c863 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository policy + +- `CR_SDK_CK-main/` is the firmware SDK source tree. Treat it as **read-only**. Do not add or edit files there. +- All host-side scripts live at the workspace root (`/home/user/chromecard`). +- `Setup.md` and `Workplan.md` are the canonical living docs. After each meaningful execution step, update `Setup.md` for environment/runtime state and `Workplan.md` for phase progress and next blocking action. + +## Running tests + +```bash +# Python unit tests (no VMs or card required, 122 tests) +python3 -m unittest tests/test_k_proxy.py + +# Playwright browser regression (requires services running and forwarded portal) +PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client + +# Split-VM end-to-end regression helper (runs from host via SSH into k_client) +./phase5_chain_regression.sh +./phase5_chain_regression.sh --interactive-card --expect-auth-mode fido2_assertion +``` + +## Probing the card (on k_proxy) + +```bash +python3 /home/user/chromecard/fido2_probe.py --list +python3 /home/user/chromecard/fido2_probe.py --json +python3 /home/user/chromecard/raw_ctap_probe.py info +python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost +python3 /home/user/chromecard/webauthn_local_demo.py # opens http://localhost:8765 +``` + +## Generating TLS certificates + +```bash +python3 generate_phase2_certs.py +# Writes tls/phase2/ca.crt, k_proxy.crt/.key, k_server.crt/.key +``` + +## Starting services (split-VM shape) + +**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 +``` + +**k_proxy VM:** +```bash +qvm-connect-tcp 9780:k_server:8780 + +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 + # Add --auth-mode fido2-direct for real CTAP2 (default is probe mode) +``` + +**k_client VM:** +```bash +qvm-connect-tcp 9771:k_proxy:8771 + +python3 /home/user/chromecard/k_client_portal.py \ + --proxy-base-url https://127.0.0.1:9771 + # Browser demo page at http://127.0.0.1:8766 +``` + +Files are deployed to VMs via `scp :~` and run via `ssh `. + +## Architecture + +**Qubes 3-VM topology:** `k_client` → `k_proxy` → `k_server`, each a Debian 13 AppVM. + +Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP routing). Validated chain: +- `k_client localhost:9771` → `k_proxy:8771` +- `k_proxy localhost:9780` → `k_server:8780` + +**k_proxy_app.py** — session gateway and FIDO2 auth bridge. Two auth modes: +- `probe` (default): validates card presence by subprocess-calling `fido2_probe.py --json` +- `fido2-direct`: performs real CTAP2 `makeCredential`/`getAssertion` against the physical card via `python-fido2`; auto-detects the FIDO hidraw device + +`ProxyState` holds all server-side state: in-memory session store (guarded by one lock), enrollment DB (JSON file), and an `UpstreamPool` of persistent TLS connections to k_server. Sessions are lost on restart. + +**k_server_app.py** — protected resource backend. Exposes a monotonic counter behind `X-Proxy-Token` auth. Counter state is in-memory only; resets on restart. Lock guards counter increments. + +**k_client_portal.py** — thin browser-facing portal in k_client. Delegates all auth and resource calls to k_proxy. Holds only a local preferred username; enrollment and session state live in k_proxy. + +**FIDO2 transport:** Card communicates over USB HID (CTAPHID) on `/dev/hidraw0` (FIDO interface, usage page `0xF1D0`). `/dev/hidraw1` is a separate vendor HID interface. If the card re-enumerates, k_proxy auto-detects the correct node. If CTAPHID stops responding, a full USB power cycle is the recovery path. + +**CardEmulator** (`tests/card_emulator.py`) — software emulator of the card for unit tests. Implements `make_credential`/`get_assertion` with real P-256 crypto; `user_confirms=False` simulates card rejection. Wire it into tests by patching `_with_direct_ctap2` and `_drop_direct_device` on `ProxyState`. See the module docstring for the exact patch pattern. + +**Key enrollment endpoints on k_proxy:** `POST /enroll/register`, `GET /enroll/status`, `POST /enroll/update`, `POST /enroll/delete`, `GET /enroll/list`. Usernames are normalized to lowercase, 3–32 chars `[a-z0-9._-]`. + +**Key session endpoints on k_proxy:** `POST /session/login`, `POST /session/status`, `POST /session/logout`, `POST /resource/counter`. + +## Known limits and blockers + +- Concurrency ceiling on the browser-facing forwarded path is ~10 in-flight requests; higher fan-out triggers Qubes vchan failures (`xs_transaction_start: No space left on device`). +- If CTAPHID `INIT` packets get no reply after a card reattach, a full USB power cycle recovers the transport. +- `CR_SDK_CK-main` is missing role directories (`mvp`, `setup`, `components`, `samples`) required for the firmware build/flash flow (`./scripts/build_flash_mvp.sh`). `west` and `nrfjprog` must also be installed. +- Phases 7 (firmware build) and 9 (phone-wireless transport) are externally gated. diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md new file mode 100644 index 0000000..ead85d2 --- /dev/null +++ b/PHASE5_RUNBOOK.md @@ -0,0 +1,240 @@ +# Phase 5 Runbook (Session Reuse Prototype) + +This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse testing. + +Last updated: 2026-04-26 + +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 + - explicit "k_server was not called" behavior when login is denied + +## 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. + +## 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 + +### 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 the same 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 +``` + +### 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. + +## 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: + +- 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 --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"}' +``` + +Copy `session_token` from response, then: + +```bash +TOKEN='' +``` + +Check session: + +```bash +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 --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/resource/counter \ + -H "Authorization: Bearer $TOKEN" +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 --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 --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:/resource/counter \ + -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 +``` + +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 + +- 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 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 +- `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 new file mode 100644 index 0000000..08877a2 --- /dev/null +++ b/Setup.md @@ -0,0 +1,784 @@ +# Setup + +Last updated: 2026-04-29 + +This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`. +Update this file whenever environment status or verified behavior changes. + +## Repository Policy + +- Treat `/home/user/chromecard/CR_SDK_CK-main` as read-only in this workflow. +- Do not add or modify helper/test scripts inside `CR_SDK_CK-main`. +- Keep host-side helper scripts at workspace root (`/home/user/chromecard`). + +## Documentation Maintenance + +- Canonical living status docs for this workspace are: + - `/home/user/chromecard/Setup.md` + - `/home/user/chromecard/Workplan.md` +- After each meaningful execution step, update at least: + - `Setup.md` for observed environment/runtime state + - `Workplan.md` for phase progress and next blocking action +- Keep helper script paths consistent in docs: + - `/home/user/chromecard/fido2_probe.py` + - `/home/user/chromecard/webauthn_local_demo.py` +- Treat `CR_SDK_CK-main/README_HOST.md` as historical reference unless its script paths are aligned with this workspace policy. + +## Scope + +- Experimental ChromeCard connected over USB. +- Firmware source tree: `/home/user/chromecard/CR_SDK_CK-main`. +- Host-side FIDO2 demo tools: + - `/home/user/chromecard/fido2_probe.py` + - `/home/user/chromecard/webauthn_local_demo.py` +- Target runtime platform: Qubes OS with 3 AppVMs: + - `k_client` (browser + enrollment process) + - `k_proxy` (card-connected proxy/auth client) + - `k_server` (protected resource/backend) + +## Planned Transport Evolution + +- Current phase assumption: card is connected directly to `k_proxy` (USB). +- Future target: card is connected to a phone, and `k_proxy` performs validation through a wireless link to that phone. +- Design implication: keep authenticator transport behind an abstraction in `k_proxy` so USB-direct and phone-wireless backends can be swapped without changing client/server API contracts. + +## Target Qubes Topology + +- Base template for all AppVMs: `debian-13-xfce`. +- Allowed network paths: + - `k_client` -> `k_proxy` over TLS + - `k_proxy` -> `k_server` over TLS + - Response traffic returns on those established connections. +- Disallowed direct path: + - `k_client` -> `k_server` (direct access should be blocked). + +Functional roles: +- `k_client`: + - Browser-only traffic client. + - Runs a user enrollment process. +- `k_proxy`: + - Current: connected to the ChromeCard over USB. + - Future: connects wirelessly to phone-attached card for validation. + - Accepts TLS requests from `k_client`. + - Uses card-backed FIDO2/WebAuthn operations to authenticate user/session. + - Calls `k_server` over TLS after successful authorization. + - Returns proxied data and session information to `k_client`. +- `k_server`: + - Hosts resource(s) requiring login via the proxy-mediated flow. + - Provides a dummy protected resource for early integration testing (monotonic increasing number/counter). + - May hold user/session state logic needed for authorization decisions. + +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`. +2. `k_proxy` validates/authenticates user via card-backed flow. +3. If allowed, `k_proxy` opens HTTPS request to `k_server` resource. +4. `k_server` responds to `k_proxy`. +5. `k_proxy` returns response payload to `k_client` plus session state. +6. Subsequent requests reuse session state so card auth is not required every request. + +Implementation note: +- `k_proxy` does not need a full web server stack; a minimal TLS API service is sufficient. +- Session state should be integrity-protected (signed/encrypted token or server-side session ID) with TTL and revocation behavior defined. +- `k_proxy` and `k_server` must be safe under concurrent access (thread-safe state handling). + +## Minimum Service Behavior (Current Target) + +- `k_server`: + - Expose protected endpoint returning an increasing integer value (dummy resource). + - Increment behavior must remain correct under concurrent requests. + - Optionally expose/maintain user/session validation logic. +- `k_proxy`: + - Accept concurrent HTTPS requests from one or more `k_client` instances. + - Perform card-backed auth when no valid session is present. + - Cache and validate session state so repeated requests avoid card access until expiry. + - Forward authorized requests to `k_server` and return upstream data plus session info. + +Thread-safety expectation: +- Shared mutable state (counter, session store, user state) must be protected against races. +- Parallel requests must not corrupt session records or return duplicate/skipped counter values caused by unsafe updates. + +## Test Topology Requirement + +- Support concurrency testing from multiple simultaneous clients: + - multiple browser tabs/processes in one `k_client`, and/or + - multiple `k_client` AppVM instances if available. +- Validate both correctness and stability under load: + - session reuse works as intended + - unauthorized access stays blocked + - protected counter/resource remains consistent. + +## Current Status Snapshot (2026-04-24) + +- 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` +- 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` +- 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` +- `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 AppVM baseline is now up: `k_client`, `k_proxy`, `k_server` can start and have terminals running. + +Implication: +- 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): +- 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. +- 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). +- 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, 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, 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, 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, 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, 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 +- 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 +- 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. + +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: + - 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 + +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 + - 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` + - 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` + - `--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 + - 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` + - `/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 + +Session note (2026-04-27, card emulator and bug fixes): +- Added software emulator of the ChromeCard FIDO2 authenticator: + - `/home/user/chromecard/tests/card_emulator.py` + - implements `make_credential` and `get_assertion` with real P-256 cryptography + - in-memory credential store keyed by credential ID (matching firmware layout) + - auth_data byte layout and COSE key encoding mirror `fido_make_cred.c` / `fido_get_assertion.c` exactly + - `user_confirms=True/False` parameter simulates the card's Yes/No confirmation dialog + - `refusing()` method returns a wrapper that forces `user_confirms=False` for integration test paths + - `forget_user(username)` simulates card-side credential removal + - module docstring is the usage guide +- Fixed two bugs in `k_proxy_app.py` that were silently breaking fido2-direct mode: + - `RegistrationResponse(id=..., ...)` → `RegistrationResponse(raw_id=..., ...)` (fido2 2.2.0 API) + - `AuthenticationResponse(id=..., ...)` → `AuthenticationResponse(raw_id=..., ...)` (same) + - both calls raised `TypeError` at runtime, caught by the surrounding `except`, so register and + authenticate in fido2-direct mode always returned failure without any visible error +- Extended test suite: 22 new tests across `TestCardEmulatorUnit` and `TestCardEmulatorIntegration` + - covers: register, authenticate, user-says-no (register and auth), forget, two-user isolation, + sign-count monotonicity, wrong RP rejection, empty allow-list rejection + - total test count is now 122, all passing locally without card or VMs + +Session note (2026-04-29, Phase 9 k_phone bring-up): +- Phase 9 approved and started: Flutter Android app (`k_phone`) replaces `k_proxy` in the auth chain. +- Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor. +- Mac environment: + - Flutter SDK installed (stable channel) + - Android Studio installed with API 37 emulator (`Pixel_7_Pro_API_37`) + - Python package manager: `brew install uv` used as workaround — macOS 26 beta broke `pip` on both Python 3.14 (Homebrew default) and Python 3.12 due to libexpat ABI mismatch +- `k_phone` Flutter project scaffolded at `/Users/mortenv.christiansen/Desktop/chromecard/k_phone/` + - Kotlin `MainActivity.kt` registers USB HID platform channel (`com.chromecard.kphone/usb_hid`) + - `lib/ctaphid_channel.dart`: CTAPHID framing/fragmentation + two transports (USB MethodChannel and emulator TCP socket) + - `lib/proxy_service.dart`: background service HTTP proxy (flutter_background_service v5) + - `lib/session_manager.dart`: in-memory bearer token sessions with TTL + - `lib/k_server_client.dart`: HTTP forwarder to k_server (:8780) + - `android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt`: USB HID platform channel implementation +- Build issues resolved (10+ iterations): + - AGP bumped to 8.7.3, Gradle wrapper to 8.10.2, Kotlin to 2.1.0 + - Foreground service type changed from `connectedDevice` to `dataSync` for emulator compatibility + - Notification channel created natively in `MainActivity.onCreate()` before service starts + - `MissingPluginException` caught in all USB channel calls (USB plugin not registered in background isolate) + - Core library desugaring enabled with `desugar_jdk_libs:2.1.4` + - Network security config added to allow cleartext to `10.0.2.2` (Mac host alias in Android emulator) +- Card emulator bridge added: `tests/card_emulator_bridge.py` + - asyncio TCP server on `127.0.0.1:8772` + - bridges CTAPHID packets from Android emulator to Python `CardEmulator` + - handles CTAPHID INIT (CID allocation), multi-packet reassembly, CBOR dispatch to `CardEmulator` + - run with: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography tests/card_emulator_bridge.py` +- End-to-end bridge verified: app reports `Card open, CID=0x1` — CTAPHID handshake with CardEmulator confirmed +- Current status (2026-04-29, emulator FIDO2 verified): + - App builds and runs on Android emulator + - Service auto-starts (`autoStart: true` for testing; revert to `false` for production) + - USB transport falls back to emulator TCP bridge on `10.0.2.2:8772` + - FIDO2 endpoints fully implemented (enrollment_db.dart, fido2_ops.dart, proxy_service.dart) + - Three bugs fixed during emulator integration: + 1. CTAP2 command prefix bytes missing from CTAPHID CBOR payload (fido2_ops.dart) + 2. Socket single-subscription stream bug — `await for ... break` cannot be reused (ctaphid_channel.dart) + 3. `on StateError` catch masked socket write errors as "user already enrolled" (proxy_service.dart) + - Verified end-to-end on emulator with CardEmulator bridge: + - `/enroll/register` → makeCredential → `has_credential: true` + - `/session/login` → getAssertion + ECDSA verify → `auth_mode: fido2_assertion` + - `/session/status`, `/session/logout`, post-logout 401 — all correct + - `/resource/counter` fails (k_server not running in Mac test env — expected) + - Next step: deploy to real Android phone, test USB HID path with physical ChromeCard + +## Known FIDO2 Transport Boundary + +- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT. +- Key code points in `CR_SDK_CK-main`: + - `mgr_fido2.c`: `mgr_fido2_init()` registers `fido2_ctaphid_handle_packet`. + - `ctaphid.c`: `fido2_ctaphid_handle_packet(...)`. + - `cr_config.h`: FIDO2 HID report descriptor definitions. + +## Host Bring-Up Steps (How To Get To A Working FIDO2 Check) + +1. Confirm USB enumeration and HID visibility. +- Replug card with a known data-capable cable. +- Check: `ls -l /dev/hidraw*` + +2. If needed, grant Linux HID access for this device. +- Add rule at `/etc/udev/rules.d/70-chromecard-fido.rules`: +```udev +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="0660", TAG+="uaccess" +``` +- Reload/apply rules and replug the device. + +3. Verify CTAP HID presence. +- `python3 /home/user/chromecard/fido2_probe.py --list` +- Then: +- `python3 /home/user/chromecard/fido2_probe.py --json` +- 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` +- Open `http://localhost:8765` (use `localhost`, not `127.0.0.1`). + +5. Execute register/login test. +- Register a user. +- Login with the same user. +- Confirm no origin/challenge mismatch errors. + +## Build/Flash Prerequisites (How To Get To Firmware Build) + +1. Ensure full SDK checkout layout exists under `CR_SDK_CK-main`: +- `mvp` +- `setup` +- `components` +- `samples` + +2. Ensure toolchain is available in shell: +- `west --version` +- `nrfjprog --version` + +3. Once layout/tooling are in place, run: +- `cd /home/user/chromecard/CR_SDK_CK-main` +- `./scripts/build_flash_mvp.sh` + +## Open Gaps To Resolve + +- 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 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 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 new file mode 100644 index 0000000..af7b777 --- /dev/null +++ b/Workplan.md @@ -0,0 +1,654 @@ +# Workplan + +Last updated: 2026-04-29 + +This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine. + +## Constraints + +- Treat `/home/user/chromecard/CR_SDK_CK-main` as read-only. +- Keep helper scripts such as `fido2_probe.py` and `webauthn_local_demo.py` at `/home/user/chromecard`. +- Target deployment model is Qubes OS with 3 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 + +- Re-establish deterministic host-to-card FIDO2 communication over USB HID/CTAPHID. +- Restore a buildable/flashable firmware workspace for `CR_SDK_CK-main`. +- Turn ad-hoc demos into a repeatable verification flow. +- Stand up chained TLS communication in Qubes: `k_client -> k_proxy -> k_server`. +- Support both login flow (browser in `k_client`) and user enrollment flow (process in `k_client`). +- Minimize repeated card prompts by introducing secure session reuse after successful authentication. +- Implement a protected dummy resource on `k_server` (monotonic counter) for end-to-end validation. +- Ensure `k_proxy` and `k_server` are thread-safe and support concurrent access. +- Prepare `k_proxy` auth path for future transport shift: USB-direct -> wireless phone bridge. + +## Phase 0: Qubes VM Baseline (Blocking) + +1. Provision/verify AppVMs. +- Ensure `k_client`, `k_proxy`, `k_server` exist and are based on `debian-13-xfce`. + +2. Assign functional responsibilities. +- `k_client`: browser client + enrollment process. +- `k_proxy`: USB card access + proxy/auth bridge. +- `k_server`: protected resource/service endpoint. + +3. Define TLS endpoints and certificates. +- `k_proxy` presents TLS service to `k_client`. +- `k_server` presents TLS service to `k_proxy`. +- Trust roots and cert distribution model documented per VM. + +Exit criteria: +- All 3 VMs exist, boot, and have clearly defined service ownership. + +## Phase 1: Qubes Firewall Policy + +1. Enforce allowed forward paths only. +- Allow `k_client` outbound TLS only to `k_proxy` service port(s). +- Allow `k_proxy` outbound TLS only to `k_server` service port(s). +- Deny direct `k_client` to `k_server` traffic. + +2. Validate return path behavior. +- Confirm responses propagate back through established flows. + +3. Verify with simple probes. +- TLS handshake and HTTP(S) checks from `k_client` to `k_proxy`. +- TLS handshake and HTTP(S) checks from `k_proxy` to `k_server`. + +Exit criteria: +- Policy matches intended chain and is test-verified. + +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. + +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 + +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. +- Create or import CA and issue certs for `k_proxy` and `k_server`. +- Install trust roots in client VM(s) that need validation. + +2. Service shape. +- `k_server`: HTTPS service exposing protected resource endpoint(s), including a monotonic counter endpoint. +- `k_proxy`: minimal HTTPS API gateway service (full web server framework not required). + +3. Endpoint contract. +- Define request/response schema between `k_client` and `k_proxy`. +- Define upstream request contract from `k_proxy` to `k_server`. + +Exit criteria: +- Mutual TLS trust decisions are documented and tested. +- HTTPS calls succeed on both links with expected cert validation. + +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. +- Decide where user/session state is authoritative (`k_proxy`, `k_server`, or split model). +- Define token/session format and validation boundary. + +2. Concurrency controls. +- Define thread-safe strategy for session store and shared counters. +- Define locking/atomic/update semantics for counter increments and session updates. + +3. Runtime model. +- Choose service runtime/config that supports simultaneous requests safely. + +Exit criteria: +- Architecture clearly documents state authority and race-free update rules. + +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 + +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. +- Check cable/port and confirm device appears in USB listings. +- Confirm `/dev/hidraw*` nodes appear when card is connected. + +2. Validate Linux permissions. +- Install/update udev rule for ChromeCard HID VID/PID. +- Reload udev and verify non-root read/write access to hidraw node. + +3. Re-run host probe. +- Run `python3 /home/user/chromecard/fido2_probe.py --list`. +- Run `python3 /home/user/chromecard/fido2_probe.py --json`. +- Record VID/PID/path and CTAP2 `getInfo` output in `Setup.md`. + +Exit criteria: +- At least one CTAP HID device is listed. +- `--json` returns valid `ctap2_info`. + +## Phase 4: Re-validate Local WebAuthn Demo on `k_proxy` + +1. Start local demo server. +- Run `python3 /home/user/chromecard/webauthn_local_demo.py`. +- Confirm URL is `http://localhost:8765`. + +2. Exercise register/login. +- Register a test user. +- Authenticate with same user. +- Capture errors (if any) and update `Setup.md`. + +3. Decide next demo hardening step. +- Keep bring-up-only mode, or +- add signature verification for attestation/assertion. + +Exit criteria: +- Register and login both complete with card interaction prompts. + +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. +- `k_proxy` handles initial auth using connected card. +- On success, create session state for `k_client`. + +2. Session model. +- Prefer server-side session store or signed session token. +- Include TTL/expiry, rotation, and explicit invalidation/logout path. +- Do not expose card secrets or long-lived auth material to `k_client`. + +3. Proxying behavior. +- With valid session: `k_proxy` forwards request to `k_server` and returns result. +- Without valid session: require fresh card-backed auth flow. + +Exit criteria: +- Repeated authorized requests do not require card interaction until session expiry. +- Expired/invalid sessions are correctly rejected. + +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. + +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` +- 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 + - 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 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` + +1. Protected dummy resource. +- Add endpoint returning increasing number. +- Require valid upstream auth/session context from `k_proxy`. + +2. Optional user/session handling. +- Add minimal user/session checks if `k_server` is chosen as authority (or partial authority). + +3. Correctness under concurrency. +- Ensure increments are monotonic and race-safe under parallel calls. + +Exit criteria: +- Authorized requests obtain consistent increasing values. +- Unauthorized requests are rejected. + +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`. +- Start process from `k_client` that captures new-user enrollment intent/data. +- Route enrollment requests to `k_proxy` over TLS. + +2. Card-mediated login in `k_proxy`. +- `k_proxy` uses connected card for FIDO2/WebAuthn operations. +- `k_proxy` authenticates toward `k_server` over TLS. + +3. Browser flow in `k_client`. +- Browser traffic goes only to `k_proxy`. + +Immediate next action: +- 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` + - `/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 + - 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`. + +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` 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 + - 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 + - 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 + - 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: + - 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` 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): +- 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. +- Generate parallel request bursts from `k_client` to `k_proxy`. +- Verify response integrity, session reuse behavior, and error rates. + +2. Multi-client tests. +- Run requests from multiple `k_client` instances (or equivalent parallel clients) concurrently. +- Verify isolation between users/sessions. + +3. Acceptance checks. +- No race-related crashes/corruption in `k_proxy` or `k_server`. +- Counter/resource behavior remains correct under load. +- Session reuse reduces card prompts while preserving authorization checks. + +Exit criteria: +- Test results demonstrate stable concurrent operation with documented limits. + +## Phase 7: Restore Firmware Build/Flash Path + +1. Validate SDK tree completeness. +- Confirm presence of `mvp`, `setup`, `components`, `samples` under `CR_SDK_CK-main`. +- If missing, obtain full repository/checkpoint and document source. + +2. Install/enable build tools. +- Ensure `west` and `nrfjprog` are available in shell. +- Confirm target board/toolchain match (`nrf7002dk/nrf5340/cpuapp`, NCS `v2.9.2` baseline in docs). + +3. Run baseline build+flash. +- From `CR_SDK_CK-main`, run `./scripts/build_flash_mvp.sh`. +- If flashing fails, run documented recovery and retry. + +Exit criteria: +- Successful `west build` and `west flash`. + +## Phase 8: Consolidate Documentation and Paths + +1. Remove path drift between docs and actual files. +- Keep `fido2_probe.py` and `webauthn_local_demo.py` at workspace root. +- Ensure docs never instruct placing helper scripts under `CR_SDK_CK-main`. +- Update references consistently in all docs. + +2. Keep `Setup.md` current. +- After each significant change, update status snapshot and outcomes. + +3. Add minimal reproducibility checklist. +- One command list for probe + demo + build/flash prechecks. + +4. Maintain Markdown execution records continuously. +- `Setup.md` and `Workplan.md` are the canonical living docs for this workspace. +- Re-scan relevant `.md` files before each new execution cycle and reconcile drift. +- Record date-stamped session notes when priorities or blockers change. + +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. + +## Phase 9: Migrate to Phone-Mediated Wireless Validation + +Status (2026-04-29): **ACTIVE — emulator integration verified** + +Architecture: `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server` + +The `k_phone` Flutter app replaces `k_proxy` entirely. It presents the same HTTP API as `k_proxy_app.py` +so `k_client_portal.py` and the browser portal work without changes. + +**Development environment:** Mac (not Qubes). Android emulator is incompatible with Xen/Qubes. All +k_phone development and testing runs on the Mac with the Android emulator and `card_emulator_bridge.py`. + +### Work completed (2026-04-29) + +- Flutter project scaffolded at `k_phone/` (no `flutter create` — fully hand-written) +- 10+ Android build issues resolved (AGP, Gradle, Kotlin, desugaring, notification channel, foreground service type) +- `k_phone/lib/ctaphid_channel.dart`: full CTAPHID framing + USB/emulator dual-transport + - Fixed: persistent socket subscription (single-subscription stream cannot use `await for ... break` per packet) + - Fixed: `_emulatorSocketOpen` flag prevents dead-socket writes from raising `StateError` + - Fixed: emulator round-trip sends all request packets before reading (no per-packet blocking) +- `k_phone/lib/proxy_service.dart`: full HTTP proxy — all endpoints implemented, error handling hardened + - Fixed: card-error try-catch separated from DB StateError catch (was masking socket errors as "user already enrolled") + - `autoStart: true` for emulator testing; revert to `false` for production builds +- `k_phone/lib/enrollment_db.dart`: enrollment model + JSON persistence via path_provider +- `k_phone/lib/fido2_ops.dart`: CTAP2 `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification + - Fixed: CTAP2 command prefix bytes (0x01/0x02) prepended to CBOR payload per CTAP2-over-CTAPHID spec +- `k_phone/lib/session_manager.dart`: in-memory bearer token sessions +- `k_phone/lib/k_server_client.dart`: HTTP forwarder to k_server +- `k_phone/android/app/src/main/kotlin/.../MainActivity.kt`: USB HID Kotlin platform channel +- `tests/card_emulator_bridge.py`: asyncio CTAPHID TCP bridge wrapping `CardEmulator` for emulator dev + +### Verified on emulator (2026-04-29) + +``` +POST /enroll/register → makeCredential via bridge → has_credential: true ✓ +POST /session/login → getAssertion + ECDSA verify → auth_mode: fido2_assertion ✓ +POST /session/status → 299 s remaining ✓ +POST /session/logout → invalidated: true ✓ +POST /resource/counter → internal error (k_server not running locally — expected) +POST /resource/counter (after logout) → 401 invalid or expired session ✓ +``` + +Bridge log confirmed: +``` +CTAP2 cmd=0x01 body=180 bytes → makeCredential OK auth_data=164 bytes +CTAP2 cmd=0x02 body=113 bytes → getAssertion OK auth_data=37 bytes sig=71 bytes +``` + +### Next action + +- Deploy to a real Android phone with physical ChromeCard via USB +- Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection) +- Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running + +### k_phone API contract (must match k_proxy_app.py exactly) + +- `GET /health` +- `POST /enroll/register` `{"username","display_name"}` +- `GET /enroll/status?username=` +- `POST /enroll/update` `{"username","display_name"}` +- `POST /enroll/delete` `{"username"}` +- `GET /enroll/list` +- `POST /session/login` `{"username"}` +- `POST /session/status` +- `POST /session/logout` +- `POST /resource/counter` (forwarded to k_server with X-Proxy-Token) + +### Key design decisions + +- rp_id: `"localhost"`, origin: `"https://localhost"` (matches k_proxy_app.py defaults) +- clientDataHash = SHA256(clientDataJSON), where clientDataJSON = `{"type":"webauthn.create","challenge":"","origin":"https://localhost","crossOrigin":false}` +- credential_data_b64 stores `AttestedCredentialData` bytes = `aaguid(16) + credIdLen(2) + credId(n) + coseKey` +- Signature verification: ECDSA-SHA256(authData || clientDataHash, P-256 pubKey extracted from COSE key) +- No begin/complete HTTP round-trip — registration and auth are each a single HTTP call (same as Python) +- Sessions: server-side in-memory, TTL 300 s (matching Python default), token = 32-byte hex + +### start bridge for emulator testing + +```bash +uv run --python 3.12 --with fido2 --with cbor2 --with cryptography tests/card_emulator_bridge.py +``` + +### Phase 9 exit criteria + +- `k_phone` presents identical HTTP API to `k_proxy_app.py` (so k_client works unchanged) +- Registration and login both complete via `card_emulator_bridge.py` in emulator testing +- With physical ChromeCard plugged into Android phone: full register → login → counter → logout works +- `phase5_chain_regression.sh` passes against `k_phone` on Android + +## Current Next Step + +Status (2026-04-29): +- Phase 9 emulator milestone complete: makeCredential + getAssertion verified via CardEmulator bridge. +- Next blocking step: deploy to real Android phone with ChromeCard over USB. +- k_server is not running in the Mac test environment; counter endpoint will work once running in Qubes. + +Phase status (2026-04-29): +- Phase 6.5 (concurrency): deferred. ~10 in-flight ceiling is acceptable. +- Phase 7 (firmware build/flash): blocked on Chrome Roads (card vendor). +- Phase 9 (phone integration): **emulator FIDO2 verified; physical phone + USB HID path is next.** + +Status (2026-04-26, markdown maintenance): +- Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files. + +## Inputs Expected During This Session + +- Exact observed behavior on reconnect attempts (USB/hidraw/probe). +- Whether we should pull server-side code now. +- Any board/firmware variants different from default documentation assumptions. +- Preferred TLS ports, certificate approach, and hostname scheme for `k_client`, `k_proxy`, `k_server`. +- Session TTL and invalidation requirements for cached authenticated access. +- Decision on where user/session authority lives (`k_proxy` vs `k_server` vs split). +- Target concurrency level for validation (parallel clients and parallel requests per client). +- Preferred wireless transport/protocol between `k_proxy` and phone (for future phase). + +## 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`) 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/fido2_probe.py b/fido2_probe.py new file mode 100755 index 0000000..f409beb --- /dev/null +++ b/fido2_probe.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Minimal host tool for checking CTAP2/FIDO2 connectivity to a ChromeCard. + +Requires: + pip install fido2 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +try: + from fido2.hid import CtapHidDevice + from fido2.ctap2 import Ctap2 +except Exception as exc: + print("Missing dependency: python-fido2", file=sys.stderr) + print("Install with: python3 -m pip install fido2", file=sys.stderr) + print(f"Import error: {exc}", file=sys.stderr) + sys.exit(2) + + +def _json_default(value: Any) -> Any: + if isinstance(value, bytes): + return value.hex() + if isinstance(value, set): + return sorted(value) + return str(value) + + +def list_devices() -> list[CtapHidDevice]: + return list(CtapHidDevice.list_devices()) + + +def describe_device(dev: CtapHidDevice) -> dict[str, Any]: + desc = getattr(dev, "descriptor", None) + out = { + "product_name": getattr(desc, "product_name", None), + "manufacturer": getattr(desc, "manufacturer_string", None), + "vendor_id": getattr(desc, "vid", None), + "product_id": getattr(desc, "pid", None), + "path": getattr(desc, "path", None), + } + return out + + +def print_device_table(devs: list[CtapHidDevice]) -> None: + if not devs: + print("No CTAP HID devices found.") + return + for idx, dev in enumerate(devs): + info = describe_device(dev) + print( + f"[{idx}] " + f"vid:pid={info['vendor_id']}:{info['product_id']} " + f"manufacturer={info['manufacturer']} " + f"product={info['product_name']} " + f"path={info['path']}" + ) + + +def get_info(dev: CtapHidDevice) -> dict[str, Any]: + ctap2 = Ctap2(dev) + info = ctap2.get_info() + out = { + "versions": getattr(info, "versions", None), + "extensions": getattr(info, "extensions", None), + "aaguid": getattr(info, "aaguid", None), + "options": getattr(info, "options", None), + "max_msg_size": getattr(info, "max_msg_size", None), + "pin_uv_protocols": getattr(info, "pin_uv_protocols", None), + "firmware_version": getattr(info, "firmware_version", None), + "transports": getattr(info, "transports", None), + "algorithms": getattr(info, "algorithms", None), + } + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description="Probe USB FIDO2/CTAP2 devices") + parser.add_argument( + "--index", + type=int, + default=0, + help="Device index from --list output (default: 0)", + ) + parser.add_argument( + "--list", + action="store_true", + help="List devices only", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print CTAP2 info as JSON", + ) + args = parser.parse_args() + + devs = list_devices() + if args.list: + print_device_table(devs) + return 0 if devs else 1 + + if not devs: + print("No CTAP HID devices found.") + return 1 + + if args.index < 0 or args.index >= len(devs): + print(f"Invalid --index {args.index}; found {len(devs)} device(s).", file=sys.stderr) + print_device_table(devs) + return 2 + + dev = devs[args.index] + dev_meta = describe_device(dev) + info = get_info(dev) + + if args.json: + payload = {"device": dev_meta, "ctap2_info": info} + print(json.dumps(payload, indent=2, default=_json_default)) + return 0 + + print("Selected device:") + print( + f" vid:pid={dev_meta['vendor_id']}:{dev_meta['product_id']} " + f"manufacturer={dev_meta['manufacturer']} " + f"product={dev_meta['product_name']}" + ) + print(f" path={dev_meta['path']}") + print("CTAP2 getInfo:") + print(json.dumps(info, indent=2, default=_json_default)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/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_client_portal.py b/k_client_portal.py new file mode 100644 index 0000000..4f185f9 --- /dev/null +++ b/k_client_portal.py @@ -0,0 +1,850 @@ +#!/usr/bin/env python3 +""" +k_client_portal — browser-facing portal running in k_client. + +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 + +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 Flow + + + +
+
+

ChromeCard Client Flow

+

+ 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
+
+
+

Registered Users

+
Loading users...
+
+
+
+

Flow Result

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

Event Log

+

+      
+
+
+ + + + +""" + + +@dataclass +class EnrollmentRecord: + username: str + + +class ClientState: + 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 + # 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) -> ssl.SSLContext | None: + return self._ssl_ctx + + 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() + if token: + req.add_header("Authorization", f"Bearer {token}") + body = json.dumps(payload or {}).encode("utf-8") + try: + 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: + 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"} + # 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", + {"username": username}, + timeout_s=self.interactive_timeout_s, + ) + 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 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 { + "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, username: str | None = None) -> tuple[int, dict[str, Any]]: + requested = (username or "").strip() + with self.lock: + if requested: + username = requested + elif self.preferred_enrollment: + username = self.preferred_enrollment.username + else: + return 400, {"ok": False, "error": "no enrolled user"} + + 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) + 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 + + 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 _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 == "/": + 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 + 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 + path = urlparse(self.path).path + if path == "/api/enroll": + 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": + 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": + data = self._require_json() + if data is None: + return + status, data = self.state.delete_enrollment(str(data.get("username", ""))) + 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_phone/android/app/build.gradle b/k_phone/android/app/build.gradle new file mode 100644 index 0000000..5ec817b --- /dev/null +++ b/k_phone/android/app/build.gradle @@ -0,0 +1,57 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +android { + namespace "com.chromecard.kphone" + compileSdk = 36 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + coreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } + + defaultConfig { + applicationId "com.chromecard.kphone" + minSdk = 26 + targetSdk = 36 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + signingConfig = signingConfigs.debug + minifyEnabled false + shrinkResources false + } + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.4" +} diff --git a/k_phone/android/app/src/main/AndroidManifest.xml b/k_phone/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bbf2b97 --- /dev/null +++ b/k_phone/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/k_phone/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/k_phone/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 0000000..dfe0754 --- /dev/null +++ b/k_phone/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,39 @@ +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new id.flutter.flutter_background_service.FlutterBackgroundServicePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_background_service_android, id.flutter.flutter_background_service.FlutterBackgroundServicePlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e); + } + } +} diff --git a/k_phone/android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt b/k_phone/android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt new file mode 100644 index 0000000..6041821 --- /dev/null +++ b/k_phone/android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt @@ -0,0 +1,225 @@ +package com.chromecard.kphone + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.os.Bundle +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +// ChromeCard USB identifiers (must match udev rule and ctaphid_channel.dart) +private const val VENDOR_ID = 0x1209 +private const val PRODUCT_ID = 0x0005 + +private const val CHANNEL = "com.chromecard.kphone/usb_hid" +private const val ACTION_USB_PERMISSION = "com.chromecard.kphone.USB_PERMISSION" +private const val HID_PACKET_SIZE = 64 +private const val TRANSFER_TIMEOUT_MS = 3000 + +class MainActivity : FlutterActivity() { + + private val usbManager: UsbManager by lazy { + getSystemService(Context.USB_SERVICE) as UsbManager + } + + private var usbDevice: UsbDevice? = null + private var usbConnection: UsbDeviceConnection? = null + private var usbInterface: UsbInterface? = null + private var endpointIn: UsbEndpoint? = null + private var endpointOut: UsbEndpoint? = null + + // Pending permission result callback + private var permissionCallback: ((Boolean) -> Unit)? = null + + private val permissionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_USB_PERMISSION) { + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + permissionCallback?.invoke(granted) + permissionCallback = null + } + } + } + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + createNotificationChannel() + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + "kphone_proxy", + "k_phone proxy service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows when the ChromeCard proxy is running" + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(permissionReceiver, filter) + } + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "openCard" -> handleOpenCard(result) + "closeCard" -> { closeCard(); result.success(null) } + "isCardAttached" -> result.success(usbConnection != null) + "sendCtaphid" -> { + val packet = call.arguments as? ByteArray + if (packet == null || packet.size != HID_PACKET_SIZE) { + result.error("INVALID_PACKET", "Expected $HID_PACKET_SIZE bytes", null) + } else { + handleSendCtaphid(packet, result) + } + } + else -> result.notImplemented() + } + } + } + + override fun onDestroy() { + super.onDestroy() + closeCard() + try { unregisterReceiver(permissionReceiver) } catch (_: Exception) {} + } + + // ------------------------------------------------------------------------- + // openCard: find ChromeCard, request permission, claim HID interface + // ------------------------------------------------------------------------- + + private fun handleOpenCard(result: MethodChannel.Result) { + // Already open? + if (usbConnection != null) { result.success(true); return } + + val device = findChromeCard() + if (device == null) { + result.success(false) + return + } + + if (usbManager.hasPermission(device)) { + result.success(claimDevice(device)) + } else { + requestPermission(device) { granted -> + runOnUiThread { + result.success(if (granted) claimDevice(device) else false) + } + } + } + } + + private fun findChromeCard(): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { dev -> + dev.vendorId == VENDOR_ID && dev.productId == PRODUCT_ID + } + } + + private fun requestPermission(device: UsbDevice, callback: (Boolean) -> Unit) { + permissionCallback = callback + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE else 0 + val permIntent = PendingIntent.getBroadcast(this, 0, + Intent(ACTION_USB_PERMISSION), flags) + usbManager.requestPermission(device, permIntent) + } + + private fun claimDevice(device: UsbDevice): Boolean { + // Find the HID interface (class 3) + val hidIface = (0 until device.interfaceCount) + .map { device.getInterface(it) } + .firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_HID } + ?: return false + + // Find IN and OUT interrupt endpoints + var inEp: UsbEndpoint? = null + var outEp: UsbEndpoint? = null + for (i in 0 until hidIface.endpointCount) { + val ep = hidIface.getEndpoint(i) + if (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT) { + if (ep.direction == UsbConstants.USB_DIR_IN) inEp = ep + if (ep.direction == UsbConstants.USB_DIR_OUT) outEp = ep + } + } + if (inEp == null || outEp == null) return false + + val conn = usbManager.openDevice(device) ?: return false + if (!conn.claimInterface(hidIface, true)) { + conn.close(); return false + } + + usbDevice = device + usbConnection = conn + usbInterface = hidIface + endpointIn = inEp + endpointOut = outEp + return true + } + + // ------------------------------------------------------------------------- + // closeCard: release interface and close connection + // ------------------------------------------------------------------------- + + private fun closeCard() { + usbInterface?.let { usbConnection?.releaseInterface(it) } + usbConnection?.close() + usbDevice = null + usbConnection = null + usbInterface = null + endpointIn = null + endpointOut = null + } + + // ------------------------------------------------------------------------- + // sendCtaphid: write one HID packet, read one HID packet + // ------------------------------------------------------------------------- + + private fun handleSendCtaphid(packet: ByteArray, result: MethodChannel.Result) { + val conn = usbConnection + val outEp = endpointOut + val inEp = endpointIn + + if (conn == null || outEp == null || inEp == null) { + result.error("NOT_OPEN", "Card not open", null) + return + } + + // Send + val sent = conn.bulkTransfer(outEp, packet, packet.size, TRANSFER_TIMEOUT_MS) + if (sent < 0) { + result.error("SEND_FAILED", "bulkTransfer OUT returned $sent", null) + return + } + + // Receive + val buf = ByteArray(HID_PACKET_SIZE) + val received = conn.bulkTransfer(inEp, buf, buf.size, TRANSFER_TIMEOUT_MS) + if (received < 0) { + result.error("RECV_FAILED", "bulkTransfer IN returned $received", null) + return + } + + result.success(buf) + } +} diff --git a/k_phone/android/app/src/main/res/drawable/ic_bg_service_small.xml b/k_phone/android/app/src/main/res/drawable/ic_bg_service_small.xml new file mode 100644 index 0000000..a5b4dc6 --- /dev/null +++ b/k_phone/android/app/src/main/res/drawable/ic_bg_service_small.xml @@ -0,0 +1,11 @@ + + + + diff --git a/k_phone/android/app/src/main/res/values/styles.xml b/k_phone/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..b925f89 --- /dev/null +++ b/k_phone/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/k_phone/android/app/src/main/res/xml/network_security_config.xml b/k_phone/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6209f7c --- /dev/null +++ b/k_phone/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + 10.0.2.2 + + diff --git a/k_phone/android/app/src/main/res/xml/usb_device_filter.xml b/k_phone/android/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 0000000..1dc1066 --- /dev/null +++ b/k_phone/android/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/k_phone/android/build.gradle b/k_phone/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/k_phone/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/k_phone/android/gradle.properties b/k_phone/android/gradle.properties new file mode 100644 index 0000000..3b5b324 --- /dev/null +++ b/k_phone/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/k_phone/android/gradle/wrapper/gradle-wrapper.jar b/k_phone/android/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000..13372ae Binary files /dev/null and b/k_phone/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/k_phone/android/gradle/wrapper/gradle-wrapper.properties b/k_phone/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/k_phone/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/k_phone/android/gradlew b/k_phone/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/k_phone/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/k_phone/android/gradlew.bat b/k_phone/android/gradlew.bat new file mode 100755 index 0000000..aec9973 --- /dev/null +++ b/k_phone/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/k_phone/android/settings.gradle b/k_phone/android/settings.gradle new file mode 100644 index 0000000..cb7d7dd --- /dev/null +++ b/k_phone/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.7.3" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ":app" diff --git a/k_phone/lib/ctaphid_channel.dart b/k_phone/lib/ctaphid_channel.dart new file mode 100644 index 0000000..a1fd061 --- /dev/null +++ b/k_phone/lib/ctaphid_channel.dart @@ -0,0 +1,355 @@ +// Dart side of the USB HID platform channel + TCP emulator transport. +// +// Two transport modes: +// USB mode (default): calls into Kotlin MainActivity via MethodChannel. +// Emulator mode: TCP socket to card_emulator_bridge.py on port 8772. +// +// Call useEmulator() before openCard() to switch to emulator mode. +// All CTAPHID framing, fragmentation, and reassembly lives here in Dart. + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter/services.dart'; + +const _channel = MethodChannel('com.chromecard.kphone/usb_hid'); + +// ChromeCard USB IDs (matches udev rule 70-chromecard-fido.rules) +const int kVendorId = 0x1209; +const int kProductId = 0x0005; + +// CTAPHID constants +const int kCtaphidBroadcastChannel = 0xFFFFFFFF; +const int kCtaphidInit = 0x06; +const int kCtaphidMsg = 0x03; +const int kCtaphidCbor = 0x10; +const int kCtaphidCancel = 0x11; +const int kCtaphidError = 0x3F; +const int kCtaphidKeepalive = 0x3B; + +const int kHidPacketSize = 64; + +// --------------------------------------------------------------------------- +// Transport selection +// --------------------------------------------------------------------------- + +bool _emulatorMode = false; +String _emulatorHost = '127.0.0.1'; +int _emulatorPort = 8772; +Socket? _emulatorSocket; + +// Persistent read state for the emulator TCP socket. +// Socket is a single-subscription stream — we must subscribe exactly once +// and accumulate all incoming bytes into a buffer. +StreamSubscription>? _emulatorSub; +final _emulatorRxBuf = []; +Completer? _emulatorRxWaiter; +bool _emulatorSocketOpen = false; + +void _emulatorStartReading(Socket sock) { + _emulatorRxBuf.clear(); + _emulatorRxWaiter = null; + _emulatorSocketOpen = true; + _emulatorSub?.cancel(); + _emulatorSub = sock.listen( + (chunk) { + _emulatorRxBuf.addAll(chunk); + final w = _emulatorRxWaiter; + if (w != null && !w.isCompleted) w.complete(); + }, + onDone: () { + _emulatorSocketOpen = false; + final w = _emulatorRxWaiter; + if (w != null && !w.isCompleted) w.completeError(const SocketException('Emulator socket closed')); + }, + onError: (Object e) { + _emulatorSocketOpen = false; + final w = _emulatorRxWaiter; + if (w != null && !w.isCompleted) w.completeError(e); + }, + ); +} + +/// Switch to emulator mode — connects to card_emulator_bridge.py. +/// Must be called before openCard(). +void useEmulator({String host = '127.0.0.1', int port = 8772}) { + _emulatorMode = true; + _emulatorHost = host; + _emulatorPort = port; +} + +/// Switch back to USB mode. +void useUsb() { + _emulatorMode = false; + _emulatorSocketOpen = false; + _emulatorSub?.cancel(); + _emulatorSub = null; + _emulatorRxBuf.clear(); + _emulatorSocket?.destroy(); + _emulatorSocket = null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Opens the ChromeCard USB device (or emulator TCP connection). +Future openCard() async { + if (_emulatorMode) { + try { + _emulatorSub?.cancel(); + _emulatorSocket?.destroy(); + _emulatorSocket = await Socket.connect(_emulatorHost, _emulatorPort); + _emulatorStartReading(_emulatorSocket!); + return true; + } catch (e) { + return false; + } + } + try { + return await _channel.invokeMethod('openCard') ?? false; + } on MissingPluginException { + return false; + } on PlatformException catch (e) { + throw CtapHidException('openCard failed: ${e.message}'); + } +} + +/// Closes the card handle / TCP connection. +Future closeCard() async { + if (_emulatorMode) { + _emulatorSub?.cancel(); + _emulatorSub = null; + _emulatorRxBuf.clear(); + _emulatorSocket?.destroy(); + _emulatorSocket = null; + return; + } + try { + await _channel.invokeMethod('closeCard'); + } on MissingPluginException { + return; + } on PlatformException catch (e) { + throw CtapHidException('closeCard failed: ${e.message}'); + } +} + +/// Returns true if a card (or emulator) is currently open. +Future isCardAttached() async { + if (_emulatorMode) return _emulatorSub != null; + try { + return await _channel.invokeMethod('isCardAttached') ?? false; + } on MissingPluginException { + return false; + } on PlatformException { + return false; + } +} + +/// Sends a CTAPHID INIT to allocate a channel, returns the allocated CID. +Future ctaphidInit() async { + final nonce = Uint8List(8); + final rng = Random.secure(); + for (var i = 0; i < 8; i++) nonce[i] = rng.nextInt(256); + + final responsePayload = await _ctaphidRoundtrip( + kCtaphidBroadcastChannel, + kCtaphidInit, + nonce, + ); + + if (responsePayload.length < 12) { + throw CtapHidException('INIT response too short: ${responsePayload.length}'); + } + // Response payload: nonce(8) + CID(4) + ... + final cid = (responsePayload[8] << 24) + | (responsePayload[9] << 16) + | (responsePayload[10] << 8) + | responsePayload[11]; + return cid; +} + +/// Sends a CTAP2 CBOR command and returns the response payload. +Future ctap2Cbor(int cid, Uint8List cbor) async { + return _ctaphidRoundtrip(cid, kCtaphidCbor, cbor); +} + +/// Sends a CTAP1/U2F message and returns the response payload. +Future ctap1Msg(int cid, Uint8List apdu) async { + return _ctaphidRoundtrip(cid, kCtaphidMsg, apdu); +} + +// --------------------------------------------------------------------------- +// Internal: request/response +// --------------------------------------------------------------------------- + +/// Full CTAPHID round-trip: fragment request, send, receive, reassemble. +Future _ctaphidRoundtrip(int cid, int cmd, Uint8List data) async { + final requestPackets = _buildPackets(cid: cid, cmd: cmd, data: data); + + if (_emulatorMode) { + // Emulator: send all request packets at once, then read response. + // The bridge buffers all request packets and sends keepalives as needed, + // but since we write everything before reading, we just send and drain. + for (final pkt in requestPackets) { + await _sendPacketOnly(pkt); + } + // Read the response init packet (bridge may have sent keepalives first). + var first = await _receivePacket(); + while (_isKeepalive(first)) { + first = await _receivePacket(); + } + return await _reassembleResponse(first, cid); + } + + // USB: platform channel returns one response per send; keepalive loop as before. + Uint8List lastReceived = Uint8List(kHidPacketSize); + for (final pkt in requestPackets) { + lastReceived = await _sendPacket(pkt); + } + while (_isKeepalive(lastReceived)) { + lastReceived = await _receivePacket(); + } + return await _reassembleResponse(lastReceived, cid); +} + +/// Send one 64-byte packet (emulator mode writes to socket; USB invokes platform channel). +Future _sendPacketOnly(Uint8List packet) async { + assert(packet.length == kHidPacketSize); + if (_emulatorMode) { + if (!_emulatorSocketOpen) throw CtapHidException('Emulator socket closed'); + final sock = _emulatorSocket; + if (sock == null) throw CtapHidException('Emulator socket not open'); + sock.add(packet); + await sock.flush(); + return; + } + // USB: sendCtaphid returns the response; handled by the USB round-trip path. + throw CtapHidException('_sendPacketOnly not used for USB'); +} + +/// Send one 64-byte packet and receive one response (USB mode). +Future _sendPacket(Uint8List packet) async { + assert(packet.length == kHidPacketSize); + try { + final r = await _channel.invokeMethod('sendCtaphid', packet); + return r ?? Uint8List(kHidPacketSize); + } on MissingPluginException { + throw CtapHidException('USB plugin not available'); + } on PlatformException catch (e) { + throw CtapHidException('USB transfer failed: ${e.message}'); + } +} + +/// Receive one 64-byte packet from the emulator buffer. +/// Waits until the persistent socket listener has buffered enough bytes. +Future _receivePacket() async { + if (_emulatorSub == null) throw CtapHidException('Emulator socket not open'); + while (_emulatorRxBuf.length < kHidPacketSize) { + _emulatorRxWaiter = Completer(); + await _emulatorRxWaiter!.future; + } + final pkt = Uint8List.fromList(_emulatorRxBuf.take(kHidPacketSize).toList()); + _emulatorRxBuf.removeRange(0, kHidPacketSize); + return pkt; +} + +/// Reassemble a full CTAPHID response from an init packet + any continuations. +Future _reassembleResponse(Uint8List initPacket, int expectedCid) async { + _checkCid(initPacket, expectedCid); + + final cmd = initPacket[4] & 0x7F; + final payloadLen = (initPacket[5] << 8) | initPacket[6]; + final firstChunk = min(payloadLen, kHidPacketSize - 7); + + final result = BytesBuilder(); + result.add(initPacket.sublist(7, 7 + firstChunk)); + + var received = firstChunk; + while (received < payloadLen) { + final contPacket = _emulatorMode ? await _receivePacket() : await _receivePacket(); + if (_isKeepalive(contPacket)) continue; + _checkCid(contPacket, expectedCid); + final chunk = min(payloadLen - received, kHidPacketSize - 5); + result.add(contPacket.sublist(5, 5 + chunk)); + received += chunk; + } + + final payload = result.toBytes(); + + if (cmd == kCtaphidError) { + throw CtapHidException( + 'CTAPHID error: 0x${payload.isNotEmpty ? payload[0].toRadixString(16) : "??"}'); + } + + return payload; +} + +bool _isKeepalive(Uint8List pkt) => + pkt.length >= 5 && (pkt[4] & 0x7F) == kCtaphidKeepalive; + +void _checkCid(Uint8List pkt, int expected) { + if (pkt.length < 4) return; + final got = (pkt[0] << 24) | (pkt[1] << 16) | (pkt[2] << 8) | pkt[3]; + if (got != expected && expected != kCtaphidBroadcastChannel) { + throw CtapHidException( + 'CID mismatch: got 0x${got.toRadixString(16)}, ' + 'expected 0x${expected.toRadixString(16)}'); + } +} + +// --------------------------------------------------------------------------- +// Packet building +// --------------------------------------------------------------------------- + +List _buildPackets({ + required int cid, + required int cmd, + required Uint8List data, +}) { + final packets = []; + const initPayload = kHidPacketSize - 7; + const contPayload = kHidPacketSize - 5; + + final init = Uint8List(kHidPacketSize); + init[0] = (cid >> 24) & 0xFF; + init[1] = (cid >> 16) & 0xFF; + init[2] = (cid >> 8) & 0xFF; + init[3] = cid & 0xFF; + init[4] = (cmd & 0x7F) | 0x80; + init[5] = (data.length >> 8) & 0xFF; + init[6] = data.length & 0xFF; + final firstChunk = min(data.length, initPayload); + init.setRange(7, 7 + firstChunk, data); + packets.add(init); + + var offset = firstChunk; + var seq = 0; + while (offset < data.length) { + final cont = Uint8List(kHidPacketSize); + cont[0] = (cid >> 24) & 0xFF; + cont[1] = (cid >> 16) & 0xFF; + cont[2] = (cid >> 8) & 0xFF; + cont[3] = cid & 0xFF; + cont[4] = seq & 0x7F; + final chunk = min(data.length - offset, contPayload); + cont.setRange(5, 5 + chunk, data, offset); + packets.add(cont); + offset += chunk; + seq++; + } + return packets; +} + +// --------------------------------------------------------------------------- +// Exception +// --------------------------------------------------------------------------- + +class CtapHidException implements Exception { + final String message; + CtapHidException(this.message); + + @override + String toString() => 'CtapHidException: $message'; +} diff --git a/k_phone/lib/enrollment_db.dart b/k_phone/lib/enrollment_db.dart new file mode 100644 index 0000000..0768842 --- /dev/null +++ b/k_phone/lib/enrollment_db.dart @@ -0,0 +1,253 @@ +// Enrollment storage — mirrors k_proxy_app.py ProxyState enrollment logic. +// Persists to a JSON file with the same schema so snapshots are portable. + +import 'dart:convert'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +// --------------------------------------------------------------------------- +// Username validation +// --------------------------------------------------------------------------- + +final _usernamePattern = RegExp(r'^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$'); + +String normalizeUsername(String raw) { + final s = raw.trim().toLowerCase(); + if (!_usernamePattern.hasMatch(s)) { + throw ArgumentError( + 'username must be 3–32 chars of lowercase letters, digits, dot, underscore, or dash'); + } + return s; +} + +String? normalizeDisplayName(String? raw) { + final s = (raw ?? '').trim(); + if (s.isEmpty) return null; + if (s.length > 64) throw ArgumentError('display_name must be 64 characters or fewer'); + return s; +} + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +class Enrollment { + final String username; + final String? displayName; + final int createdAt; + final int updatedAt; + final String? userIdB64; + final String? credentialDataB64; + + const Enrollment({ + required this.username, + this.displayName, + required this.createdAt, + required this.updatedAt, + this.userIdB64, + this.credentialDataB64, + }); + + bool get hasCredential => credentialDataB64 != null; + + Enrollment copyWith({ + String? displayName, + int? updatedAt, + String? userIdB64, + String? credentialDataB64, + }) => + Enrollment( + username: username, + displayName: displayName ?? this.displayName, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + userIdB64: userIdB64 ?? this.userIdB64, + credentialDataB64: credentialDataB64 ?? this.credentialDataB64, + ); + + Map toJson() => { + 'username': username, + 'display_name': displayName, + 'created_at': createdAt, + 'updated_at': updatedAt, + 'user_id_b64': userIdB64, + 'credential_data_b64': credentialDataB64, + }; + + factory Enrollment.fromJson(Map m) { + final username = (m['username'] as String? ?? '').trim(); + final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs(); + return Enrollment( + username: username, + displayName: normalizeDisplayName(m['display_name'] as String?), + createdAt: createdAt, + updatedAt: m['updated_at'] as int? ?? createdAt, + userIdB64: m['user_id_b64'] as String?, + credentialDataB64: m['credential_data_b64'] as String?, + ); + } +} + +int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + +// --------------------------------------------------------------------------- +// Database +// --------------------------------------------------------------------------- + +class EnrollmentDb { + final Map _entries = {}; + bool _loaded = false; + + // Dart isolates are single-threaded so there is no data race on _entries. + // We still serialize async disk I/O with a simple future chain. + Future? _pending; + + Future _serialize(Future Function() op) async { + final prev = _pending; + final next = _doAfter(prev, op); + _pending = next; + await next; + } + + static Future _doAfter(Future? prev, Future Function() op) async { + if (prev != null) { + try { + await prev; + } catch (_) {} + } + await op(); + } + + // ------------------------------------------------------------------------- + // Persistence + // ------------------------------------------------------------------------- + + Future _dbFile() async { + final dir = await getApplicationSupportDirectory(); + return File('${dir.path}/k_phone_enrollments.json'); + } + + Future _load() async { + if (_loaded) return; + _loaded = true; + try { + final f = await _dbFile(); + if (!f.existsSync()) return; + final raw = jsonDecode(await f.readAsString()) as Map; + final users = raw['users'] as List? ?? []; + for (final item in users) { + final e = Enrollment.fromJson(item as Map); + if (e.username.isNotEmpty) _entries[e.username] = e; + } + } catch (_) { + _entries.clear(); + } + } + + Future _save() async { + final f = await _dbFile(); + final users = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username)); + await f.writeAsString( + const JsonEncoder.withIndent(' ').convert({'users': users.map((e) => e.toJson()).toList()}) + '\n', + ); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + Future ensureLoaded() async { + await _serialize(_load); + } + + /// Register a new user. Throws [StateError] if already enrolled. + Future register({ + required String username, + String? displayName, + String? userIdB64, + String? credentialDataB64, + }) async { + final canonical = normalizeUsername(username); + final pretty = normalizeDisplayName(displayName); + final now = _nowSecs(); + Enrollment? result; + await _serialize(() async { + await _load(); + if (_entries.containsKey(canonical)) throw StateError('user already enrolled'); + final e = Enrollment( + username: canonical, + displayName: pretty, + createdAt: now, + updatedAt: now, + userIdB64: userIdB64, + credentialDataB64: credentialDataB64, + ); + _entries[canonical] = e; + result = e; + await _save(); + }); + return result!; + } + + /// Update display_name (and optionally credential data) for an existing user. + /// Throws [StateError] if not found. + Future update({ + required String username, + String? displayName, + String? userIdB64, + String? credentialDataB64, + }) async { + final canonical = normalizeUsername(username); + final pretty = normalizeDisplayName(displayName); + final now = _nowSecs(); + Enrollment? result; + await _serialize(() async { + await _load(); + final existing = _entries[canonical]; + if (existing == null) throw StateError('user not enrolled'); + final updated = existing.copyWith( + displayName: pretty, + updatedAt: now, + userIdB64: userIdB64 ?? existing.userIdB64, + credentialDataB64: credentialDataB64 ?? existing.credentialDataB64, + ); + _entries[canonical] = updated; + result = updated; + await _save(); + }); + return result!; + } + + /// Delete a user. Throws [StateError] if not found. Returns deleted entry. + Future delete(String username) async { + final canonical = normalizeUsername(username); + Enrollment? result; + await _serialize(() async { + await _load(); + final existing = _entries.remove(canonical); + if (existing == null) throw StateError('user not enrolled'); + result = existing; + await _save(); + }); + return result!; + } + + /// Get a single enrollment or null. + Future get(String username) async { + String canonical; + try { + canonical = normalizeUsername(username); + } catch (_) { + return null; + } + await ensureLoaded(); + return _entries[canonical]; + } + + /// List all enrollments sorted by username. + Future> list() async { + await ensureLoaded(); + final result = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username)); + return result; + } +} diff --git a/k_phone/lib/fido2_ops.dart b/k_phone/lib/fido2_ops.dart new file mode 100644 index 0000000..f1e8f81 --- /dev/null +++ b/k_phone/lib/fido2_ops.dart @@ -0,0 +1,335 @@ +// CTAP2 FIDO2 operations — makeCredential, getAssertion, verifyAssertion. +// Mirrors the direct-CTAP2 path in k_proxy_app.py. +// +// Wire format: first byte of ctap2Cbor response is a CTAP status code (0x00 = OK), +// remaining bytes are a CBOR map. Request is a CBOR map with no status prefix. + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cbor/cbor.dart'; +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; + +import 'ctaphid_channel.dart'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const String kRpId = 'localhost'; +const String kOrigin = 'https://localhost'; +const String kRpName = 'ChromeCard Proxy'; + +// --------------------------------------------------------------------------- +// Public result types +// --------------------------------------------------------------------------- + +class MakeCredentialResult { + /// Raw AttestedCredentialData bytes: aaguid(16) + credIdLen(2) + credId + coseKey + final Uint8List credentialData; + + /// base64url of credentialData — store this in EnrollmentDb + String get credentialDataB64 => _b64uEncode(credentialData); + + /// The 32-byte user handle used during registration + final Uint8List userId; + + /// base64url of userId — store this in EnrollmentDb + String get userIdB64 => _b64uEncode(userId); + + MakeCredentialResult({required this.credentialData, required this.userId}); +} + +class GetAssertionResult { + final Uint8List authData; + final Uint8List signature; + final Uint8List clientDataHash; + + GetAssertionResult({ + required this.authData, + required this.signature, + required this.clientDataHash, + }); +} + +// --------------------------------------------------------------------------- +// makeCredential +// --------------------------------------------------------------------------- + +/// Runs CTAP2 authenticatorMakeCredential against the card on [cid]. +/// Returns credential data that should be persisted in the enrollment store. +Future makeCredential( + int cid, + String username, { + String? displayName, + Uint8List? userId, +}) async { + final uid = userId ?? _randomBytes(32); + final challenge = _randomBytes(32); + + final clientDataJson = _buildClientDataJson('webauthn.create', challenge); + final clientDataHash = _sha256(utf8.encode(clientDataJson)); + + // CBOR map: authenticatorMakeCredential (CTAP2 spec integer keys throughout) + final requestMap = CborMap({ + CborSmallInt(1): CborBytes(clientDataHash), + CborSmallInt(2): CborMap({ + CborString('id'): CborString(kRpId), + CborString('name'): CborString(kRpName), + }), + CborSmallInt(3): CborMap({ + CborString('id'): CborBytes(uid), + CborString('name'): CborString(username), + CborString('displayName'): CborString(displayName ?? username), + }), + CborSmallInt(4): CborList([ + CborMap({ + CborString('type'): CborString('public-key'), + CborString('alg'): CborSmallInt(-7), + }), + ]), + CborSmallInt(7): CborMap({ + CborString('rk'): CborBool(false), + CborString('uv'): CborBool(false), + }), + }); + + // CTAP2 over CTAPHID: first byte is the authenticatorMakeCredential (0x01) command code. + final encoded = Uint8List.fromList([0x01, ...cbor.encode(requestMap)]); + final response = await ctap2Cbor(cid, encoded); + + final responseMap = _parseCtapResponse(response); + final authData = _requireBytes(responseMap, 2, 'makeCredential authData'); + + final credData = _extractAttestedCredentialData(authData); + return MakeCredentialResult(credentialData: credData, userId: uid); +} + +// --------------------------------------------------------------------------- +// getAssertion +// --------------------------------------------------------------------------- + +/// Runs CTAP2 authenticatorGetAssertion against the card on [cid]. +/// [credentialDataB64] is the base64url of the stored AttestedCredentialData. +Future getAssertion( + int cid, + String credentialDataB64, +) async { + final credData = _b64uDecode(credentialDataB64); + final credId = _extractCredentialId(credData); + + final challenge = _randomBytes(32); + final clientDataJson = _buildClientDataJson('webauthn.get', challenge); + final clientDataHash = _sha256(utf8.encode(clientDataJson)); + + final requestMap = CborMap({ + CborSmallInt(1): CborString(kRpId), + CborSmallInt(2): CborBytes(clientDataHash), + CborSmallInt(3): CborList([ + CborMap({ + CborString('type'): CborString('public-key'), + CborString('id'): CborBytes(credId), + }), + ]), + CborSmallInt(5): CborMap({ + CborString('up'): CborBool(true), + CborString('uv'): CborBool(false), + }), + }); + + // CTAP2 over CTAPHID: first byte is the authenticatorGetAssertion (0x02) command code. + final encoded = Uint8List.fromList([0x02, ...cbor.encode(requestMap)]); + final response = await ctap2Cbor(cid, encoded); + + final responseMap = _parseCtapResponse(response); + final authData = _requireBytes(responseMap, 2, 'getAssertion authData'); + final signature = _requireBytes(responseMap, 3, 'getAssertion signature'); + + return GetAssertionResult( + authData: authData, + signature: signature, + clientDataHash: clientDataHash, + ); +} + +// --------------------------------------------------------------------------- +// verifyAssertion +// --------------------------------------------------------------------------- + +/// Verifies the ECDSA-P256 assertion signature. +/// [credentialDataB64] is the stored base64url AttestedCredentialData. +/// Returns true if the signature is valid. +bool verifyAssertion( + String credentialDataB64, + Uint8List authData, + Uint8List signature, + Uint8List clientDataHash, +) { + final credData = _b64uDecode(credentialDataB64); + final coseKey = _extractCoseKey(credData); + final pubKey = _coseKeyToEcPublicKey(coseKey); + + final message = Uint8List(authData.length + clientDataHash.length) + ..setRange(0, authData.length, authData) + ..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash); + + final (r, s) = _decodeDerSignature(signature); + + final verifier = ECDSASigner(SHA256Digest()) + ..init(false, PublicKeyParameter(pubKey)); + + try { + return verifier.verifySignature(message, ECSignature(r, s)); + } catch (_) { + return false; + } +} + +// --------------------------------------------------------------------------- +// AuthData parsing helpers +// --------------------------------------------------------------------------- + +/// Extracts AttestedCredentialData from a full authData blob. +/// authData layout: +/// [0:32] rpIdHash +/// [32] flags +/// [33:37] signCount (uint32 BE) +/// [37:53] aaguid (16 bytes) ← attested cred data starts here +/// [53:55] credIdLen (uint16 BE) +/// [55:55+n] credId +/// [55+n:] COSE key (CBOR) +Uint8List _extractAttestedCredentialData(Uint8List authData) { + if (authData.length < 55) { + throw FormatException('authData too short for attested credential data: ${authData.length}'); + } + // The attested credential data is everything from offset 37 onward. + return Uint8List.fromList(authData.sublist(37)); +} + +/// Extracts the credential ID from AttestedCredentialData bytes. +/// Layout: aaguid(16) + credIdLen(2) + credId(n) + coseKey +Uint8List _extractCredentialId(Uint8List credData) { + if (credData.length < 18) { + throw FormatException('credentialData too short: ${credData.length}'); + } + final credIdLen = (credData[16] << 8) | credData[17]; + if (credData.length < 18 + credIdLen) { + throw FormatException('credentialData truncated before credId end'); + } + return Uint8List.fromList(credData.sublist(18, 18 + credIdLen)); +} + +/// Extracts the COSE key bytes from AttestedCredentialData. +Uint8List _extractCoseKey(Uint8List credData) { + if (credData.length < 18) { + throw FormatException('credentialData too short for COSE key'); + } + final credIdLen = (credData[16] << 8) | credData[17]; + final coseStart = 18 + credIdLen; + if (credData.length <= coseStart) { + throw FormatException('credentialData has no COSE key bytes'); + } + return Uint8List.fromList(credData.sublist(coseStart)); +} + +/// Parses a COSE EC2 key and returns an ECPublicKey for pointycastle. +ECPublicKey _coseKeyToEcPublicKey(Uint8List coseKeyBytes) { + final decoded = cbor.decode(coseKeyBytes); + if (decoded is! CborMap) throw FormatException('COSE key is not a CBOR map'); + + Uint8List? x, y; + for (final entry in decoded.entries) { + final k = entry.key; + final v = entry.value; + // COSE key -2 = x, -3 = y (represented as CborSmallInt or CborInt) + final ki = _cborInt(k); + if (ki == -2 && v is CborBytes) x = Uint8List.fromList(v.bytes); + if (ki == -3 && v is CborBytes) y = Uint8List.fromList(v.bytes); + } + if (x == null || y == null) throw FormatException('COSE key missing x or y coordinate'); + + final domainParams = ECDomainParameters('prime256v1'); + final point = domainParams.curve.createPoint( + BigInt.parse(x.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16), + BigInt.parse(y.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16), + ); + return ECPublicKey(point, domainParams); +} + +int _cborInt(CborValue v) { + if (v is CborSmallInt) return v.value; + if (v is CborInt) return v.toInt(); + throw FormatException('expected CBOR int, got ${v.runtimeType}'); +} + +/// DER-decode an ECDSA signature into (r, s) BigInts. +(BigInt, BigInt) _decodeDerSignature(Uint8List der) { + // SEQUENCE { INTEGER r, INTEGER s } + if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag'); + var offset = 2; // skip 0x30 + length + if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r'); + final rLen = der[offset + 1]; + final rBytes = der.sublist(offset + 2, offset + 2 + rLen); + offset += 2 + rLen; + if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for s'); + final sLen = der[offset + 1]; + final sBytes = der.sublist(offset + 2, offset + 2 + sLen); + return (_bigIntFromBytes(rBytes), _bigIntFromBytes(sBytes)); +} + +BigInt _bigIntFromBytes(Uint8List bytes) { + var result = BigInt.zero; + for (final b in bytes) { + result = (result << 8) | BigInt.from(b); + } + return result; +} + +// --------------------------------------------------------------------------- +// CTAP response parsing +// --------------------------------------------------------------------------- + +CborMap _parseCtapResponse(Uint8List response) { + if (response.isEmpty) throw FormatException('empty CTAP response'); + final status = response[0]; + if (status != 0x00) throw FormatException('CTAP error: 0x${status.toRadixString(16)}'); + final decoded = cbor.decode(response.sublist(1)); + if (decoded is! CborMap) throw FormatException('CTAP response body is not a CBOR map'); + return decoded; +} + +Uint8List _requireBytes(CborMap map, int key, String field) { + final v = map[CborSmallInt(key)]; + if (v == null) throw FormatException('$field: missing key $key in CTAP response'); + if (v is! CborBytes) throw FormatException('$field: expected bytes, got ${v.runtimeType}'); + return Uint8List.fromList(v.bytes); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +String _buildClientDataJson(String type, Uint8List challenge) { + final challengeB64 = _b64uEncode(challenge); + return '{"type":"$type","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}'; +} + +Uint8List _sha256(List data) { + return Uint8List.fromList(sha256.convert(data).bytes); +} + +Uint8List _randomBytes(int n) { + final rng = Random.secure(); + return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256))); +} + +String _b64uEncode(Uint8List data) { + return base64Url.encode(data).replaceAll('=', ''); +} + +Uint8List _b64uDecode(String s) { + final padded = s + '=' * ((4 - s.length % 4) % 4); + return Uint8List.fromList(base64Url.decode(padded)); +} diff --git a/k_phone/lib/k_server_client.dart b/k_phone/lib/k_server_client.dart new file mode 100644 index 0000000..59d602a --- /dev/null +++ b/k_phone/lib/k_server_client.dart @@ -0,0 +1,80 @@ +// Client for forwarding requests to k_server (:8780). +// Mirrors the k_proxy → k_server leg in k_proxy_app.py. + +import 'dart:io'; +import 'dart:typed_data'; + +const String kServerHost = '127.0.0.1'; // k_server address (same device or Qubes forward) +const int kServerPort = 8780; + +class KServerResponse { + final int statusCode; + final HttpHeaders headers; + final Uint8List body; + + KServerResponse({ + required this.statusCode, + required this.headers, + required this.body, + }); +} + +class KServerClient { + HttpClient? _client; + + HttpClient _getClient() { + // TLS: k_server uses self-signed cert from generate_phase2_certs.py. + // In dev, accept any cert; in prod, pin the CA cert. + _client ??= HttpClient() + ..badCertificateCallback = (cert, host, port) { + // TODO: replace with CA pinning once certs are bundled. + return true; + }; + return _client!; + } + + Future forward({ + required String method, + required String path, + required HttpHeaders headers, + required Uint8List body, + }) async { + final client = _getClient(); + final uri = Uri( + scheme: 'https', + host: kServerHost, + port: kServerPort, + path: path, + ); + + final req = await client.openUrl(method, uri); + + // Forward relevant headers + headers.forEach((name, values) { + if (_shouldForwardHeader(name)) { + for (final v in values) req.headers.add(name, v); + } + }); + + if (body.isNotEmpty) { + req.headers.contentLength = body.length; + req.add(body); + } + + final res = await req.close(); + final resBody = await res.fold>([], (a, b) => a..addAll(b)); + + return KServerResponse( + statusCode: res.statusCode, + headers: res.headers, + body: Uint8List.fromList(resBody), + ); + } + + bool _shouldForwardHeader(String name) { + const skip = {'host', 'connection', 'transfer-encoding', 'authorization'}; + return !skip.contains(name.toLowerCase()); + } + + void close() => _client?.close(); +} diff --git a/k_phone/lib/main.dart b/k_phone/lib/main.dart new file mode 100644 index 0000000..6758967 --- /dev/null +++ b/k_phone/lib/main.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'proxy_service.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await ProxyService.initialize(); + runApp(const KPhoneApp()); +} + +class KPhoneApp extends StatelessWidget { + const KPhoneApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'k_phone', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + home: const ProxyStatusScreen(), + ); + } +} + +class ProxyStatusScreen extends StatefulWidget { + const ProxyStatusScreen({super.key}); + + @override + State createState() => _ProxyStatusScreenState(); +} + +class _ProxyStatusScreenState extends State { + bool _serviceRunning = false; + bool _cardAttached = false; + String _statusMessage = 'Stopped'; + final List _log = []; + + @override + void initState() { + super.initState(); + _subscribeToService(); + } + + void _subscribeToService() { + final service = FlutterBackgroundService(); + + // Sync initial running state + service.isRunning().then((running) { + if (mounted) setState(() => _serviceRunning = running); + }); + + service.on('status').listen((event) { + if (event == null) return; + if (mounted) { + setState(() { + _serviceRunning = event['running'] as bool? ?? false; + _cardAttached = event['cardAttached'] as bool? ?? false; + _statusMessage = event['message'] as String? ?? ''; + final log = event['log'] as String?; + if (log != null) { + _log.insert(0, log); + if (_log.length > 200) _log.removeLast(); + } + }); + } + }); + } + + Future _toggleService() async { + final service = FlutterBackgroundService(); + final running = await service.isRunning(); + if (running) { + service.invoke('stop'); + setState(() { + _serviceRunning = false; + _cardAttached = false; + _statusMessage = 'Stopped'; + }); + } else { + await service.startService(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('k_phone — ChromeCard proxy'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusTile( + label: 'Proxy service', + ok: _serviceRunning, + value: _serviceRunning ? 'Running on :8771' : 'Stopped', + ), + const SizedBox(height: 8), + _StatusTile( + label: 'ChromeCard (USB)', + ok: _cardAttached, + value: _cardAttached ? 'Attached' : 'Not detected', + ), + const SizedBox(height: 8), + Text( + _statusMessage, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _toggleService, + child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'), + ), + const Divider(height: 32), + const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)), + Expanded( + child: ListView.builder( + itemCount: _log.length, + itemBuilder: (_, i) => Text( + _log[i], + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _StatusTile extends StatelessWidget { + final String label; + final bool ok; + final String value; + + const _StatusTile({ + required this.label, + required this.ok, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + ok ? Icons.check_circle : Icons.radio_button_unchecked, + color: ok ? Colors.green : Colors.grey, + size: 18, + ), + const SizedBox(width: 8), + Text('$label: ', style: const TextStyle(fontWeight: FontWeight.w600)), + Text(value), + ], + ); + } +} diff --git a/k_phone/lib/proxy_service.dart b/k_phone/lib/proxy_service.dart new file mode 100644 index 0000000..955049c --- /dev/null +++ b/k_phone/lib/proxy_service.dart @@ -0,0 +1,652 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'ctaphid_channel.dart'; +import 'enrollment_db.dart'; +import 'fido2_ops.dart'; +import 'k_server_client.dart'; +import 'session_manager.dart'; + +const int kProxyPort = 8771; +const String kNotificationChannelId = 'kphone_proxy'; +const String kNotificationChannelName = 'k_phone proxy service'; + +// --------------------------------------------------------------------------- +// Top-level entry points — required by flutter_background_service isolate +// --------------------------------------------------------------------------- + +@pragma('vm:entry-point') +Future onIosBackground(ServiceInstance service) async => true; + +@pragma('vm:entry-point') +void onServiceStart(ServiceInstance service) async { + final proxy = _ProxyServer(service); + service.on('stop').listen((_) async { + await proxy.stop(); + service.stopSelf(); + }); + await proxy.start(); +} + +// --------------------------------------------------------------------------- +// Service bootstrap (called from main()) +// --------------------------------------------------------------------------- + +@pragma('vm:entry-point') +class ProxyService { + static Future initialize() async { + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + const channel = AndroidNotificationChannel( + kNotificationChannelId, + kNotificationChannelName, + description: 'Shows when the ChromeCard proxy is running', + importance: Importance.low, + ); + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.createNotificationChannel(channel); + + final service = FlutterBackgroundService(); + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: onServiceStart, + autoStart: true, + isForegroundMode: true, + notificationChannelId: kNotificationChannelId, + initialNotificationTitle: 'k_phone proxy', + initialNotificationContent: 'Starting…', + foregroundServiceNotificationId: 1, + ), + iosConfiguration: IosConfiguration( + autoStart: false, + onForeground: onServiceStart, + onBackground: onIosBackground, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Proxy server (runs inside the background service isolate) +// --------------------------------------------------------------------------- + +class _ProxyServer { + final ServiceInstance _service; + HttpServer? _server; + final SessionManager _sessions = SessionManager(); + final EnrollmentDb _db = EnrollmentDb(); + final KServerClient _kserver = KServerClient(); + int? _cardCid; + bool _cardAttached = false; + bool _running = false; + + _ProxyServer(this._service); + + void _emit(String msg) { + _service.invoke('status', { + 'running': _running, + 'cardAttached': _cardAttached, + 'message': msg, + 'log': '[${DateTime.now().toIso8601String()}] $msg', + }); + } + + Future start() async { + _running = true; + _emit('Starting proxy on :$kProxyPort'); + + await _tryOpenCard(); + await _db.ensureLoaded(); + + SecurityContext? tlsCtx; + try { + tlsCtx = await _loadTlsContext(); + } catch (_) { + _emit('No TLS certs found — running plain HTTP (dev mode)'); + } + + try { + if (tlsCtx != null) { + _server = await HttpServer.bindSecure(InternetAddress.anyIPv4, kProxyPort, tlsCtx); + } else { + _server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort); + } + _emit('Listening on :$kProxyPort'); + _server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e')); + } catch (e) { + _emit('FATAL: Could not bind :$kProxyPort — $e'); + _running = false; + } + } + + Future stop() async { + _running = false; + await _server?.close(force: true); + await closeCard(); + _emit('Stopped'); + } + + // ------------------------------------------------------------------------- + // Request dispatch + // ------------------------------------------------------------------------- + + Future _handleRequest(HttpRequest req) async { + final path = req.uri.path; + _emit('${req.method} $path'); + + try { + if (req.method == 'GET') { + switch (path) { + case '/': + await _serveHtml(req); + case '/health': + await _handleHealth(req); + case '/enroll/list': + await _handleEnrollList(req); + default: + if (path.startsWith('/enroll/status')) { + await _handleEnrollStatus(req); + } else { + await _send(req.response, 404, {'ok': false, 'error': 'not found'}); + } + } + } else if (req.method == 'POST') { + switch (path) { + case '/enroll/register': + await _handleEnrollRegister(req); + case '/enroll/update': + await _handleEnrollUpdate(req); + case '/enroll/delete': + await _handleEnrollDelete(req); + case '/session/login': + await _handleSessionLogin(req); + case '/session/status': + await _handleSessionStatus(req); + case '/session/logout': + await _handleSessionLogout(req); + case '/resource/counter': + await _handleResourceCounter(req); + default: + await _send(req.response, 404, {'ok': false, 'error': 'not found'}); + } + } else { + await _send(req.response, 405, {'ok': false, 'error': 'method not allowed'}); + } + } catch (e) { + _emit('Error handling $path: $e'); + try { + await _send(req.response, 500, {'ok': false, 'error': 'internal error'}); + } catch (_) {} + } + } + + // ------------------------------------------------------------------------- + // Enrollment endpoints + // ------------------------------------------------------------------------- + + Future _handleEnrollRegister(HttpRequest req) async { + final body = await _readJson(req); + if (body == null) return; + + final rawUsername = body['username'] as String? ?? ''; + final rawDisplay = body['display_name'] as String?; + + String canonical; + String? pretty; + try { + canonical = normalizeUsername(rawUsername); + pretty = normalizeDisplayName(rawDisplay); + } on ArgumentError catch (e) { + await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); + return; + } + + if (_cardAttached && _cardCid != null) { + // FIDO2-direct mode: run makeCredential on the card + MakeCredentialResult result; + try { + result = await makeCredential(_cardCid!, canonical, displayName: pretty); + } catch (e) { + await _send(req.response, 401, {'ok': false, 'error': 'card registration failed: $e'}); + return; + } + try { + final enrollment = await _db.register( + username: canonical, + displayName: pretty, + userIdB64: result.userIdB64, + credentialDataB64: result.credentialDataB64, + ); + await _send(req.response, 200, _enrollmentPayload(enrollment, created: true)); + } on StateError { + await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'}); + } + return; + } else { + // Probe mode: metadata-only enrollment + try { + final enrollment = await _db.register(username: canonical, displayName: pretty); + await _send(req.response, 200, _enrollmentPayload(enrollment, created: true)); + } on StateError { + await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'}); + } + } + } + + Future _handleEnrollUpdate(HttpRequest req) async { + final body = await _readJson(req); + if (body == null) return; + + final rawUsername = body['username'] as String? ?? ''; + final rawDisplay = body['display_name'] as String?; + + String canonical; + String? pretty; + try { + canonical = normalizeUsername(rawUsername); + pretty = normalizeDisplayName(rawDisplay); + } on ArgumentError catch (e) { + await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); + return; + } + + try { + final enrollment = await _db.update(username: canonical, displayName: pretty); + await _send(req.response, 200, _enrollmentPayload(enrollment)); + } on StateError { + await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'}); + } + } + + Future _handleEnrollDelete(HttpRequest req) async { + final body = await _readJson(req); + if (body == null) return; + + final rawUsername = body['username'] as String? ?? ''; + + String canonical; + try { + canonical = normalizeUsername(rawUsername); + } on ArgumentError catch (e) { + await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); + return; + } + + try { + final enrollment = await _db.delete(canonical); + _sessions.revokeAll(canonical); + await _send(req.response, 200, {'ok': true, 'username': enrollment.username, 'deleted': true}); + } on StateError { + await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'}); + } + } + + Future _handleEnrollStatus(HttpRequest req) async { + final username = req.uri.queryParameters['username'] ?? ''; + if (username.isEmpty) { + await _send(req.response, 400, {'ok': false, 'error': 'username query required'}); + return; + } + final enrollment = await _db.get(username); + if (enrollment == null) { + await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled', 'username': username}); + return; + } + await _send(req.response, 200, _enrollmentPayload(enrollment)); + } + + Future _handleEnrollList(HttpRequest req) async { + final users = await _db.list(); + await _send(req.response, 200, { + 'ok': true, + 'users': users.map(_enrollmentPayload).toList(), + }); + } + + // ------------------------------------------------------------------------- + // Session endpoints + // ------------------------------------------------------------------------- + + Future _handleSessionLogin(HttpRequest req) async { + final body = await _readJson(req); + if (body == null) return; + + final rawUsername = body['username'] as String? ?? ''; + String canonical; + try { + canonical = normalizeUsername(rawUsername); + } on ArgumentError catch (e) { + await _send(req.response, 400, {'ok': false, 'error': e.message.toString()}); + return; + } + + final enrollment = await _db.get(canonical); + if (enrollment == null) { + await _send(req.response, 403, {'ok': false, 'error': 'user not enrolled', 'username': canonical}); + return; + } + + if (enrollment.hasCredential && _cardCid != null) { + // FIDO2-direct: getAssertion + verify + GetAssertionResult assertionResult; + try { + assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!); + } catch (e) { + await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': e.toString()}); + return; + } + final ok = verifyAssertion( + enrollment.credentialDataB64!, + assertionResult.authData, + assertionResult.signature, + assertionResult.clientDataHash, + ); + if (!ok) { + await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'signature verification failed'}); + return; + } + } else if (!_cardAttached) { + await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'}); + return; + } + // else: probe-mode enrollment, card is attached — accept + + final token = _sessions.issue(canonical); + final session = _sessions.getSession(token)!; + final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000; + final authMode = enrollment.hasCredential ? 'fido2_assertion' : 'card_presence_probe'; + + await _send(req.response, 200, { + 'ok': true, + 'username': canonical, + 'session_token': token, + 'expires_at': expiresAt, + 'ttl_seconds': 300, + 'auth_mode': authMode, + }); + } + + Future _handleSessionStatus(HttpRequest req) async { + await _drainBody(req); + final token = _extractBearerToken(req); + if (token == null) { + await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); + return; + } + final session = _sessions.getSession(token); + if (session == null) { + await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'}); + return; + } + final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000; + final secondsRemaining = session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999); + await _send(req.response, 200, { + 'ok': true, + 'username': session.username, + 'expires_at': expiresAt, + 'seconds_remaining': secondsRemaining, + }); + } + + Future _handleSessionLogout(HttpRequest req) async { + await _drainBody(req); + final token = _extractBearerToken(req); + if (token == null) { + await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); + return; + } + final wasValid = _sessions.isValid(token); + _sessions.revoke(token); + await _send(req.response, 200, {'ok': true, 'invalidated': wasValid}); + } + + // ------------------------------------------------------------------------- + // Resource forwarding + // ------------------------------------------------------------------------- + + Future _handleResourceCounter(HttpRequest req) async { + await _drainBody(req); + final token = _extractBearerToken(req); + if (token == null) { + await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'}); + return; + } + final session = _sessions.getSession(token); + if (session == null) { + await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'}); + return; + } + + final result = await _kserver.forward( + method: 'POST', + path: '/resource/counter', + headers: req.headers, + body: Uint8List(0), + ); + + if (result.statusCode != 200) { + await _send(req.response, result.statusCode, {'ok': false, 'error': 'upstream failed'}); + return; + } + + Map upstream; + try { + upstream = jsonDecode(utf8.decode(result.body)) as Map; + } catch (_) { + upstream = {}; + } + + await _send(req.response, 200, { + 'ok': true, + 'username': session.username, + 'session_reused': true, + 'upstream': upstream, + }); + } + + // ------------------------------------------------------------------------- + // Health + HTML + // ------------------------------------------------------------------------- + + Future _handleHealth(HttpRequest req) async { + await _send(req.response, 200, { + 'ok': true, + 'service': 'k_phone', + 'card': _cardAttached, + 'active_sessions': 0, // SessionManager doesn't expose count; good enough + 'time': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }); + } + + Future _serveHtml(HttpRequest req) async { + final data = utf8.encode(_kPortalHtml); + req.response.statusCode = 200; + req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8'); + req.response.headers.contentLength = data.length; + req.response.add(data); + await req.response.close(); + } + + // ------------------------------------------------------------------------- + // Card management + // ------------------------------------------------------------------------- + + Future _tryOpenCard() async { + try { + _cardAttached = await openCard(); + if (!_cardAttached) { + _emit('No USB card — trying emulator bridge on 10.0.2.2:8772'); + useEmulator(host: '10.0.2.2'); + _cardAttached = await openCard(); + } + if (_cardAttached) { + _cardCid = await ctaphidInit(); + _emit('Card open, CID=0x${_cardCid!.toRadixString(16)}'); + } else { + _emit('No card and no emulator bridge — card operations unavailable'); + } + } catch (e) { + _emit('Card open failed: $e'); + _cardAttached = false; + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + String? _extractBearerToken(HttpRequest req) { + final auth = req.headers.value('authorization') ?? ''; + if (!auth.startsWith('Bearer ')) return null; + final token = auth.substring(7).trim(); + return token.isEmpty ? null : token; + } + + Future?> _readJson(HttpRequest req) async { + try { + final bytes = await req.fold>([], (acc, chunk) => acc..addAll(chunk)); + if (bytes.isEmpty) return {}; + return jsonDecode(utf8.decode(bytes)) as Map; + } catch (_) { + await _send(req.response, 400, {'ok': false, 'error': 'invalid json'}); + return null; + } + } + + Future _drainBody(HttpRequest req) async { + await req.fold(null, (_, __) {}); + } + + Future _send(HttpResponse res, int status, Map body) async { + final encoded = utf8.encode(jsonEncode(body)); + res.statusCode = status; + res.headers.contentType = ContentType.json; + res.headers.contentLength = encoded.length; + res.add(encoded); + await res.close(); + } + + Map _enrollmentPayload(Enrollment e, {bool? created}) { + final m = { + 'ok': true, + 'username': e.username, + 'display_name': e.displayName, + 'created_at': e.createdAt, + 'updated_at': e.updatedAt, + 'has_credential': e.hasCredential, + }; + if (created != null) m['created'] = created; + return m; + } + + Future _loadTlsContext() async { + throw UnimplementedError('TLS cert loading not yet wired up'); + } +} + +// --------------------------------------------------------------------------- +// Portal HTML (mirrors k_proxy_app.py HTML) +// --------------------------------------------------------------------------- + +const String _kPortalHtml = ''' + + + + + ChromeCard k_phone Portal + + + +
+
+

ChromeCard k_phone Portal

+

Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.

+
+
+
+

Enrollment

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

Session Flow

+
+ + + + +
+
+
+

+  
+ + +'''; diff --git a/k_phone/lib/session_manager.dart b/k_phone/lib/session_manager.dart new file mode 100644 index 0000000..06ba0e6 --- /dev/null +++ b/k_phone/lib/session_manager.dart @@ -0,0 +1,59 @@ +// Session token management — mirrors k_proxy_app.py session logic. +// Tokens are 32-byte hex strings; stored in memory only. + +import 'dart:math'; + +class SessionEntry { + final String username; + final DateTime expires; + SessionEntry({required this.username, required this.expires}); +} + +class SessionManager { + final Map _sessions = {}; + static const Duration _ttl = Duration(seconds: 300); + + /// Issue a new session token for [username]. + String issue(String username) { + _purgeExpired(); + final token = _randomToken(); + _sessions[token] = SessionEntry( + username: username, + expires: DateTime.now().add(_ttl), + ); + return token; + } + + /// Returns the session entry for [token], or null if missing/expired. + SessionEntry? getSession(String token) { + final s = _sessions[token]; + if (s == null) return null; + if (DateTime.now().isAfter(s.expires)) { + _sessions.remove(token); + return null; + } + return s; + } + + /// Returns true if [token] is known and not expired. + bool isValid(String token) => getSession(token) != null; + + /// Revoke [token] immediately. + void revoke(String token) => _sessions.remove(token); + + /// Revoke all sessions for [username]. + void revokeAll(String username) { + _sessions.removeWhere((_, s) => s.username == username); + } + + void _purgeExpired() { + final now = DateTime.now(); + _sessions.removeWhere((_, s) => now.isAfter(s.expires)); + } + + String _randomToken() { + final rng = Random.secure(); + final bytes = List.generate(32, (_) => rng.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} diff --git a/k_phone/pubspec.lock b/k_phone/pubspec.lock new file mode 100644 index 0000000..4f640c8 --- /dev/null +++ b/k_phone/pubspec.lock @@ -0,0 +1,581 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cbor: + dependency: "direct main" + description: + name: cbor + sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_background_service: + dependency: "direct main" + description: + name: flutter_background_service + sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_background_service_android: + dependency: "direct main" + description: + name: flutter_background_service_android + sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87 + url: "https://pub.dev" + source: hosted + version: "6.3.1" + flutter_background_service_ios: + dependency: transitive + description: + name: flutter_background_service_ios + sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_background_service_platform_interface: + dependency: transitive + description: + name: flutter_background_service_platform_interface + sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41 + url: "https://pub.dev" + source: hosted + version: "5.1.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/k_phone/pubspec.yaml b/k_phone/pubspec.yaml new file mode 100644 index 0000000..5b5be3b --- /dev/null +++ b/k_phone/pubspec.yaml @@ -0,0 +1,27 @@ +name: k_phone +description: ChromeCard FIDO2 proxy phone app — replaces k_proxy in the auth chain. +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_background_service: ^5.0.5 + flutter_background_service_android: ^6.2.0 + flutter_local_notifications: ^18.0.0 + uuid: ^4.4.0 + cbor: ^6.3.0 + pointycastle: ^3.7.3 + path_provider: ^2.1.4 + crypto: ^3.0.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true diff --git a/k_phone/test/fido2_test.dart b/k_phone/test/fido2_test.dart new file mode 100644 index 0000000..6f07830 --- /dev/null +++ b/k_phone/test/fido2_test.dart @@ -0,0 +1,167 @@ +// FIDO2 Dart unit tests. +// +// Tests 1-2 run without any external dependency. +// Tests 3-6 require card_emulator_bridge.py on TCP port 8772: +// +// uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \ +// tests/card_emulator_bridge.py +// +// Then run: flutter test test/fido2_test.dart + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cbor/cbor.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../lib/ctaphid_channel.dart'; +import '../lib/fido2_ops.dart'; + +void main() { + // ------------------------------------------------------------------------- + // Test 1: CBOR encode/decode round-trip + // ------------------------------------------------------------------------- + test('CBOR makeCredential request map encodes and decodes correctly', () { + final clientDataHash = Uint8List(32)..fillRange(0, 32, 0xAB); + + final requestMap = CborMap({ + CborSmallInt(1): CborBytes(clientDataHash), + CborSmallInt(2): CborMap({ + CborString('id'): CborString(kRpId), + CborString('name'): CborString(kRpName), + }), + CborSmallInt(4): CborList([ + CborMap({ + CborString('type'): CborString('public-key'), + CborString('alg'): CborSmallInt(-7), + }), + ]), + CborSmallInt(7): CborMap({ + CborString('rk'): CborBool(false), + CborString('uv'): CborBool(false), + }), + }); + + final encoded = Uint8List.fromList(cbor.encode(requestMap)); + expect(encoded, isNotEmpty); + + final decoded = cbor.decode(encoded); + expect(decoded, isA()); + final m = decoded as CborMap; + final hash = m[CborSmallInt(1)]; + expect(hash, isA()); + expect((hash as CborBytes).bytes, equals(clientDataHash)); + + final rp = m[CborSmallInt(2)] as CborMap; + expect((rp[CborString('id')] as CborString).toString(), equals(kRpId)); + }); + + // ------------------------------------------------------------------------- + // Test 2: clientDataHash is SHA256 of known JSON + // ------------------------------------------------------------------------- + test('clientDataHash matches SHA256 of known clientDataJSON', () { + // Fixed challenge for deterministic test + final challenge = Uint8List.fromList(List.generate(32, (i) => i)); + final challengeB64 = base64Url.encode(challenge).replaceAll('=', ''); + final clientDataJson = + '{"type":"webauthn.create","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}'; + + final expected = Uint8List.fromList(sha256.convert(utf8.encode(clientDataJson)).bytes); + expect(expected.length, equals(32)); + expect(expected, isNot(equals(Uint8List(32)))); + }); + + // ------------------------------------------------------------------------- + // Tests 3-6: require card_emulator_bridge.py on 127.0.0.1:8772 + // ------------------------------------------------------------------------- + group('emulator bridge', () { + late int cid; + late String credentialDataB64; + + setUpAll(() async { + useEmulator(host: '127.0.0.1', port: 8772); + final connected = await openCard(); + if (!connected) { + markTestSkipped('card_emulator_bridge.py not reachable on :8772'); + return; + } + cid = await ctaphidInit(); + }); + + tearDownAll(() async { + await closeCard(); + }); + + // ----------------------------------------------------------------------- + // Test 3: makeCredential returns valid AttestedCredentialData + // ----------------------------------------------------------------------- + test('makeCredential returns non-empty credentialData with valid structure', () async { + final result = await makeCredential(cid, 'testuser', displayName: 'Test User'); + + expect(result.credentialData, isNotEmpty); + expect(result.userId.length, equals(32)); + + // AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey + final cd = result.credentialData; + expect(cd.length, greaterThan(18)); + final credIdLen = (cd[16] << 8) | cd[17]; + expect(credIdLen, greaterThan(0)); + expect(cd.length, greaterThanOrEqualTo(18 + credIdLen + 1)); // at least 1 byte of COSE key + + credentialDataB64 = result.credentialDataB64; + expect(credentialDataB64, isNotEmpty); + }); + + // ----------------------------------------------------------------------- + // Test 4: getAssertion returns non-empty authData and signature + // ----------------------------------------------------------------------- + test('getAssertion returns authData and signature bytes', () async { + expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first'); + + final result = await getAssertion(cid, credentialDataB64); + + expect(result.authData, isNotEmpty); + expect(result.signature, isNotEmpty); + expect(result.clientDataHash.length, equals(32)); + }); + + // ----------------------------------------------------------------------- + // Test 5: verifyAssertion accepts valid signature + // ----------------------------------------------------------------------- + test('verifyAssertion accepts valid assertion signature', () async { + expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first'); + + final assertion = await getAssertion(cid, credentialDataB64); + + final ok = verifyAssertion( + credentialDataB64, + assertion.authData, + assertion.signature, + assertion.clientDataHash, + ); + expect(ok, isTrue); + }); + + // ----------------------------------------------------------------------- + // Test 6: verifyAssertion rejects tampered authData + // ----------------------------------------------------------------------- + test('verifyAssertion rejects tampered authData', () async { + expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first'); + + final assertion = await getAssertion(cid, credentialDataB64); + + // Flip one byte in authData (byte 32 = flags byte) + final tampered = Uint8List.fromList(assertion.authData); + tampered[32] ^= 0xFF; + + final ok = verifyAssertion( + credentialDataB64, + tampered, + assertion.signature, + assertion.clientDataHash, + ); + expect(ok, isFalse); + }); + }); +} diff --git a/k_phone/test/widget_test.dart b/k_phone/test/widget_test.dart new file mode 100644 index 0000000..4e06cbd --- /dev/null +++ b/k_phone/test/widget_test.dart @@ -0,0 +1,6 @@ +// Placeholder — real tests will cover proxy logic and CTAPHID framing. +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('placeholder', () => expect(1 + 1, 2)); +} diff --git a/k_proxy_app.py b/k_proxy_app.py new file mode 100644 index 0000000..ca72ba1 --- /dev/null +++ b/k_proxy_app.py @@ -0,0 +1,1350 @@ +#!/usr/bin/env python3 +""" +k_proxy — session gateway and card authentication bridge. + +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. + +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 + +import argparse +import base64 +import http.client +import json +import queue +import re +import secrets +import ssl +import subprocess +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 + +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: + if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None: + fido2.features.webauthn_json_mapping.enabled = True +except AttributeError: + pass + + +HTML = """ + + + + + ChromeCard Proxy Portal + + + +
+
+

ChromeCard Proxy Portal

+

+ Primary browser entry point for the current prototype. Browser traffic now targets k_proxy directly. + Enrollment, card-backed 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 + expires_at: float + + +@dataclass +class Enrollment: + username: str + 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 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, + "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 + + +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, + direct_device_path: 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.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 + 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 = 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() + + 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: + 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 _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 + 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, + "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 _new_fido_client(self) -> Fido2Client: + 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 _direct_device_candidates(self) -> list[str]: + configured = str(self.direct_device_configured_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: + 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_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})") + raise last_exc + + 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: + 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): + # 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): + try: + device = self._get_direct_device(force_reopen=reopen) + return action(Ctap2(device)) + except Exception as exc: + last_exc = exc + self._drop_direct_device_locked() + 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, + 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(canonical) + if existing: + 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 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: + 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, + RegistrationResponse( + raw_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 + + 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() + # 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: + 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(canonical) + + def has_enrollment(self, username: str) -> bool: + return self.get_enrollment(username) is not None + + def _authenticate_with_probe(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 _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: + # 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. + options, state = self.fido_server.authenticate_begin( + [credential], + user_verification=UserVerificationRequirement.DISCOURAGED, + ) + 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( + raw_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" + + 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() + # 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 {} + except Exception: + 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") + 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 _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 "): + return None + token = value[7:].strip() + 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"}) + 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 == "/": + self._html(HTML) + return + if path == "/health": + self._json( + 200, + { + "ok": True, + "service": "k_proxy", + "active_sessions": self.state.active_session_count(), + "time": int(time.time()), + }, + ) + return + 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 + path = urlparse(self.path).path + if path == "/session/login": + self._session_login() + return + 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 + 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: + data = self._require_json() + if data is None: + return + + 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(username) + 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": self.state.auth_mode_label(), + }, + ) + + def _enroll_register(self) -> None: + data = self._require_json() + if data is None: + return + + 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 + + self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at)) + + def _enroll_update(self) -> None: + data = self._require_json() + if data is None: + 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: + data = self._require_json() + if data is None: + 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) + 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, 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 + _, 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: + self._discard_request_body() + 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: + self._discard_request_body() + 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("--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 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", + 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( + "--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", + 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", + ) + parser.add_argument( + "--direct-device-path", + default="/dev/hidraw0", + help="Explicit hidraw path used for direct FIDO2 mode", + ) + return parser.parse_args() + + +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_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, + direct_device_path=args.direct_device_path, + ) + Handler.state = state + server = ThreadingHTTPServer((args.host, args.port), Handler) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/k_server_app.py b/k_server_app.py new file mode 100644 index 0000000..b95902a --- /dev/null +++ b/k_server_app.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +k_server — protected resource backend. + +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 + +import argparse +import json +import ssl +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +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 + self.lock = threading.Lock() + + def next_counter(self) -> int: + with self.lock: + self.counter += 1 + return self.counter + + +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") + 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 _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) + + 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 + self._discard_request_body() + 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("--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", + help="Shared token expected in X-Proxy-Token from k_proxy", + ) + return parser.parse_args() + + +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) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) 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/phase5_chain_regression.sh b/phase5_chain_regression.sh new file mode 100755 index 0000000..7203731 --- /dev/null +++ b/phase5_chain_regression.sh @@ -0,0 +1,230 @@ +#!/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}" +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' +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 + --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 +} + +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 + ;; + --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 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "${INTERACTIVE_CARD}" == "1" ]]; then + cat <= 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, timeout: int = 10): + 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=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") + 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}, 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 = [] + +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/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/raw_ctap_probe.py b/raw_ctap_probe.py new file mode 100644 index 0000000..8c35935 --- /dev/null +++ b/raw_ctap_probe.py @@ -0,0 +1,321 @@ +#!/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 + 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) + 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 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)) + + +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") + 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") + 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() + + if args.command == "list": + devs = list_devices() + print_json( + { + "devices": [describe_device(dev) for dev in devs], + } + ) + return 0 if devs else 1 + + dev = get_device(args.index, args.device_path) + 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()) diff --git a/tests/card_emulator.py b/tests/card_emulator.py new file mode 100644 index 0000000..a5b7b3f --- /dev/null +++ b/tests/card_emulator.py @@ -0,0 +1,339 @@ +""" +CardEmulator — software emulator of the ChromeCard FIDO2 authenticator +====================================================================== + +What it is +---------- +CardEmulator is a drop-in replacement for the physical ChromeCard in tests. +It implements the two Ctap2 methods that k_proxy_app calls in fido2-direct +mode — make_credential (registration) and get_assertion (authentication) — +using real P-256 cryptography and an in-memory credential store. + +The auth_data layout and COSE key encoding mirror the firmware exactly +(see fido_make_cred.c and fido_get_assertion.c), so fido2.server's +register_complete and authenticate_complete accept the emulator's responses +without any extra patching. + + +Wiring the emulator into a ProxyState test +------------------------------------------ +Two patches are needed: one to replace _with_direct_ctap2 so it hands the +emulator to the lambda instead of opening a real HID device, and one to +suppress _drop_direct_device which would otherwise try to close a real handle. + +A convenience helper for this is provided in test_k_proxy.py: + + def _patch_emulator(state, emulator): + return patch.multiple( + state, + _with_direct_ctap2=lambda fn: fn(emulator), + _drop_direct_device=lambda: None, + ) + +Typical test setup: + + from card_emulator import CardEmulator + from unittest.mock import patch + + emulator = CardEmulator() + state = _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + + with _patch_emulator(state, emulator): + enrollment = state.register_enrollment("alice", "Alice") + ok, msg = state.authenticate_with_card("alice") # True, "assertion verified" + + +User confirmation (Yes / No on the card) +----------------------------------------- +Both make_credential and get_assertion accept a `user_confirms` keyword +argument (default True). Setting it to False raises +CtapError(OPERATION_DENIED), exactly as the firmware does when the user taps +No on the card's confirmation dialog. + +Direct calls — pass the flag explicitly: + + attest = emulator.make_credential( + client_data_hash=..., rp=..., user=..., key_params=..., + user_confirms=False, + ) # raises CtapError(OPERATION_DENIED) + +Integration tests through _with_direct_ctap2 — the lambda that ProxyState +builds cannot inject user_confirms, so use refusing() instead. It returns +a thin wrapper whose methods forward to the emulator with user_confirms=False: + + with _patch_emulator(state, emulator.refusing()): + ok, msg = state.authenticate_with_card("alice") # False + # msg contains "assertion verification failed: CTAP error: OPERATION_DENIED" + + with _patch_emulator(state, emulator.refusing()): + with self.assertRaises(RuntimeError): + state.register_enrollment("bob", None) + # raises RuntimeError("card registration failed: ...") + +refusing() shares the same credential store as the parent emulator, so +credentials registered before or after the call are visible to both. + + +Simulating card-side credential removal +---------------------------------------- +forget_user(username) removes all credentials for that user from the +emulator's store and returns the count removed. Use it to simulate a +factory reset or a deliberate key deletion: + + emulator.forget_user("alice") + ok, msg = state.authenticate_with_card("alice") # False + + +API summary +----------- +CardEmulator() + Create a new emulator with an empty credential store. + +make_credential(client_data_hash, rp, user, key_params, *, user_confirms=True) + Simulate CTAP2 makeCredential. Returns AttestationResponse. + +get_assertion(rp_id, client_data_hash, allow_list, *, user_confirms=True) + Simulate CTAP2 getAssertion. Returns AssertionResponse. + Raises CtapError(NO_CREDENTIALS) if no matching credential is found. + +refusing() -> _RefusingView + Return a wrapper that forces user_confirms=False on every call. + +forget_user(username) -> int + Remove all credentials for username. Returns count removed. + +credential_count() -> int + Total credentials currently in the store. +""" + +from __future__ import annotations + +import hashlib +import os +import struct +from dataclasses import dataclass +from typing import Any, Mapping + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from fido2.ctap import CtapError +from fido2.ctap2 import AssertionResponse, AttestationResponse +from fido2.webauthn import AuthenticatorData + +# AAGUID from fido_make_cred.c +_AAGUID = bytes([ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, +]) + + +def _cose_es256(x: bytes, y: bytes) -> bytes: + """CBOR-encoded COSE ES256 public key, byte-for-byte as the firmware builds it.""" + return ( + bytes([0xA5]) # map(5) + + bytes([0x01, 0x02]) # kty: 2 (EC2) + + bytes([0x03, 0x26]) # alg: -7 (ES256) + + bytes([0x20, 0x01]) # crv (-1): 1 (P-256) + + bytes([0x21, 0x58, 0x20]) + x # x (-2): bstr(32) + + bytes([0x22, 0x58, 0x20]) + y # y (-3): bstr(32) + ) + + +@dataclass +class _Credential: + private_key: ec.EllipticCurvePrivateKey + rp_id_hash: bytes + user_id: bytes + username: str + sign_count: int = 0 + + +class CardEmulator: + """In-process emulator of a ChromeCard FIDO2 authenticator. + + Implements make_credential and get_assertion with the same signatures as + fido2.ctap2.Ctap2, plus forget_user() for test teardown. + """ + + def __init__(self) -> None: + self._creds: dict[bytes, _Credential] = {} + + # ── CTAP2 interface ─────────────────────────────────────────────────────── + + def make_credential( + self, + client_data_hash: bytes, + rp: Mapping[str, Any], + user: Mapping[str, Any], + key_params: list[Mapping[str, Any]], + exclude_list: list[Mapping[str, Any]] | None = None, + extensions: Mapping[str, Any] | None = None, + options: Mapping[str, Any] | None = None, + *, + user_confirms: bool = True, + **_: Any, + ) -> AttestationResponse: + """Simulate makeCredential. + + When user_confirms is False the call raises CtapError(OPERATION_DENIED), + mirroring the firmware's response when the user taps No on the card. + Otherwise a real P-256 keypair is generated, stored, and returned as a + fmt='none' AttestationResponse with a valid COSE ES256 public key. + """ + if not user_confirms: + raise CtapError(CtapError.ERR.OPERATION_DENIED) + + rp_id: str = rp["id"] + rp_id_hash = hashlib.sha256(rp_id.encode()).digest() + + priv = ec.generate_private_key(ec.SECP256R1()) + pub_nums = priv.public_key().public_numbers() + x = pub_nums.x.to_bytes(32, "big") + y = pub_nums.y.to_bytes(32, "big") + + credential_id = os.urandom(32) + + raw_user_id: bytes = user.get("id", b"") # type: ignore[assignment] + if isinstance(raw_user_id, str): + raw_user_id = raw_user_id.encode() + + cred = _Credential( + private_key=priv, + rp_id_hash=rp_id_hash, + user_id=raw_user_id, + username=user.get("name", ""), # type: ignore[arg-type] + ) + + # authData layout matches fido_make_cred.c build_make_credential_response(): + # rpIdHash(32) | flags(1) | signCount(4) | aaguid(16) + # | credIdLen(2) | credId(32) | coseKey(77) + auth_data_bytes = ( + rp_id_hash + + bytes([0x41]) # flags: UP=1, AT=1 + + struct.pack(">I", cred.sign_count) + + _AAGUID + + struct.pack(">H", len(credential_id)) + + credential_id + + _cose_es256(x, y) + ) + cred.sign_count += 1 + self._creds[credential_id] = cred + + return AttestationResponse( + fmt="none", + auth_data=AuthenticatorData(auth_data_bytes), + att_stmt={}, + ) + + def get_assertion( + self, + rp_id: str, + client_data_hash: bytes, + allow_list: list[Mapping[str, Any]] | None = None, + extensions: Mapping[str, Any] | None = None, + options: Mapping[str, Any] | None = None, + *, + user_confirms: bool = True, + **_: Any, + ) -> AssertionResponse: + """Simulate getAssertion. + + When user_confirms is False raises CtapError(OPERATION_DENIED). + Otherwise finds the credential, builds authData (37 bytes, no AT flag), + signs authData || clientDataHash with ECDSA-SHA256 (DER), and returns + an AssertionResponse — byte-for-byte compatible with the firmware output. + """ + if not user_confirms: + raise CtapError(CtapError.ERR.OPERATION_DENIED) + + rp_id_hash = hashlib.sha256(rp_id.encode()).digest() + + if not allow_list: + raise CtapError(CtapError.ERR.NO_CREDENTIALS) + + cred_id: bytes | None = None + cred: _Credential | None = None + for desc in allow_list: + cid: bytes = desc["id"] if isinstance(desc, dict) else getattr(desc, "id") + entry = self._creds.get(cid) + if entry is not None and entry.rp_id_hash == rp_id_hash: + cred_id = cid + cred = entry + break + + if cred is None or cred_id is None: + raise CtapError(CtapError.ERR.NO_CREDENTIALS) + + # authData layout matches fido_get_assertion.c build_get_assertion_response(): + # rpIdHash(32) | flags(1) | signCount(4) + auth_data_bytes = ( + rp_id_hash + + bytes([0x01]) # flags: UP=1 + + struct.pack(">I", cred.sign_count) + ) + cred.sign_count += 1 + + # Signature over authData || clientDataHash, DER-encoded. + # Matches drv_crypto_sign_hash_DER() in the firmware. + sig = cred.private_key.sign( + auth_data_bytes + client_data_hash, + ec.ECDSA(hashes.SHA256()), + ) + + user_field: dict[str, Any] | None = {"id": cred.user_id} if cred.user_id else None + + return AssertionResponse( + credential={"id": cred_id, "type": "public-key"}, + auth_data=AuthenticatorData(auth_data_bytes), + signature=sig, + user=user_field, + ) + + # ── test helpers ────────────────────────────────────────────────────────── + + def refusing(self) -> "_RefusingView": + """Return a view of this emulator that always declines confirmation. + + Use this when the test goes through _with_direct_ctap2, which calls + make_credential / get_assertion via a lambda and cannot inject + user_confirms directly: + + with patch.object(state, "_with_direct_ctap2", + side_effect=lambda fn: fn(emulator.refusing())): + ok, msg = state.authenticate_with_card("alice") + self.assertFalse(ok) + """ + return _RefusingView(self) + + def forget_user(self, username: str) -> int: + """Remove all credentials for *username* from the emulator store. + + Returns the number of credentials removed. Use this to simulate a + card-side credential deletion (factory reset, or deliberate removal). + """ + to_delete = [cid for cid, e in self._creds.items() if e.username == username] + for cid in to_delete: + del self._creds[cid] + return len(to_delete) + + def credential_count(self) -> int: + """Total number of credentials currently in the emulator store.""" + return len(self._creds) + + +class _RefusingView: + """Thin proxy returned by CardEmulator.refusing(). + + Forwards make_credential and get_assertion to the underlying emulator + with user_confirms=False, so every call raises OPERATION_DENIED. + The credential store is shared with the parent emulator. + """ + + def __init__(self, emulator: CardEmulator) -> None: + self._emulator = emulator + + def make_credential(self, **kwargs: Any) -> AttestationResponse: + return self._emulator.make_credential(**kwargs, user_confirms=False) + + def get_assertion(self, **kwargs: Any) -> AssertionResponse: + return self._emulator.get_assertion(**kwargs, user_confirms=False) diff --git a/tests/card_emulator_bridge.py b/tests/card_emulator_bridge.py new file mode 100644 index 0000000..db9e42d --- /dev/null +++ b/tests/card_emulator_bridge.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +card_emulator_bridge.py — CTAPHID TCP bridge for Android emulator testing. + +The Dart ctaphid_channel.dart speaks raw 64-byte CTAPHID packets over TCP. +This bridge listens on :8772 (Android emulator reaches the Mac host at +10.0.2.2), translates CTAPHID frames into CardEmulator calls, and sends +framed CTAPHID responses back. + +Run: + uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \\ + tests/card_emulator_bridge.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import struct +import sys +from typing import Any + +import cbor2 +from fido2.ctap import CtapError + +sys.path.insert(0, os.path.dirname(__file__)) +from card_emulator import CardEmulator + +LOG = logging.getLogger("bridge") + +# CTAPHID constants +_BROADCAST_CID = 0xFFFFFFFF +_CMD_INIT = 0x06 +_CMD_CBOR = 0x10 +_CMD_ERROR = 0x3F +_CMD_KEEPALIVE = 0x3B + +_HID_SIZE = 64 +_INIT_PAYLOAD = _HID_SIZE - 7 # 57 bytes usable in an init packet +_CONT_PAYLOAD = _HID_SIZE - 5 # 59 bytes usable in a continuation packet + +# CTAP2 authenticator command codes (first byte of CTAPHID_CBOR payload) +_CTAP2_MAKE_CREDENTIAL = 0x01 +_CTAP2_GET_ASSERTION = 0x02 +_CTAP2_GET_INFO = 0x04 + +# CTAP error codes +_ERR_INVALID_CMD = 0x01 +_ERR_INVALID_LEN = 0x03 + + +# --------------------------------------------------------------------------- +# Packet helpers +# --------------------------------------------------------------------------- + +def _pack(cid: int, cmd: int, payload: bytes) -> list[bytes]: + """Fragment payload into CTAPHID init + continuation packets.""" + packets: list[bytes] = [] + cid_b = struct.pack(">I", cid) + + init = bytearray(_HID_SIZE) + init[:4] = cid_b + init[4] = (cmd & 0x7F) | 0x80 + init[5] = (len(payload) >> 8) & 0xFF + init[6] = len(payload) & 0xFF + first_chunk = payload[:_INIT_PAYLOAD] + init[7: 7 + len(first_chunk)] = first_chunk + packets.append(bytes(init)) + + offset, seq = len(first_chunk), 0 + while offset < len(payload): + cont = bytearray(_HID_SIZE) + cont[:4] = cid_b + cont[4] = seq & 0x7F + chunk = payload[offset: offset + _CONT_PAYLOAD] + cont[5: 5 + len(chunk)] = chunk + packets.append(bytes(cont)) + offset += len(chunk) + seq += 1 + + return packets + + +def _error_pkt(cid: int, code: int) -> bytes: + return _pack(cid, _CMD_ERROR, bytes([code]))[0] + + +def _keepalive_pkt(cid: int) -> bytes: + # Status 0x02 = TUP_NEEDED (card is processing) + return _pack(cid, _CMD_KEEPALIVE, bytes([0x02]))[0] + + +# --------------------------------------------------------------------------- +# Per-connection handler +# --------------------------------------------------------------------------- + +class _Handler: + """Handles one Android emulator TCP connection.""" + + def __init__( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + emulator: CardEmulator, + ) -> None: + self._r = reader + self._w = writer + self._emulator = emulator + self._allocated_cid = 1 # fixed CID for single-connection bridge + + async def run(self) -> None: + peer = self._w.get_extra_info("peername") + LOG.info("connect from %s", peer) + try: + while True: + cid, cmd, data = await self._recv_message() + await self._dispatch(cid, cmd, data) + except (asyncio.IncompleteReadError, ConnectionResetError, EOFError): + LOG.info("disconnect from %s", peer) + except Exception: + LOG.exception("handler error") + finally: + self._w.close() + try: + await self._w.wait_closed() + except Exception: + pass + + # ---- I/O ---------------------------------------------------------------- + + async def _recv_pkt(self) -> bytes: + return await self._r.readexactly(_HID_SIZE) + + async def _send(self, packets: list[bytes]) -> None: + for pkt in packets: + self._w.write(pkt) + await self._w.drain() + + # ---- Message reassembly ------------------------------------------------- + + async def _recv_message(self) -> tuple[int, int, bytes]: + """Read one full CTAPHID message (init + any continuations). + + After every non-final packet the Dart client is blocked waiting for a + response. We send a keepalive so it resumes and sends the next packet. + """ + init_pkt = await self._recv_pkt() + + cid = struct.unpack(">I", init_pkt[:4])[0] + cmd = init_pkt[4] & 0x7F + bcnt = (init_pkt[5] << 8) | init_pkt[6] + + buf = bytearray(init_pkt[7: 7 + min(bcnt, _INIT_PAYLOAD)]) + + while len(buf) < bcnt: + # Unblock Dart's _sendPacket which is awaiting a response. + self._w.write(_keepalive_pkt(cid)) + await self._w.drain() + + cont = await self._recv_pkt() + remaining = bcnt - len(buf) + buf.extend(cont[5: 5 + min(remaining, _CONT_PAYLOAD)]) + + return cid, cmd, bytes(buf) + + # ---- Dispatch ----------------------------------------------------------- + + async def _dispatch(self, cid: int, cmd: int, data: bytes) -> None: + if cmd == _CMD_INIT: + await self._handle_init(cid, data) + elif cmd == _CMD_CBOR: + await self._handle_cbor(cid, data) + else: + LOG.warning("unknown CTAPHID cmd=0x%02x cid=0x%08x", cmd, cid) + await self._send([_error_pkt(cid, _ERR_INVALID_CMD)]) + + # ---- CTAPHID INIT ------------------------------------------------------- + + async def _handle_init(self, cid: int, data: bytes) -> None: + if len(data) < 8: + await self._send([_error_pkt(cid, _ERR_INVALID_LEN)]) + return + nonce = data[:8] + new_cid = self._allocated_cid + # Response payload: nonce(8) + new_cid(4) + CTAPHID_version(1) + # + major(1) + minor(1) + build(1) + capabilities(1) + payload = nonce + struct.pack(">I", new_cid) + bytes([2, 1, 0, 0, 0x04]) + await self._send(_pack(_BROADCAST_CID, _CMD_INIT, payload)) + LOG.info("INIT → CID=0x%08x", new_cid) + + # ---- CTAPHID CBOR (CTAP2) ----------------------------------------------- + + async def _handle_cbor(self, cid: int, data: bytes) -> None: + if not data: + await self._send([_error_pkt(cid, _ERR_INVALID_LEN)]) + return + + ctap2_cmd = data[0] + body = data[1:] if len(data) > 1 else b"" + + LOG.info("CTAP2 cmd=0x%02x body=%d bytes", ctap2_cmd, len(body)) + + try: + if ctap2_cmd == _CTAP2_MAKE_CREDENTIAL: + resp_cbor = self._make_credential(body) + elif ctap2_cmd == _CTAP2_GET_ASSERTION: + resp_cbor = self._get_assertion(body) + elif ctap2_cmd == _CTAP2_GET_INFO: + resp_cbor = self._get_info() + else: + LOG.warning("unsupported CTAP2 cmd=0x%02x", ctap2_cmd) + await self._send([_error_pkt(cid, _ERR_INVALID_CMD)]) + return + except CtapError as exc: + code = exc.code.value if hasattr(exc.code, "value") else int(exc.code) + LOG.warning("CtapError 0x%02x: %s", code, exc) + await self._send(_pack(cid, _CMD_CBOR, bytes([code]))) + return + except Exception: + LOG.exception("CTAP2 processing error") + await self._send(_pack(cid, _CMD_CBOR, bytes([0x01]))) + return + + # Success: status 0x00 + CBOR-encoded response map + await self._send(_pack(cid, _CMD_CBOR, bytes([0x00]) + resp_cbor)) + + # ---- CTAP2 operations --------------------------------------------------- + + def _make_credential(self, body: bytes) -> bytes: + params: dict[Any, Any] = cbor2.loads(body) + + # CTAP2 spec integer keys: 1=clientDataHash, 2=rp, 3=user, + # 4=pubKeyCredParams, 7=options + client_data_hash: bytes = bytes(params[1]) + rp: dict = dict(params[2]) + user: dict = dict(params[3]) + key_params: list = [dict(kp) for kp in params[4]] + options: dict = dict(params.get(7, {})) + + LOG.info("makeCredential rp_id=%r user=%r", rp.get("id"), user.get("name")) + + resp = self._emulator.make_credential( + client_data_hash=client_data_hash, + rp=rp, + user=user, + key_params=key_params, + options=options, + ) + + auth_data_bytes = bytes(resp.auth_data) + LOG.info("makeCredential OK auth_data=%d bytes", len(auth_data_bytes)) + + # CTAP2 makeCredential response map: 1=fmt, 2=authData, 3=attStmt + return cbor2.dumps({ + 1: resp.fmt, + 2: auth_data_bytes, + 3: resp.att_stmt or {}, + }) + + def _get_assertion(self, body: bytes) -> bytes: + params: dict[Any, Any] = cbor2.loads(body) + + # CTAP2 spec integer keys: 1=rpId, 2=clientDataHash, 3=allowList, 5=options + rp_id: str = params[1] + client_data_hash: bytes = bytes(params[2]) + allow_list_raw: list = list(params.get(3, [])) + options: dict = dict(params.get(5, {})) + + allow_list = [dict(item) for item in allow_list_raw] or None + + LOG.info("getAssertion rp_id=%r allow_list_len=%s", + rp_id, len(allow_list) if allow_list else 0) + + resp = self._emulator.get_assertion( + rp_id=rp_id, + client_data_hash=client_data_hash, + allow_list=allow_list, + options=options, + ) + + auth_data_bytes = bytes(resp.auth_data) + signature = bytes(resp.signature) + + # Build credential descriptor for key 1 + cred = resp.credential + if hasattr(cred, "id"): + cred_map: dict = {"type": "public-key", "id": bytes(cred.id)} + else: + cred_map = { + k: (bytes(v) if isinstance(v, (bytes, bytearray, memoryview)) else v) + for k, v in (cred or {}).items() + } + + LOG.info("getAssertion OK auth_data=%d bytes sig=%d bytes", + len(auth_data_bytes), len(signature)) + + # CTAP2 getAssertion response map: 1=credential, 2=authData, 3=signature + resp_map: dict[int, Any] = {1: cred_map, 2: auth_data_bytes, 3: signature} + if resp.user: + u = resp.user + resp_map[4] = dict(u) if hasattr(u, "items") else u + + return cbor2.dumps(resp_map) + + def _get_info(self) -> bytes: + # Minimal getInfo response + return cbor2.dumps({ + 1: ["FIDO_2_0"], # versions + 3: b"\x00" * 16, # aaguid + }) + + +# --------------------------------------------------------------------------- +# Server bootstrap +# --------------------------------------------------------------------------- + +async def _serve(host: str, port: int) -> None: + emulator = CardEmulator() + LOG.info("card_emulator_bridge listening on %s:%d — ctrl-C to stop", host, port) + + async def _on_connect( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + await _Handler(reader, writer, emulator).run() + + server = await asyncio.start_server(_on_connect, host, port) + async with server: + await server.serve_forever() + + +def _main() -> None: + ap = argparse.ArgumentParser( + description="CTAPHID TCP bridge for Android emulator ↔ CardEmulator" + ) + ap.add_argument("--host", default="0.0.0.0", help="Listen host (default 0.0.0.0)") + ap.add_argument("--port", type=int, default=8772, help="Listen port (default 8772)") + args = ap.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + asyncio.run(_serve(args.host, args.port)) + + +if __name__ == "__main__": + _main() 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); + }); + }); +}); diff --git a/tests/test_k_proxy.py b/tests/test_k_proxy.py new file mode 100644 index 0000000..cd010b4 --- /dev/null +++ b/tests/test_k_proxy.py @@ -0,0 +1,1048 @@ +#!/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)) +sys.path.insert(0, str(Path(__file__).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) + + +# ── card emulator integration tests ────────────────────────────────────────── + +from card_emulator import CardEmulator + + +def _make_direct_state(tmp_path): + return _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT) + + +def _patch_emulator(state, emulator): + """Return a context manager that wires *emulator* into *state* as the card.""" + return patch.multiple( + state, + _with_direct_ctap2=lambda fn: fn(emulator), + _drop_direct_device=lambda: None, + ) + + +class TestCardEmulatorUnit(unittest.TestCase): + """Direct calls to the emulator — no ProxyState involved.""" + + def setUp(self): + self.emulator = CardEmulator() + + def _register(self, username="alice", rp_id="localhost"): + rp_id_hash = __import__("hashlib").sha256(rp_id.encode()).digest() + return self.emulator.make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": rp_id, "name": "Test RP"}, + user={"id": b"user-id", "name": username, "displayName": username}, + key_params=[{"type": "public-key", "alg": -7}], + ) + + def test_make_credential_returns_none_attestation(self): + attest = self._register() + self.assertEqual(attest.fmt, "none") + self.assertEqual(attest.att_stmt, {}) + + def test_make_credential_stores_credential(self): + self._register() + self.assertEqual(self.emulator.credential_count(), 1) + + def test_make_credential_auth_data_is_attested(self): + attest = self._register() + self.assertTrue(attest.auth_data.is_attested()) + + def test_make_credential_cred_id_is_32_bytes(self): + attest = self._register() + self.assertEqual(len(attest.auth_data.credential_data.credential_id), 32) + + def test_make_credential_user_confirms_false_raises(self): + from fido2.ctap import CtapError + with self.assertRaises(CtapError) as ctx: + self._register() # first register so there's a credential + self.emulator.make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": "localhost", "name": "Test RP"}, + user={"id": b"user-id", "name": "bob", "displayName": "bob"}, + key_params=[{"type": "public-key", "alg": -7}], + user_confirms=False, + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_get_assertion_user_confirms_false_raises(self): + from fido2.ctap import CtapError + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError) as ctx: + self.emulator.get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + user_confirms=False, + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_get_assertion_wrong_rp_raises(self): + from fido2.ctap import CtapError + attest = self._register(rp_id="localhost") + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError): + self.emulator.get_assertion( + rp_id="evil.example", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + + def test_get_assertion_empty_allow_list_raises(self): + from fido2.ctap import CtapError + self._register() + with self.assertRaises(CtapError): + self.emulator.get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=None, + ) + + def test_sign_count_increments_across_assertions(self): + import struct + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + + def _count(assertion): + return struct.unpack(">I", bytes(assertion.auth_data)[33:37])[0] + + a1 = self.emulator.get_assertion("localhost", b"\x01" * 32, + [{"id": cred_id, "type": "public-key"}]) + a2 = self.emulator.get_assertion("localhost", b"\x02" * 32, + [{"id": cred_id, "type": "public-key"}]) + self.assertGreater(_count(a2), _count(a1)) + + def test_forget_user_removes_credential(self): + self._register() + removed = self.emulator.forget_user("alice") + self.assertEqual(removed, 1) + self.assertEqual(self.emulator.credential_count(), 0) + + def test_forget_unknown_user_returns_zero(self): + self._register() + self.assertEqual(self.emulator.forget_user("nobody"), 0) + self.assertEqual(self.emulator.credential_count(), 1) + + def test_refusing_view_make_credential_raises(self): + from fido2.ctap import CtapError + with self.assertRaises(CtapError) as ctx: + self.emulator.refusing().make_credential( + client_data_hash=b"\x00" * 32, + rp={"id": "localhost", "name": "Test RP"}, + user={"id": b"u", "name": "alice", "displayName": "Alice"}, + key_params=[{"type": "public-key", "alg": -7}], + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + def test_refusing_view_get_assertion_raises(self): + from fido2.ctap import CtapError + attest = self._register() + cred_id = attest.auth_data.credential_data.credential_id + with self.assertRaises(CtapError) as ctx: + self.emulator.refusing().get_assertion( + rp_id="localhost", + client_data_hash=b"\x01" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + self.assertEqual(ctx.exception.code, CtapError.ERR.OPERATION_DENIED) + + +class TestCardEmulatorIntegration(unittest.TestCase): + """Full register → authenticate flow through ProxyState with the emulator.""" + + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmpdir.name) + self.state = _make_direct_state(self.tmp_path) + self.emulator = CardEmulator() + + def tearDown(self): + self._tmpdir.cleanup() + + def _register(self, username="alice", display_name=None): + with _patch_emulator(self.state, self.emulator): + return self.state.register_enrollment(username, display_name) + + def _authenticate(self, username="alice"): + with _patch_emulator(self.state, self.emulator): + return self.state.authenticate_with_card(username) + + def _authenticate_refusing(self, username="alice"): + with _patch_emulator(self.state, self.emulator.refusing()): + return self.state.authenticate_with_card(username) + + def test_register_produces_credential_data(self): + enrollment = self._register("alice", "Alice") + self.assertIsNotNone(enrollment.credential_data_b64) + self.assertEqual(enrollment.username, "alice") + + def test_register_persists_to_disk(self): + self._register("alice") + state2 = _make_direct_state(self.tmp_path) + self.assertTrue(state2.has_enrollment("alice")) + self.assertIsNotNone(state2.get_enrollment("alice").credential_data_b64) + + def test_authenticate_after_register_succeeds(self): + self._register("alice") + ok, msg = self._authenticate("alice") + self.assertTrue(ok) + self.assertEqual(msg, "assertion verified") + + def test_authenticate_user_says_no_fails(self): + self._register("alice") + ok, msg = self._authenticate_refusing("alice") + self.assertFalse(ok) + self.assertIn("assertion verification failed", msg) + + def test_register_user_says_no_fails(self): + with _patch_emulator(self.state, self.emulator.refusing()): + with self.assertRaises(RuntimeError) as ctx: + self.state.register_enrollment("alice", None) + self.assertIn("card registration failed", str(ctx.exception)) + + def test_authenticate_after_forget_fails(self): + self._register("alice") + self.emulator.forget_user("alice") + ok, msg = self._authenticate("alice") + self.assertFalse(ok) + + def test_two_users_independent(self): + self._register("alice") + self._register("bob") + ok_a, _ = self._authenticate("alice") + ok_b, _ = self._authenticate("bob") + self.assertTrue(ok_a) + self.assertTrue(ok_b) + + def test_forget_one_user_leaves_other_intact(self): + self._register("alice") + self._register("bob") + self.emulator.forget_user("alice") + ok_a, _ = self._authenticate("alice") + ok_b, _ = self._authenticate("bob") + self.assertFalse(ok_a) + self.assertTrue(ok_b) + + def test_sign_count_increases_across_logins(self): + import struct + from k_proxy_app import AttestedCredentialData, b64u_decode + self._register("alice") + enrollment = self.state.get_enrollment("alice") + cred_data = AttestedCredentialData(b64u_decode(enrollment.credential_data_b64)) + cred_id = cred_data.credential_id + + sign_counts = [] + for _ in range(3): + assertion = self.emulator.get_assertion( + rp_id=self.state.rp_id, + client_data_hash=b"\xAB" * 32, + allow_list=[{"id": cred_id, "type": "public-key"}], + ) + sign_counts.append(struct.unpack(">I", bytes(assertion.auth_data)[33:37])[0]) + + self.assertLess(sign_counts[0], sign_counts[1]) + self.assertLess(sign_counts[1], sign_counts[2]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/webauthn_local_demo.py b/webauthn_local_demo.py new file mode 100644 index 0000000..a34ff81 --- /dev/null +++ b/webauthn_local_demo.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +Local WebAuthn demo server for USB FIDO2 card testing. + +Purpose: +- Validate registration and authentication flows with the connected card. +- Keep setup minimal (Python stdlib only). + +Security note: +- This demo does NOT verify attestation or assertion signatures. +- Use only for local bring-up/testing, not production. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import secrets +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + + +def b64u_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def b64u_decode(data: str) -> bytes: + pad = "=" * ((4 - len(data) % 4) % 4) + return base64.urlsafe_b64decode((data + pad).encode("ascii")) + + +def random_b64u(n: int = 32) -> str: + return b64u_encode(secrets.token_bytes(n)) + + +class DemoState: + def __init__(self, db_path: Path, rp_id: str, rp_name: str, origin: str): + self.db_path = db_path + self.rp_id = rp_id + self.rp_name = rp_name + self.origin = origin + self.pending_register: dict[str, str] = {} + self.pending_auth: dict[str, str] = {} + self.db: dict[str, Any] = self._load_db() + + def _load_db(self) -> dict[str, Any]: + if not self.db_path.exists(): + return {"users": {}} + with self.db_path.open("r", encoding="utf-8") as f: + return json.load(f) + + def save_db(self) -> None: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + with self.db_path.open("w", encoding="utf-8") as f: + json.dump(self.db, f, indent=2) + + def get_user(self, username: str) -> dict[str, Any]: + users = self.db.setdefault("users", {}) + return users.setdefault(username, {"credentials": []}) + + +def html_page() -> str: + return """ + + + + + ChromeCard WebAuthn Local Demo + + + +

ChromeCard WebAuthn Demo

+

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

+
+ + + +
+

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