Initial commit: chromecard workspace snapshot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-04-29 22:06:14 +02:00
commit 83a6382270
48 changed files with 10949 additions and 0 deletions

28
.gitignore vendored Executable file
View File

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

109
CLAUDE.md Normal file
View File

@ -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 <file> <host>:~` and run via `ssh <host> <cmd>`.
## 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, 332 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.

240
PHASE5_RUNBOOK.md Normal file
View File

@ -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:<proxy-port>/session/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice"}'
```
Copy `session_token` from response, then:
```bash
TOKEN='<paste-token>'
```
Check session:
```bash
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/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:<proxy-port>/resource/counter \
-H "Authorization: Bearer $TOKEN"
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/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:<proxy-port>/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:<proxy-port>/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.

784
Setup.md Normal file
View File

@ -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 <host> <cmd>`
- file copy to VM home: `scp <file> <host>:~`
- 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 <target> 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=<name>`
- 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=<name>`
- `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).

654
Workplan.md Normal file
View File

@ -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 <host> <cmd>` and `scp <file> <host>:~`.
## 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":"<b64>","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`)

74
ctaphid_init_probe.py Normal file
View File

@ -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())

138
fido2_probe.py Executable file
View File

@ -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())

157
generate_phase2_certs.py Normal file
View File

@ -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())

850
k_client_portal.py Normal file
View File

