From 83a6382270e42a0d61fb0d7e2a51f45139b117cc Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Wed, 29 Apr 2026 22:06:14 +0200 Subject: [PATCH] Initial commit: chromecard workspace snapshot Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 28 + CLAUDE.md | 109 ++ PHASE5_RUNBOOK.md | 240 +++ Setup.md | 784 ++++++++++ Workplan.md | 654 ++++++++ ctaphid_init_probe.py | 74 + fido2_probe.py | 138 ++ generate_phase2_certs.py | 157 ++ k_client_portal.py | 850 +++++++++++ k_phone/android/app/build.gradle | 57 + .../android/app/src/main/AndroidManifest.xml | 62 + .../plugins/GeneratedPluginRegistrant.java | 39 + .../com/chromecard/kphone/MainActivity.kt | 225 +++ .../main/res/drawable/ic_bg_service_small.xml | 11 + .../app/src/main/res/values/styles.xml | 9 + .../main/res/xml/network_security_config.xml | 7 + .../src/main/res/xml/usb_device_filter.xml | 6 + k_phone/android/build.gradle | 18 + k_phone/android/gradle.properties | 3 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + k_phone/android/gradlew | 160 ++ k_phone/android/gradlew.bat | 90 ++ k_phone/android/settings.gradle | 25 + k_phone/lib/ctaphid_channel.dart | 355 +++++ k_phone/lib/enrollment_db.dart | 253 +++ k_phone/lib/fido2_ops.dart | 335 ++++ k_phone/lib/k_server_client.dart | 80 + k_phone/lib/main.dart | 163 ++ k_phone/lib/proxy_service.dart | 652 ++++++++ k_phone/lib/session_manager.dart | 59 + k_phone/pubspec.lock | 581 +++++++ k_phone/pubspec.yaml | 27 + k_phone/test/fido2_test.dart | 167 ++ k_phone/test/widget_test.dart | 6 + k_proxy_app.py | 1350 +++++++++++++++++ k_server_app.py | 128 ++ package-lock.json | 78 + package.json | 12 + phase5_chain_regression.sh | 230 +++ phase65_concurrency_probe.py | 188 +++ playwright.config.js | 18 + raw_ctap_probe.py | 321 ++++ tests/card_emulator.py | 339 +++++ tests/card_emulator_bridge.py | 349 +++++ tests/k_client_portal.spec.js | 70 + tests/test_k_proxy.py | 1048 +++++++++++++ webauthn_local_demo.py | 389 +++++ 48 files changed, 10949 insertions(+) create mode 100755 .gitignore create mode 100644 CLAUDE.md create mode 100644 PHASE5_RUNBOOK.md create mode 100644 Setup.md create mode 100644 Workplan.md create mode 100644 ctaphid_init_probe.py create mode 100755 fido2_probe.py create mode 100644 generate_phase2_certs.py create mode 100644 k_client_portal.py create mode 100644 k_phone/android/app/build.gradle create mode 100644 k_phone/android/app/src/main/AndroidManifest.xml create mode 100644 k_phone/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java create mode 100644 k_phone/android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt create mode 100644 k_phone/android/app/src/main/res/drawable/ic_bg_service_small.xml create mode 100644 k_phone/android/app/src/main/res/values/styles.xml create mode 100644 k_phone/android/app/src/main/res/xml/network_security_config.xml create mode 100644 k_phone/android/app/src/main/res/xml/usb_device_filter.xml create mode 100644 k_phone/android/build.gradle create mode 100644 k_phone/android/gradle.properties create mode 100755 k_phone/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 k_phone/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 k_phone/android/gradlew create mode 100755 k_phone/android/gradlew.bat create mode 100644 k_phone/android/settings.gradle create mode 100644 k_phone/lib/ctaphid_channel.dart create mode 100644 k_phone/lib/enrollment_db.dart create mode 100644 k_phone/lib/fido2_ops.dart create mode 100644 k_phone/lib/k_server_client.dart create mode 100644 k_phone/lib/main.dart create mode 100644 k_phone/lib/proxy_service.dart create mode 100644 k_phone/lib/session_manager.dart create mode 100644 k_phone/pubspec.lock create mode 100644 k_phone/pubspec.yaml create mode 100644 k_phone/test/fido2_test.dart create mode 100644 k_phone/test/widget_test.dart create mode 100644 k_proxy_app.py create mode 100644 k_server_app.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 phase5_chain_regression.sh create mode 100644 phase65_concurrency_probe.py create mode 100644 playwright.config.js create mode 100644 raw_ctap_probe.py create mode 100644 tests/card_emulator.py create mode 100644 tests/card_emulator_bridge.py create mode 100644 tests/k_client_portal.spec.js create mode 100644 tests/test_k_proxy.py create mode 100644 webauthn_local_demo.py 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 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 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())