@ -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 = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Client Flow</title>
<style>
:root {
--bg: #f3efe8;
--panel: #fffdf8;
--ink: #181614;
--muted: #655f56;
--line: #d9cfbf;
--accent: #0c6a60;
--accent-2: #8a5b2b;
--ok: #17653c;
--warn: #8f5b00;
--bad: #8a1f28;
--shadow: rgba(55, 41, 19, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
background:
radial-gradient(circle at top left, rgba(12,106,96,0.12), transparent 34%),
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
color: var(--ink);
}
main {
max-width: 980px;
margin: 0 auto;
padding: 32px 20px 56px;
}
.hero, .panel {
padding: 22px 24px;
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
box-shadow: 0 18px 40px var(--shadow);
}
.hero {
margin-bottom: 18px;
}
h1 {
margin: 0 0 8px;
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
max-width: 62ch;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.9fr);
gap: 18px;
align-items: start;
}
.stack {
display: grid;
gap: 18px;
}
.actions, .row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.actions {
margin-top: 18px;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
color: var(--ink);
}
label {
display: grid;
gap: 6px;
margin-top: 14px;
color: var(--muted);
font-size: 0.95rem;
}
button {
text-decoration: none;
border: 0;
padding: 10px 14px;
font: inherit;
color: #fff;
background: var(--accent);
cursor: pointer;
}
button.secondary { background: var(--accent-2); }
button.ghost {
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.status {
display: grid;
gap: 12px;
}
.status-card {
padding: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.86);
}
.status-card h2 {
margin: 0 0 6px;
font-size: 1rem;
}
.status-line {
font-size: 0.95rem;
color: var(--muted);
}
#usersList {
display: grid;
gap: 8px;
margin-top: 12px;
}
.user-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.86);
}
.user-meta {
display: grid;
gap: 2px;
}
.user-name {
font-weight: 600;
}
.user-subtle {
color: var(--muted);
font-size: 0.9rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.small {
padding: 8px 10px;
font-size: 0.92rem;
}
.badge {
display: inline-block;
padding: 4px 8px;
border: 1px solid var(--line);
font-size: 0.86rem;
background: #fff;
color: var(--ink);
margin-right: 6px;
margin-bottom: 6px;
}
.timeline {
display: grid;
gap: 10px;
margin-top: 16px;
}
.step {
display: grid;
grid-template-columns: 32px 1fr;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
}
.step-index {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
font-size: 0.88rem;
}
.hint {
margin-top: 14px;
padding: 12px 14px;
border-left: 4px solid var(--accent-2);
background: rgba(138,91,43,0.08);
color: var(--ink);
font-size: 0.95rem;
}
pre {
margin: 0;
padding: 16px;
overflow: auto;
border: 1px solid var(--line);
background: #16130f;
color: #efe7da;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
line-height: 1.45;
min-height: 360px;
}
@media (max-width: 860px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard Client Flow</h1>
<p class="subtitle">
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.
</p>
</section>
<div class="grid">
<section class="stack">
<section class="panel">
<div class="row">
<span class="badge">Browser: k_client</span>
<span class="badge">Card: k_proxy</span>
<span class="badge">Resource: k_server</span>
</div>
<label>
Username
<input id="username" value="directtest" autocomplete="off">
</label>
<div class="actions">
<button id="registerBtn">Register User</button>
<button id="loginBtn">Login</button>
<button id="counterBtn">Call k_server</button>
<button id="logoutBtn" class="secondary">Logout</button>
<button id="runFlowBtn" class="ghost">Run Full Flow</button>
<button id="refreshBtn" class="ghost">Refresh State</button>
</div>
<div class="hint" id="hintBox">
Registration: press <strong>yes</strong> on the card to enroll.
Login: press <strong>yes</strong> to allow the identity check, or
<strong>no</strong> to deny it. If login is denied, this page will
show that `k_server` was not called.
</div>
<div class="timeline">
<div class="step">
<div class="step-index">1</div>
<div>
<strong>Register user</strong><br>
Creates or refreshes the enrolled identity in `k_proxy`.
</div>
</div>
<div class="step">
<div class="step-index">2</div>
<div>
<strong>Authenticate with the card</strong><br>
`k_proxy` asks the card for approval. Press `yes` to continue or `no` to reject.
</div>
</div>
<div class="step">
<div class="step-index">3</div>
<div>
<strong>Call `k_server`</strong><br>
The protected counter is only reached when login created a valid session.
</div>
</div>
</div>
</section>
<section class="panel status">
<div class="status-card">
<h2>Client State</h2>
<div class="status-line" id="stateUser">Enrolled user: unknown</div>
<div class="status-line" id="stateSession">Session: unknown</div>
<div class="status-line" id="stateExpires">Expires: unknown</div>
</div>
<div class="status-card">
<h2>Registered Users</h2>
<div class="status-line" id="usersSummary">Loading users...</div>
<div id="usersList"></div>
</div>
<div class="status-card">
<h2>Flow Result</h2>
<div class="status-line" id="flowResult">No flow run yet.</div>
</div>
</section>
</section>
<section class="panel">
<h2 style="margin-top:0">Event Log</h2>
<pre id="log"></pre>
</section>
</div>
</main>
<script>
const logNode = document.getElementById("log");
const hintBox = document.getElementById("hintBox");
const flowResult = document.getElementById("flowResult");
const stateUser = document.getElementById("stateUser");
const stateSession = document.getElementById("stateSession");
const stateExpires = document.getElementById("stateExpires");
const usersSummary = document.getElementById("usersSummary");
const usersList = document.getElementById("usersList");
const usernameInput = document.getElementById("username");
const buttons = Array.from(document.querySelectorAll("button"));
function log(message, payload) {
const stamp = new Date().toLocaleTimeString();
let line = `[${stamp}] ${message}`;
if (payload !== undefined) {
line += "\\n" + JSON.stringify(payload, null, 2);
}
logNode.textContent = line + "\\n\\n" + logNode.textContent;
}
function setBusy(busy) {
for (const button of buttons) button.disabled = busy;
}
function username() {
return usernameInput.value.trim();
}
async function api(path, payload) {
const resp = await fetch(path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload || {})
});
const data = await resp.json();
return {status: resp.status, data};
}
async function refreshState() {
const resp = await fetch("/api/client/state");
const data = await resp.json();
stateUser.textContent = `Enrolled user: ${data.enrolled_username || "none"}`;
stateSession.textContent = `Session active: ${data.session_active ? "yes" : "no"}`;
stateExpires.textContent = `Expires: ${data.session_expires_at || "none"}`;
return data;
}
function renderUsers(users) {
usersList.innerHTML = "";
if (!users.length) {
usersSummary.textContent = "No registered users in k_proxy.";
return;
}
usersSummary.textContent = `${users.length} registered user${users.length === 1 ? "" : "s"} visible in k_proxy.`;
for (const user of users) {
const row = document.createElement("div");
row.className = "user-row";
const meta = document.createElement("div");
meta.className = "user-meta";
meta.innerHTML =
`<div class="user-name">${user.username}</div>` +
`<div class="user-subtle">Credential present: ${user.has_credential ? "yes" : "no"}</div>`;
const actions = document.createElement("div");
actions.className = "user-actions";
const useBtn = document.createElement("button");
useBtn.className = "ghost small";
useBtn.textContent = "Use";
useBtn.addEventListener("click", () => {
usernameInput.value = user.username;
flowResult.textContent = `Selected user ${user.username}.`;
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "secondary small";
deleteBtn.textContent = "Unregister";
deleteBtn.addEventListener("click", async () => {
setBusy(true);
try { await deleteUser(user.username); } finally { setBusy(false); }
});
actions.appendChild(useBtn);
actions.appendChild(deleteBtn);
row.appendChild(meta);
row.appendChild(actions);
usersList.appendChild(row);
}
}
async function refreshUsers() {
const resp = await fetch("/api/enrollments");
const data = await resp.json();
renderUsers(data.users || []);
return data;
}
async function registerUser() {
hintBox.innerHTML = "Card step: if the card shows a <strong>registration</strong> prompt, press <strong>yes</strong> to enroll this user.";
const result = await api("/api/enroll", {username: username()});
log("Register user", result);
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
await refreshState();
await refreshUsers();
return result;
}
async function loginUser() {
hintBox.innerHTML = "Card step: if the card shows an <strong>authentication</strong> prompt, press <strong>yes</strong> to allow login or <strong>no</strong> to deny it.";
const result = await api("/api/login", {username: username()});
log("Login", result);
await refreshState();
return result;
}
async function callCounter() {
const result = await api("/api/resource/counter", {});
log("Call k_server counter", result);
flowResult.textContent =
result.status === 200
? `k_server was reached. Counter value: ${result.data.upstream?.value}`
: "k_server was not reached successfully.";
return result;
}
async function logoutUser() {
const result = await api("/api/logout", {});
log("Logout", result);
flowResult.textContent = result.status === 200 ? "Session cleared." : "Logout failed.";
await refreshState();
return result;
}
async function deleteUser(usernameToDelete) {
const result = await api("/api/enroll/delete", {username: usernameToDelete});
log("Unregister user", result);
flowResult.textContent =
result.status === 200
? `User ${usernameToDelete} was unregistered.`
: `Could not unregister ${usernameToDelete}.`;
if (result.status === 200 && username() === usernameToDelete) {
usernameInput.value = "";
}
await refreshState();
await refreshUsers();
return result;
}
async function runFlow() {
setBusy(true);
flowResult.textContent = "Flow running...";
try {
const login = await loginUser();
if (login.status !== 200) {
flowResult.textContent = "Login denied or failed. `k_server` was not called.";
log("Flow stopped before k_server", {
reason: "login failed",
status: login.status,
response: login.data
});
return;
}
const counter = await callCounter();
if (counter.status === 200) {
flowResult.textContent = `Flow succeeded. k_server returned counter ${counter.data.upstream?.value}.`;
} else {
flowResult.textContent = "Login succeeded, but the protected k_server call failed.";
}
} finally {
setBusy(false);
}
}
document.getElementById("registerBtn").addEventListener("click", async () => {
setBusy(true);
try { await registerUser(); } finally { setBusy(false); }
});
document.getElementById("loginBtn").addEventListener("click", async () => {
setBusy(true);
try {
const result = await loginUser();
flowResult.textContent = result.status === 200 ? "Login succeeded. You can now call k_server." : "Login denied or failed. k_server was not called.";
} finally { setBusy(false); }
});
document.getElementById("counterBtn").addEventListener("click", async () => {
setBusy(true);
try { await callCounter(); } finally { setBusy(false); }
});
document.getElementById("logoutBtn").addEventListener("click", async () => {
setBusy(true);
try { await logoutUser(); } finally { setBusy(false); }
});
document.getElementById("runFlowBtn").addEventListener("click", runFlow);
document.getElementById("refreshBtn").addEventListener("click", async () => {
setBusy(true);
try {
const state = await refreshState();
const users = await refreshUsers();
log("State refreshed", {state, users});
} finally { setBusy(false); }
});
Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => {
log("Client flow page ready", {state, users});
});
</script>
</body>
</html>
"""
@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())

View File

@ -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"
}

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- USB host mode: required to open UsbDevice handles -->
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<!-- Foreground service for running proxy while screen is off -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- connectedDevice for real hardware; dataSync for emulator dev -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Network: TLS server on :8771 + outbound to k_server -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for foreground service notification on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="k_phone"
android:allowBackup="false"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config">
<!-- Flutter v2 embedding -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- USB device attach intent: auto-launch when ChromeCard plugged in -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_device_filter" />
</activity>
<!-- flutter_background_service foreground service type override -->
<!-- dataSync used for emulator dev; swap to connectedDevice on real hardware -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="dataSync"
tools:replace="android:foregroundServiceType" />
</application>
</manifest>

View File

@ -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);
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5L12,1zM12,11.99h7c-0.53,4.12 -3.28,7.79 -7,8.94V12H5V6.3l7,-3.11v8.8z"/>
</vector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext to the Mac host (emulator bridge) in debug builds -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- USB device filter: matches ChromeCard (VID=0x1209, PID=0x0005) -->
<resources>
<usb-device vendor-id="4617" product-id="5" />
<!-- vendor-id and product-id are decimal: 0x1209=4617, 0x0005=5 -->
</resources>

View File

@ -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
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

Binary file not shown.

View File

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

160
k_phone/android/gradlew vendored Executable file
View File

@ -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 "$@"

90
k_phone/android/gradlew.bat vendored Executable file
View File

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

View File

@ -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"

View File

@ -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<List<int>>? _emulatorSub;
final _emulatorRxBuf = <int>[];
Completer<void>? _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<bool> 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<bool>('openCard') ?? false;
} on MissingPluginException {
return false;
} on PlatformException catch (e) {
throw CtapHidException('openCard failed: ${e.message}');
}
}
/// Closes the card handle / TCP connection.
Future<void> closeCard() async {
if (_emulatorMode) {
_emulatorSub?.cancel();
_emulatorSub = null;
_emulatorRxBuf.clear();
_emulatorSocket?.destroy();
_emulatorSocket = null;
return;
}
try {
await _channel.invokeMethod<void>('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<bool> isCardAttached() async {
if (_emulatorMode) return _emulatorSub != null;
try {
return await _channel.invokeMethod<bool>('isCardAttached') ?? false;
} on MissingPluginException {
return false;
} on PlatformException {
return false;
}
}
/// Sends a CTAPHID INIT to allocate a channel, returns the allocated CID.
Future<int> 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<Uint8List> ctap2Cbor(int cid, Uint8List cbor) async {
return _ctaphidRoundtrip(cid, kCtaphidCbor, cbor);
}
/// Sends a CTAP1/U2F message and returns the response payload.
Future<Uint8List> ctap1Msg(int cid, Uint8List apdu) async {
return _ctaphidRoundtrip(cid, kCtaphidMsg, apdu);
}
// ---------------------------------------------------------------------------
// Internal: request/response
// ---------------------------------------------------------------------------
/// Full CTAPHID round-trip: fragment request, send, receive, reassemble.
Future<Uint8List> _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<void> _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<Uint8List> _sendPacket(Uint8List packet) async {
assert(packet.length == kHidPacketSize);
try {
final r = await _channel.invokeMethod<Uint8List>('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<Uint8List> _receivePacket() async {
if (_emulatorSub == null) throw CtapHidException('Emulator socket not open');
while (_emulatorRxBuf.length < kHidPacketSize) {
_emulatorRxWaiter = Completer<void>();
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<Uint8List> _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<Uint8List> _buildPackets({
required int cid,
required int cmd,
required Uint8List data,
}) {
final packets = <Uint8List>[];
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';
}

View File

@ -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 332 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<String, dynamic> toJson() => {
'username': username,
'display_name': displayName,
'created_at': createdAt,
'updated_at': updatedAt,
'user_id_b64': userIdB64,
'credential_data_b64': credentialDataB64,
};
factory Enrollment.fromJson(Map<String, dynamic> 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<String, Enrollment> _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<void>? _pending;
Future<void> _serialize(Future<void> Function() op) async {
final prev = _pending;
final next = _doAfter(prev, op);
_pending = next;
await next;
}
static Future<void> _doAfter(Future<void>? prev, Future<void> Function() op) async {
if (prev != null) {
try {
await prev;
} catch (_) {}
}
await op();
}
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
Future<File> _dbFile() async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/k_phone_enrollments.json');
}
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
try {
final f = await _dbFile();
if (!f.existsSync()) return;
final raw = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final users = raw['users'] as List? ?? [];
for (final item in users) {
final e = Enrollment.fromJson(item as Map<String, dynamic>);
if (e.username.isNotEmpty) _entries[e.username] = e;
}
} catch (_) {
_entries.clear();
}
}
Future<void> _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<void> ensureLoaded() async {
await _serialize(_load);
}
/// Register a new user. Throws [StateError] if already enrolled.
Future<Enrollment> 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<Enrollment> 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<Enrollment> 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<Enrollment?> 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<Enrollment>> list() async {
await ensureLoaded();
final result = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
return result;
}
}

335
k_phone/lib/fido2_ops.dart Normal file
View File

@ -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<MakeCredentialResult> 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<GetAssertionResult> 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<ECPublicKey>(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<int> 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));
}

View File

@ -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<KServerResponse> 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<List<int>>([], (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();
}

163
k_phone/lib/main.dart Normal file
View File

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'proxy_service.dart';
Future<void> 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<ProxyStatusScreen> createState() => _ProxyStatusScreenState();
}
class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
bool _serviceRunning = false;
bool _cardAttached = false;
String _statusMessage = 'Stopped';
final List<String> _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<void> _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),
],
);
}
}

View File

@ -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<bool> 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<void> initialize() async {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
const channel = AndroidNotificationChannel(
kNotificationChannelId,
kNotificationChannelName,
description: 'Shows when the ChromeCard proxy is running',
importance: Importance.low,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.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<void> 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<void> stop() async {
_running = false;
await _server?.close(force: true);
await closeCard();
_emit('Stopped');
}
// -------------------------------------------------------------------------
// Request dispatch
// -------------------------------------------------------------------------
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _handleEnrollList(HttpRequest req) async {
final users = await _db.list();
await _send(req.response, 200, {
'ok': true,
'users': users.map(_enrollmentPayload).toList(),
});
}
// -------------------------------------------------------------------------
// Session endpoints
// -------------------------------------------------------------------------
Future<void> _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<void> _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<void> _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<void> _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<String, dynamic> upstream;
try {
upstream = jsonDecode(utf8.decode(result.body)) as Map<String, dynamic>;
} catch (_) {
upstream = {};
}
await _send(req.response, 200, {
'ok': true,
'username': session.username,
'session_reused': true,
'upstream': upstream,
});
}
// -------------------------------------------------------------------------
// Health + HTML
// -------------------------------------------------------------------------
Future<void> _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<void> _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<void> _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<Map<String, dynamic>?> _readJson(HttpRequest req) async {
try {
final bytes = await req.fold<List<int>>([], (acc, chunk) => acc..addAll(chunk));
if (bytes.isEmpty) return {};
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
} catch (_) {
await _send(req.response, 400, {'ok': false, 'error': 'invalid json'});
return null;
}
}
Future<void> _drainBody(HttpRequest req) async {
await req.fold<void>(null, (_, __) {});
}
Future<void> _send(HttpResponse res, int status, Map<String, dynamic> 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<String, dynamic> _enrollmentPayload(Enrollment e, {bool? created}) {
final m = <String, dynamic>{
'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<SecurityContext> _loadTlsContext() async {
throw UnimplementedError('TLS cert loading not yet wired up');
}
}
// ---------------------------------------------------------------------------
// Portal HTML (mirrors k_proxy_app.py HTML)
// ---------------------------------------------------------------------------
const String _kPortalHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard k_phone Portal</title>
<style>
:root {
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
.hero { padding: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.card { padding: 18px; }
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
button.secondary { background: var(--accent-2); }
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard k_phone Portal</h1>
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Counter</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/resource/counter",{},true);log("Counter",data);}catch(err){log("Counter failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';

View File

@ -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<String, SessionEntry> _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();
}
}

581
k_phone/pubspec.lock Normal file
View File

@ -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"

27
k_phone/pubspec.yaml Normal file
View File

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

View File

@ -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<CborMap>());
final m = decoded as CborMap;
final hash = m[CborSmallInt(1)];
expect(hash, isA<CborBytes>());
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);
});
});
}

View File

@ -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));
}

1350
k_proxy_app.py Normal file

File diff suppressed because it is too large Load Diff

128
k_server_app.py Normal file
View File

@ -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())

78
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

12
package.json Normal file
View File

@ -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"
}
}

230
phase5_chain_regression.sh Executable file
View File

@ -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 <<EOF
Starting interactive login for ${USERNAME}.
When the card shows the authentication prompt, press yes to approve.
Press no only if you want to reject the login.
EOF
fi
ssh \
-F "${SSH_CONFIG}" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o ConnectTimeout="${CONNECT_TIMEOUT}" \
"${CLIENT_HOST}" \
env \
CA_FILE="${CA_FILE}" \
PROXY_URL="${PROXY_URL}" \
USERNAME="${USERNAME}" \
REQUESTS="${REQUESTS}" \
PARALLELISM="${PARALLELISM}" \
LOGIN_TIMEOUT="${LOGIN_TIMEOUT}" \
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE}" \
python3 - <<'PY'
import concurrent.futures
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
ca_file = os.environ["CA_FILE"]
proxy_url = os.environ["PROXY_URL"].rstrip("/")
username = os.environ["USERNAME"]
requests = int(os.environ["REQUESTS"])
parallelism = int(os.environ["PARALLELISM"])
login_timeout = int(os.environ["LOGIN_TIMEOUT"])
expect_auth_mode = os.environ["EXPECT_AUTH_MODE"]
if requests < 1:
raise SystemExit("REQUESTS must be >= 1")
if parallelism < 1:
raise SystemExit("PARALLELISM must be >= 1")
ctx = ssl.create_default_context(cafile=ca_file)
def post_json(path: str, payload: dict | None = None, token: str | None = None, 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

View File

@ -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())

18
playwright.config.js Normal file
View File

@ -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"]],
});

321
raw_ctap_probe.py Normal file
View File

@ -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())

339
tests/card_emulator.py Normal file
View File

@ -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)

View File

@ -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()

View File

@ -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);
});
});
});

1048
tests/test_k_proxy.py Normal file

File diff suppressed because it is too large Load Diff

389
webauthn_local_demo.py Normal file
View File

@ -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 """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ChromeCard WebAuthn Local Demo</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: 0.5rem; }
.row { display: flex; gap: 0.5rem; margin: 0.8rem 0; }
input { flex: 1; padding: 0.55rem; border: 1px solid #c8c8c8; border-radius: 8px; }
button { padding: 0.55rem 0.8rem; border: 1px solid #444; border-radius: 8px; background: #fff; cursor: pointer; }
pre { background: #111; color: #ddd; padding: 1rem; border-radius: 10px; overflow: auto; min-height: 200px; }
.muted { color: #555; }
</style>
</head>
<body>
<h1>ChromeCard WebAuthn Demo</h1>
<p class="muted">Use this page to test local FIDO2 register/login over USB.</p>
<div class="row">
<input id="username" value="alice" />
<button id="registerBtn">Register</button>
<button id="loginBtn">Login</button>
</div>
<pre id="log"></pre>
<script>
const log = (obj) => {
const el = document.getElementById("log");
const text = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
el.textContent = text + "\\n" + el.textContent;
};
const toB64u = (bytes) => {
let str = "";
const arr = new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) str += String.fromCharCode(arr[i]);
return btoa(str).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/g, "");
};
const fromB64u = (s) => {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out.buffer;
};
async function postJson(path, body) {
const resp = await fetch(path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
const data = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(data));
return data;
}
async function register() {
const username = document.getElementById("username").value.trim();
const start = await postJson("/register/start", {username});
const pk = start.publicKey;
pk.challenge = fromB64u(pk.challenge);
pk.user.id = fromB64u(pk.user.id);
const cred = await navigator.credentials.create({ publicKey: pk });
const body = {
username,
id: cred.id,
rawId: toB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: toB64u(cred.response.clientDataJSON),
attestationObject: toB64u(cred.response.attestationObject),
}
};
const finish = await postJson("/register/finish", body);
log({registerResult: finish});
}
async function login() {
const username = document.getElementById("username").value.trim();
const start = await postJson("/auth/start", {username});
const pk = start.publicKey;
pk.challenge = fromB64u(pk.challenge);
pk.allowCredentials = pk.allowCredentials.map(c => ({...c, id: fromB64u(c.id)}));
const assertion = await navigator.credentials.get({ publicKey: pk });
const body = {
username,
id: assertion.id,
rawId: toB64u(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: toB64u(assertion.response.clientDataJSON),
authenticatorData: toB64u(assertion.response.authenticatorData),
signature: toB64u(assertion.response.signature),
userHandle: assertion.response.userHandle ? toB64u(assertion.response.userHandle) : null
}
};
const finish = await postJson("/auth/finish", body);
log({authResult: finish});
}
document.getElementById("registerBtn").addEventListener("click", () => {
register().catch((e) => log("register error: " + e.message));
});
document.getElementById("loginBtn").addEventListener("click", () => {
login().catch((e) => log("login error: " + e.message));
});
</script>
</body>
</html>
"""
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())