Compare commits

..

No commits in common. "main" and "docs-maintenance" have entirely different histories.

52 changed files with 87 additions and 9025 deletions

16
.gitignore vendored Executable file → Normal file
View File

@ -10,19 +10,3 @@ 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

164
CLAUDE.md
View File

@ -1,164 +0,0 @@
# 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
### Target production architecture (4-device system)
Four physical devices: optional client computer, phone, chromecard, server.
**Devices:**
- **Client (optional):** Computer with Component 3 installed. No browser proxy configuration needed.
- **Phone:** Central hub. Runs Component 1 and Component 2, hosts registration page, connects to chromecard via USB or WiFi.
- **Chromecard:** FIDO2 hardware security module. All crypto happens on-card; private keys never leave. Two fingerprint types: *user* (login) and *admin* (registration/deletion).
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
**Components on the phone:**
- **Component 1 — Proxy + gating filter:** Listens on a local port. Receives requests from the phone's own browser and from external clients via Component 3. Binary decision per request: host is gated → forward to Component 2, receive WebAuthn token back, then call the endpoint with the token (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
- **Component 2 — WebAuthn client + URL recognition:** Receives requests from Component 1. Always returns a WebAuthn token to the caller — never calls endpoints itself. Detects registration-URL → triggers admin registration flow (admin fingerprint); other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, returns token to Component 1).
- **Registration page:** Local web app on phone. Requires admin fingerprint on the card for enrollment/deletion.
**Component 3 (on external client):**
- Installed on external client computers; replaces the old browser-proxy-configuration approach.
- Finds the phone on the network (currently via hardcoded IP+port — TODO: rendezvous mechanism).
- Forwards validation requests to Component 1, receives WebAuthn token back, calls the protected endpoint directly, and returns the response to the browser.
- Must be a compiled binary that runs without a specific runtime. Recommended: **Go** (single static binary, cross-platform). Alternative: Rust (stronger memory guarantees, higher implementation complexity).
**Three flows:**
- **Flow A (authenticated access — phone browser):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → token returned to Component 1 → Component 1 calls endpoint (TLS) → resource returned.
- **Flow A (authenticated access — external client):** Browser → Component 3 → Component 1 → Component 2 → Card (user fingerprint) → token returned to Component 1 → token returned to Component 3 → Component 3 calls endpoint (TLS) → resource returned to browser.
- **Flow B (registration):** Browser → Component 1 → Component 2 (detects registration URL) → Card (admin fingerprint) → user created/deleted on card.
- **Flow C (unauthenticated):** Host not gated → Component 1 forwards directly to internet via port 80 (unencrypted, bypasses Component 2 and card). By design for normal web traffic.
**Open architectural decisions:**
- PIN on card (in addition to biometrics) — not yet decided
- User database location: on-card only vs. external — not yet decided
- Network-level access control on registration page — not yet decided
- Rendezvous mechanism for Component 3 to discover the phone — not yet decided
- iOS requires a push-relay component (APNs) for background operation; Android does not — platform priority not yet decided
### Development topology (Qubes 3-VM)
**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`.
### k_phone Flutter app (Phase 9 — replaces k_proxy)
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → fetches bearer token from Component 2 (`POST /auth/get-token`), then calls endpoint directly with `Authorization: Bearer`; non-gated → direct to target. HTTPS CONNECT to gated host: relays CONNECT through Component 2 (session-gate check). Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), `/auth/get-token`, and CONNECT tunnels. Returns bearer token to caller via `/auth/get-token`; never calls endpoints itself. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally.
**`k_phone/lib/portal_html.dart`** — HTML string constants (`kPortalHtml`, `kEnrollHtml`) and pre-encoded byte lists (`kPortalHtmlBytes`, `kEnrollHtmlBytes`) for the portal and enrollment pages served by Component 2.
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests). `SessionManager.ttlSeconds` is the public TTL constant (300 s).
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.
**`k_phone/lib/ctaphid_channel.dart`** — CTAPHID framing over USB (Kotlin platform channel) or emulator bridge TCP socket (`card_emulator_bridge.py` on `10.0.2.2:8772`).
**`k_phone/lib/enrollment_db.dart`** — enrollment model + JSON persistence via path_provider.
**Tests:** `flutter test test/filter_proxy_test.dart` and `flutter test test/enrollment_test.dart` (no device needed).
## 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.
- Phase 7 (firmware build): blocked on Chrome Roads (card vendor).
- Phase 9 (phone): Component 2 CONNECT handler implemented. HTTPS tunnels to gated hosts are gated by `hasAnyActiveSession()`; the tunnel connects to the actual upstream target (not k_server).

113
Setup.md
View File

@ -1,6 +1,6 @@
# Setup
Last updated: 2026-05-08
Last updated: 2026-04-26
This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`.
Update this file whenever environment status or verified behavior changes.
@ -611,23 +611,6 @@ Session note (2026-04-25, direct FIDO2 auth attempt):
- the deployed `k_proxy` service was restored to default `probe` mode
- verified `alice` login still works afterward, so the validated Phase 5 baseline remains intact
Session note (2026-04-27, fido2-direct end-to-end browser validation):
- Deployed all three services (k_server, k_proxy, k_client_portal) in split-VM chain via SSH/SCP.
- k_proxy restarted with --auth-mode fido2-direct.
- Full browser flow verified from k_client at http://127.0.0.1:8766 with real card:
- Register: makeCredential triggered on card, button press confirmed.
- Login: getAssertion triggered on card, button press confirmed.
- Counter: k_server returned incremented value.
- Logout: session correctly invalidated.
- Confirmed: probe mode showed stale directtest enrollment (no credential_data_b64) from earlier session; that is expected.
- Bug found and fixed: clicking Register after Login cleared the client-side session token but left the server-side session alive; fix adds a best-effort /session/logout call to k_proxy before re-enrolling.
- Current deployed service state:
- k_server: https://127.0.0.1:8780, TLS, proxy-token dev-proxy-token
- k_proxy: https://127.0.0.1:8771, TLS, --auth-mode fido2-direct, upstream https://127.0.0.1:9780
- k_client: http://127.0.0.1:8766, proxy-base-url https://127.0.0.1:9771
- Forwards: k_proxy 9780->k_server:8780, k_client 9771->k_proxy:8771
- Unit test suite added: tests/test_k_proxy.py (100 tests, all passing, run locally with python3 -m unittest tests/test_k_proxy.py).
Session note (2026-04-26, markdown maintenance re-scan):
- Re-read the maintained workspace markdown set:
- `/home/user/chromecard/Setup.md`
@ -648,100 +631,6 @@ Session note (2026-04-26, markdown maintenance re-scan):
- direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime
- the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up
Session note (2026-04-27, card emulator and bug fixes):
- Added software emulator of the ChromeCard FIDO2 authenticator:
- `/home/user/chromecard/tests/card_emulator.py`
- implements `make_credential` and `get_assertion` with real P-256 cryptography
- in-memory credential store keyed by credential ID (matching firmware layout)
- auth_data byte layout and COSE key encoding mirror `fido_make_cred.c` / `fido_get_assertion.c` exactly
- `user_confirms=True/False` parameter simulates the card's Yes/No confirmation dialog
- `refusing()` method returns a wrapper that forces `user_confirms=False` for integration test paths
- `forget_user(username)` simulates card-side credential removal
- module docstring is the usage guide
- Fixed two bugs in `k_proxy_app.py` that were silently breaking fido2-direct mode:
- `RegistrationResponse(id=..., ...)``RegistrationResponse(raw_id=..., ...)` (fido2 2.2.0 API)
- `AuthenticationResponse(id=..., ...)``AuthenticationResponse(raw_id=..., ...)` (same)
- both calls raised `TypeError` at runtime, caught by the surrounding `except`, so register and
authenticate in fido2-direct mode always returned failure without any visible error
- Extended test suite: 22 new tests across `TestCardEmulatorUnit` and `TestCardEmulatorIntegration`
- covers: register, authenticate, user-says-no (register and auth), forget, two-user isolation,
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
- total test count is now 122, all passing locally without card or VMs
Session note (2026-05-09, Android Playwright tests passing):
- All 4 tests in `tests/k_phone_android.spec.js` now pass (16 s total on emulator).
- Root cause 1: `playwright.android.devices()[0].launchBrowser()` hangs indefinitely on Chrome 145 in the emulator. Replaced with: write `--proxy-server=127.0.0.1:8888` to `/data/local/tmp/chrome-command-line` via adb, force-stop + restart Chrome, forward `tcp:9222 localabstract:chrome_devtools_remote`, and connect via `chromium.connectOverCDP()`. CDP becomes ready within 23 s; a polling retry loop (max 15 s) handles variance.
- Root cause 2: `card_emulator_bridge.py` TCP socket in the Flutter app becomes stale when the bridge process is restarted. `_cardAttached` and `_cardCid` remained set in `proxy_service.dart` even after the Dart socket `onDone` fired. Added `_ensureCardOpen()` in `proxy_service.dart`, called before `makeCredential` (enrollment) and `getAssertion` (login and `/auth/get-token`). The method calls `isCardAttached()` and, if the socket is closed, re-runs `_tryOpenCard()` to reconnect.
- Global Playwright test timeout reduced from 180 s to 60 s in `playwright.config.js`. No test should need more than 60 s (FIDO2 assertion via CardEmulator bridge is instant).
- `adb` path: discovered at `~/Library/Android/sdk/platform-tools/adb` (not in system PATH). The spec file now auto-detects it without requiring a modified PATH.
- `card_emulator_bridge.py` must be running before the first card operation. The bridge does not need restarting between test runs — `_ensureCardOpen()` in the Flutter app reconnects automatically after a bridge restart.
Session note (2026-05-08, per-request token binding + Playwright acceptance tests):
- Per-request FIDO2 token binding implemented across the full stack:
- `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge.
- `k_phone/lib/proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, returns a self-contained assertion bundle as base64url Bearer token. No session created.
- `k_phone/lib/filter_proxy.dart`: `_getAuthToken(uri, method)` generates a 16-byte secure nonce, POSTs `{url, method, nonce}` to Component 2.
- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session cache, no mutex.
- `k_server_app.py`: `_verify_assertion_token()` added — verifies path+method, challenge, and ECDSA-P256 signature from the self-contained bundle. `_is_proxy_authorized()` accepts legacy `X-Proxy-Token` or `Authorization: Bearer <bundle>`.
- Test coverage added:
- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit + 5 CardEmulator round-trips. All pass.
Run: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography python3 -m unittest tests/test_k_server.py`
- `k_phone/test/filter_proxy_test.dart`: 2 new tests verify `/auth/get-token` body fields. 48/48 pass.
- Playwright acceptance tests added (three specs, all in `tests/`):
- `k_phone_portal.spec.js`: portal UI flow — enroll → login → status → list → logout → delete. DOM assertions only; no phone screen needed.
Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js`
- `k_phone_proxy.spec.js`: 4 serial proxy-routing tests using Node `http` module.
1. No users → non-gated passes. 2. No users → gated rejected (407). 3. Enroll (card) → non-gated still passes. 4. Gated succeeds with card assertion (200 + Bearer token in response).
Run: `K_PHONE_PROXY=http://127.0.0.1:8888 K_PHONE_BASE_URL=http://127.0.0.1:8771 npx playwright test tests/k_phone_proxy.spec.js` (requires `adb forward tcp:8888 tcp:8888 && adb forward tcp:8771 tcp:8771`)
- `k_phone_android.spec.js`: same 4 tests but Chrome runs inside the Android emulator via Playwright Android (`playwright.android.devices()`). No adb port-forward needed — `127.0.0.1:8888` is Component 1 from inside the emulator. Auto-skips if no ADB device found.
Prerequisite: `npm install playwright` + card_emulator_bridge.py running.
Run: `npx playwright test tests/k_phone_android.spec.js [--headed]`
- card_emulator_bridge.py auto-approves all FIDO2 operations instantly — no physical fingerprint or card needed for emulator tests. The `CARD_REGISTRATION_TIMEOUT_MS` / `CARD_LOGIN_TIMEOUT_MS` timeouts exist only for physical ChromeCard use.
- Flutter analyze: no issues. `go build ./...`: clean. 48/48 Flutter tests pass.
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.

View File

@ -1,6 +1,6 @@
# Workplan
Last updated: 2026-05-08
Last updated: 2026-04-26
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
@ -527,230 +527,43 @@ Status (2026-04-25, dom0 policy fix validated):
Exit criteria:
- New team member can follow docs end-to-end without path or tooling ambiguity.
## Phase 9: Migrate to Phone-Mediated Wireless Validation
## Phase 9: Migrate to Phone-Mediated Wireless Validation (Future)
Status (2026-05-04): **ACTIVE — Architecture v2 adopted; Component 1 + Component 2 CONNECT handler complete**
1. Auth transport abstraction in `k_proxy`.
- Introduce/keep a transport interface for authenticator operations.
- Implement at least two backends:
- USB-direct backend (current).
- Phone-wireless backend (future).
### Architecture v2 changes (2026-05-04)
2. Wireless phone integration.
- Define protocol between `k_proxy` and phone service.
- Define secure pairing/authentication and message integrity for wireless link.
- Add timeout/retry behavior and offline handling.
The following changes replace the v1 architecture. Source: `chromecard_arkitektur_v2.docx`.
3. Functional equivalence tests.
- Verify login/enrollment behavior is unchanged at API level for `k_client`.
- Verify session reuse still works and card prompts are not increased unexpectedly.
**Component 2 no longer calls endpoints:** Component 2 returns the WebAuthn token to whoever asked (Component 1). It is Component 1 that calls the endpoint with the token. This is the most important behavioral change.
**New Component 3 (external client):** A compiled binary (Go recommended, Rust alternative) installed on external client computers. Replaces the old browser-proxy-configuration approach. Tasks: find the phone (currently hardcoded IP+port — rendezvous TBD), forward validation requests to Component 1, receive token back, call the protected endpoint directly, return response to browser.
**Flow A splits into two paths:**
- Phone browser: Browser → Component 1 → Component 2 (returns token) → Component 1 calls endpoint → resource
- External client: Browser → Component 3 → Component 1 → Component 2 (returns token) → Component 1 → Component 3 calls endpoint → resource
**Platform note:** Android needs no extra infrastructure. iOS requires a push-relay (APNs) for background operation — platform priority is an open decision.
**New open decisions:** Rendezvous mechanism for Component 3; iOS vs Android priority.
**Architectural decision (2026-05-08) — token binding model:**
Current choice: per-request authentication. No session is opened. Each request to a gated resource requires a fresh FIDO2 assertion from the card, with the challenge bound to the specific request (URL + method + nonce). The server verifies that the assertion's challenge matches the resource being requested. A token cannot be replayed for a different resource.
Consequence: one card interaction per request. This is intentional for now.
May change to: session model (one card interaction opens a time-limited session for all gated resources). If changed, token must at minimum be bound to a specific server (audience) to prevent cross-server replay.
Trigger for revisiting: user experience — if per-request card interaction proves too slow or disruptive.
### Target architecture (v2)
Four physical devices: optional client computer, phone, chromecard, server.
**Phone components:**
- **Component 1 — Proxy + gating filter:** Receives requests from phone browser and from external clients via Component 3. Per-request: gated host → forward to Component 2, receive WebAuthn token back, call endpoint with token (TLS); non-gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
- **Component 2 — WebAuthn client + URL recognition:** Always returns token to caller, never calls endpoints itself. Detects registration URL → admin registration flow (admin fingerprint); other gated URLs → FIDO2 assertion flow (user fingerprint → token returned to Component 1).
- **Registration page:** Local web app on phone; admin fingerprint access control enforced by card.
- **Component 3 (external client):** Compiled binary, finds phone, relays auth through Component 1, calls endpoint with received token.
**Three flows:**
- **Flow A (phone browser):** Browser → Comp 1 → Comp 2 → card → token → Comp 1 → endpoint → resource
- **Flow A (external client):** Browser → Comp 3 → Comp 1 → Comp 2 → card → token → Comp 1 → Comp 3 → endpoint → resource
- **Flow B:** Browser → Comp 1 → Comp 2 (registration URL) → card (admin biometric) → enroll/delete user
- **Flow C:** Non-gated host → Comp 1 → internet port 80 (no TLS, no card)
**Open decisions:** PIN on card; user DB on-card vs. external; network-level access control on registration page; Component 3 rendezvous mechanism; iOS vs Android priority.
Development chain (Qubes): `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; `hasAnyActiveSession()` added for gated-proxy forwarding (personal-device model: any live session authorises gated traffic)
- `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
### Work completed (2026-05-02)
- `k_phone/lib/filter_proxy.dart`: Component 1 implemented — HTTP proxy with gating filter
- Plain HTTP to gated host: rewritten to relative path and forwarded to Component 2
- HTTPS CONNECT to gated host: CONNECT request relayed to Component 2; tunnel opened on 200, denied on 4xx
- All other traffic forwarded directly to target host
- Gated hosts file: `gated_hosts.txt` in app documents directory (one `host` or `host:port` per line)
- Default seeded with `httpbin.org` on first run
- `k_phone/test/filter_proxy_test.dart`: full test suite for Component 1 (gated matching, HTTP routing, CONNECT routing, edge cases)
- `k_phone/test/enrollment_test.dart`: full test suite for `EnrollmentDb` (register, list, delete, persistence, update)
### Work completed (2026-05-02, session 2)
- `k_phone/lib/proxy_service.dart`: `_handleConnect` added to `_ProxyServer`
- Dispatched from `_handleRequest` for `CONNECT` method
- Checks `_sessions.hasAnyActiveSession()` — returns 407 if no active session
- Extracts upstream host:port from `Host` header
- Opens TCP socket to upstream target (the real external server — httpbin.org, etc.)
- Detaches the HTTP socket (`detachSocket(writeHeaders: false)`) and writes `200 Connection Established` manually
- Pipes bytes bidirectionally: client ↔ upstream
- k_server is not involved in CONNECT tunnels; Component 2 connects directly to the real target
### 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
```
### Work completed (2026-05-05, v2 architecture refactor)
**k_phone (Dart):**
- `filter_proxy_test.dart`: rewritten for v2 semantics — gated HTTP now hits a mock endpoint with Bearer token, not Component 2 directly. 24/24 tests pass.
- `filter_proxy.dart`: extracted `_writeProxyHeaders` and `_forwardHttpRequest` helpers to eliminate ~30 lines of duplication between `_handleGatedHttp` and `_handleDirectHttp`; simplified `_handleDirectHttp` signature (redundant `host`/`port` params removed).
- `session_manager.dart`: added `static const int ttlSeconds = 300` (public); `_ttl` now references it.
- `portal_html.dart` (new): extracted 400-line HTML blobs (`kPortalHtml`, `kEnrollHtml`, `kPortalHtmlBytes`, `kEnrollHtmlBytes`) from `proxy_service.dart`.
- `proxy_service.dart`: imports `portal_html.dart`; removed `_kSessionTtlSeconds` constant (replaced with `SessionManager.ttlSeconds`); merged `_serveHtml`/`_serveEnrollHtml` into `_serveHtmlBytes(req, bytes)`; extracted `_parseUsername` and `_parseUsernameAndDisplay` helpers eliminating repeated validation boilerplate; removed dead `_loadTlsContext` stub; simplified `start()` TLS branch. File: 872 → 455 lines.
- `k_server_client.dart`: deleted (dead code — no longer imported anywhere).
**component3 (Go):**
- `gated.go`: `IsGated(host, port string)` — was `IsGated(host string)`. Was silently missing `host:port` entries in gated_hosts.txt. Now checks both bare hostname and `host:port`.
- `proxy.go`: `handleHTTP` extracts `port` from URL (defaults `"80"`), passes to `IsGated`; `handleConnect` passes `portStr` to `IsGated`.
- `phone.go`: added `getToken()` calling `/auth/get-token` — avoids FIDO2 card interaction if the phone already has an active session. `EnsureSession()` tries `getToken()` first, falls back to `login()`. Fixed `login()` JSON field: `expires_in``ttl_seconds` (actual server field name). `go build ./...` passes.
### Parallel-change note: Component 1 and Component 3 share the same proxy logic
Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) implement the same core behaviour: intercept HTTP/HTTPS traffic, decide per-request whether the target is gated, fetch a WebAuthn token if so, and call the endpoint directly with the token. Any structural change to one (new gating logic, token-binding changes, CONNECT handling, error semantics) will almost certainly need a corresponding change in the other. Treat them as a pair: when modifying Component 3, check Component 1 for the same fix, and vice versa.
### Work completed (2026-05-08, per-request token binding)
- `fido2_ops.dart`: `GetAssertionResult` now includes `clientDataJson`; `getAssertion()` accepts optional `challenge` param for binding.
- `proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, calls card (getAssertion), returns self-contained assertion bundle as base64url Bearer token. No session involved.
- `filter_proxy.dart`: `_getAuthToken(uri, method)` generates a secure 16-byte nonce, posts `{url, method, nonce}` to Component 2, uses returned assertion token directly.
- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session caching, no mutex, no expiry tracking.
- `component3/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`.
- `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user).
- `k_server_app.py`: `_verify_assertion_token()` added — decodes bundle, verifies path+method match, verifies challenge claim, verifies ECDSA-P256 signature over authData||clientDataHash using public key extracted from bundle's credentialData. `_is_proxy_authorized()` accepts either X-Proxy-Token (legacy k_proxy path) or Bearer assertion token.
- `filter_proxy_test.dart`: 2 new tests for `/auth/get-token` body fields (url, method, nonce). 48/48 tests pass.
- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit tests with synthetic P-256 keys, 5 round-trip tests via `CardEmulator`. All pass.
- 48/48 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues.
### Work completed (2026-05-08, Playwright acceptance tests for k_phone)
- `tests/k_phone_portal.spec.js` (new): Portal UI acceptance tests (enroll → login → status → list → logout → delete). DOM assertions against `#storedUser`, `#sessionActive`, `#log`. Also tests empty-username and unknown-user error paths.
- Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js`
- `tests/k_phone_proxy.spec.js` (new): Proxy routing acceptance tests. Four serial tests that prove Component 1's routing decisions:
1. No users → non-gated request passes through (< 500).
2. No users → gated request rejected with 407 (Component 2 has no enrolled user).
3. Register user (card fingerprint) → non-gated still passes through.
4. With enrolled user → gated request succeeds after card assertion (200); response body proves Bearer token was forwarded to target.
- Uses Node `http` module for proxy requests (absolute URI / proxy protocol).
- Uses Playwright `page` fixture for enrollment in test 3 (card interaction).
- `GATED_URL` defaults to `http://httpbin.org/get`; point at `http://k-server-ip:8780/resource/counter` (GATED_METHOD=POST) for full chain validation including token signature verification.
- Run: `K_PHONE_PROXY=http://phone-ip:8888 K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_proxy.spec.js`
### Work completed (2026-05-09, Android Playwright tests passing)
- `tests/k_phone_android.spec.js`: all 4 tests pass (16 s total). Two root causes fixed:
1. **`launchBrowser()` hangs on Chrome 145.** Replaced with: write proxy flag to `/data/local/tmp/chrome-command-line`, force-stop + restart Chrome, `adb forward tcp:9222 localabstract:chrome_devtools_remote`, `chromium.connectOverCDP()`. CDP polling loop handles startup variance (≤ 15 s).
2. **Stale emulator socket after bridge restart.** `proxy_service.dart`: added `_ensureCardOpen()` — checks `isCardAttached()` and re-runs `_tryOpenCard()` if the socket is closed. Called before `makeCredential` and `getAssertion` in all three handler paths (enroll, session login, `/auth/get-token`).
- `playwright.config.js`: global timeout reduced from 180 s → 60 s.
- `adb` auto-detected at `~/Library/Android/sdk/platform-tools/adb` without PATH changes.
- `card_emulator_bridge.py` is long-running; no restart needed between test runs.
### Next action
1. Deploy to a real Android phone with physical ChromeCard via USB
2. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
3. 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
Exit criteria:
- `k_proxy` can validate via wireless phone path with no client-facing API changes.
## 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.**
- Treat the default HTTPS split-VM chain as the stable baseline and keep validating it with `/home/user/chromecard/phase5_chain_regression.sh`.
- Push the next engineering cycle toward Phase 6.5 limits:
- reproduce and narrow the `~10` in-flight request ceiling on the browser-facing `k_client -> k_proxy` Qubes forward
- separate Qubes forwarding churn from app-level issues with targeted concurrency probes and log capture
- In parallel, decide whether `--auth-mode fido2-direct` is ready to become the default deployed path or should remain an optional/operator mode.
- Keep the regression helpers as the fast check that transport, auth, session reuse, and counter semantics still hold after each change.
Status (2026-04-26, markdown maintenance):
- Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files.
- Updated the plan to match the verified state:
- direct FIDO2 auth is no longer the primary blocker because register/login/logout already work in the experimental path
- the main open system limit is concurrency/fan-out on the Qubes-forwarded browser path
- the current planning split is now:
- baseline path: keep `probe` mode stable and reproducible
- follow-up path: decide whether to promote `fido2-direct`
## Inputs Expected During This Session

View File

@ -1,64 +0,0 @@
package main
import (
"bufio"
"os"
"strings"
"sync"
)
// GatedHosts is the set of hostnames that require FIDO2 authentication.
// Format matches k_phone's gated_hosts.txt: one "host" or "host:port" per line,
// lines starting with "#" and blank lines are ignored.
type GatedHosts struct {
mu sync.RWMutex
entries map[string]bool
}
// Load reads the gated hosts file. Missing file is not an error (empty list).
func (g *GatedHosts) Load(path string) error {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
entries := make(map[string]bool)
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Normalise: lowercase, strip any trailing port-free colon.
entries[strings.ToLower(line)] = true
}
g.mu.Lock()
g.entries = entries
g.mu.Unlock()
return sc.Err()
}
// Len returns the number of entries in the gated list.
func (g *GatedHosts) Len() int {
g.mu.RLock()
defer g.mu.RUnlock()
return len(g.entries)
}
// IsGated returns true if host:port matches a gated entry.
// An entry "example.com" matches any port; "example.com:8080" matches only port 8080.
func (g *GatedHosts) IsGated(host, port string) bool {
g.mu.RLock()
defer g.mu.RUnlock()
if len(g.entries) == 0 {
return false
}
h := strings.ToLower(host)
return g.entries[h] || (port != "" && g.entries[h+":"+port])
}

View File

@ -1,3 +0,0 @@
module github.com/chromecard/component3
go 1.22

View File

@ -1,57 +0,0 @@
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
listen := flag.String("listen", "127.0.0.1:9090", "local proxy address (configure browser to use this)")
phoneURL := flag.String("phone", "http://192.168.1.10:8771", "phone base URL (Component 1/2)")
gatedFile := flag.String("gated", "", "gated hosts file (default: ~/.config/component3/gated_hosts.txt)")
verbose := flag.Bool("v", false, "verbose logging")
flag.Parse()
cfgDir := defaultConfigDir()
if err := os.MkdirAll(cfgDir, 0700); err != nil {
log.Fatalf("cannot create config dir: %v", err)
}
if *gatedFile == "" {
*gatedFile = filepath.Join(cfgDir, "gated_hosts.txt")
}
gated := &GatedHosts{}
if err := gated.Load(*gatedFile); err != nil {
log.Printf("warning: gated hosts: %v (using empty list)", err)
} else {
log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile)
}
phone := NewPhoneClient(*phoneURL)
proxy := &Proxy{
phone: phone,
gated: gated,
verbose: *verbose,
}
log.Printf("listening on %s — configure browser HTTP proxy to this address", *listen)
server := &http.Server{
Addr: *listen,
Handler: proxy,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("proxy: %v", err)
}
}
func defaultConfigDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ".component3"
}
return filepath.Join(home, ".config", "component3")
}

View File

@ -1,68 +0,0 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
)
// PhoneClient fetches a per-request FIDO2 assertion token from Component 2.
// There is no session caching — each call triggers a card interaction.
type PhoneClient struct {
baseURL string
}
func NewPhoneClient(baseURL string) *PhoneClient {
return &PhoneClient{baseURL: baseURL}
}
// GetTokenForRequest calls /auth/get-token with url+method+nonce, triggering
// a fresh FIDO2 assertion on the card. Returns the self-contained assertion
// bundle that the endpoint can verify independently.
func (c *PhoneClient) GetTokenForRequest(rawURL, method string) (string, error) {
nonce, err := randomHex(16)
if err != nil {
return "", fmt.Errorf("nonce: %w", err)
}
body, _ := json.Marshal(map[string]string{
"url": rawURL,
"method": method,
"nonce": nonce,
})
resp, err := http.Post(c.baseURL+"/auth/get-token", "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("phone unreachable (%s): %w", c.baseURL, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result struct {
Ok bool `json:"ok"`
Token string `json:"token"`
Error string `json:"error"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", fmt.Errorf("parse token response: %w (body: %s)", err, raw)
}
if !result.Ok {
return "", fmt.Errorf("auth failed: %s", result.Error)
}
if result.Token == "" {
return "", fmt.Errorf("phone returned empty token")
}
return result.Token, nil
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@ -1,181 +0,0 @@
package main
import (
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"time"
)
// hopByHop headers that must not be forwarded by a proxy (RFC 7230 §6.1).
var hopByHopHeaders = map[string]bool{
"connection": true,
"keep-alive": true,
"proxy-authenticate": true,
"proxy-authorization": true,
"te": true,
"trailers": true,
"transfer-encoding": true,
"upgrade": true,
"proxy-connection": true, // non-standard but common
}
// Proxy is the HTTP/HTTPS proxy handler.
//
// For plain HTTP requests to gated hosts:
// browser → Proxy → (session token from phone) → endpoint directly → browser
//
// For HTTPS CONNECT to gated hosts:
// 407 — HTTPS interception is not supported; use plain HTTP for gated endpoints.
//
// For non-gated hosts:
// browser → Proxy → internet (transparent, no auth)
type Proxy struct {
phone *PhoneClient
gated *GatedHosts
verbose bool
}
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
p.handleConnect(w, r)
} else {
p.handleHTTP(w, r)
}
}
// handleHTTP handles plain HTTP proxy requests.
// For gated hosts: acquires a session token from the phone, adds it as
// Authorization: Bearer, then calls the endpoint directly.
func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Host == "" {
http.Error(w, "not a proxy request", http.StatusBadRequest)
return
}
host := r.URL.Hostname()
port := r.URL.Port()
if port == "" {
port = "80"
}
isGated := p.gated.IsGated(host, port)
p.logf("HTTP %s %s (gated=%v)", r.Method, r.URL, isGated)
// Build outgoing request. RequestURI must be empty for http.Client/RoundTrip.
out := r.Clone(r.Context())
out.RequestURI = ""
stripHopByHop(out.Header)
if isGated {
token, err := p.phone.GetTokenForRequest(r.URL.String(), r.Method)
if err != nil {
http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized)
return
}
out.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultTransport.RoundTrip(out)
if err != nil {
http.Error(w, "upstream: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
copyHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
// handleConnect handles HTTPS CONNECT tunnels.
// For gated hosts: does TLS MITM so Authorization can be injected into each
// inner HTTP request before it is forwarded to the actual server.
// For non-gated hosts: transparent byte-level tunnel.
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
host, portStr, err := net.SplitHostPort(r.Host)
if err != nil {
// No port — default to 443.
host = r.Host
portStr = "443"
}
if portStr == "" {
portStr = "443"
}
target := net.JoinHostPort(host, portStr)
isGated := p.gated.IsGated(host, portStr)
p.logf("CONNECT %s (gated=%v)", target, isGated)
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
log.Printf("hijack: %v", err)
return
}
if isGated {
fmt.Fprintf(clientConn, "HTTP/1.1 407 Proxy Authentication Required\r\nContent-Type: text/plain\r\nProxy-Authenticate: Bearer realm=\"chromecard\"\r\n\r\nHTTPS tunnels to gated hosts are not supported. Use plain HTTP.\r\n")
p.logf("CONNECT %s: gated HTTPS not supported, returned 407", target)
clientConn.Close()
} else {
p.handleDirectConnect(clientConn, target)
}
}
// handleDirectConnect tunnels bytes transparently — no auth, no inspection.
func (p *Proxy) handleDirectConnect(clientConn net.Conn, target string) {
defer clientConn.Close()
upConn, err := net.DialTimeout("tcp", target, 10*time.Second)
if err != nil {
fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
return
}
defer upConn.Close()
fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n")
pipe(clientConn, upConn)
}
// pipe copies bytes bidirectionally between two connections until either closes.
func pipe(a, b net.Conn) {
done := make(chan struct{}, 2)
go func() { io.Copy(a, b); done <- struct{}{} }()
go func() { io.Copy(b, a); done <- struct{}{} }()
<-done
}
// stripHopByHop removes hop-by-hop headers and any headers named in Connection.
func stripHopByHop(h http.Header) {
if conn := h.Get("Connection"); conn != "" {
for _, name := range strings.Split(conn, ",") {
h.Del(strings.TrimSpace(name))
}
}
for name := range hopByHopHeaders {
h.Del(name)
}
}
// copyHeaders copies non-hop-by-hop headers from src to dst.
func copyHeaders(dst, src http.Header) {
for k, vs := range src {
if !hopByHopHeaders[strings.ToLower(k)] {
for _, v := range vs {
dst.Add(k, v)
}
}
}
}
func (p *Proxy) logf(format string, args ...any) {
if p.verbose {
log.Printf(format, args...)
}
}

View File

@ -1,11 +1,9 @@
#!/usr/bin/env python3
"""
k_client_portal browser-facing portal running in k_client.
Minimal browser-facing client portal for Phase 6 bring-up.
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.
This runs in k_client, keeps a local preferred username, and talks to k_proxy
over the localhost-forwarded TLS endpoint.
"""
from __future__ import annotations
@ -563,25 +561,18 @@ class ClientState:
self.proxy_base_url = proxy_base_url.rstrip("/")
self.proxy_ca_file = proxy_ca_file
self.enroll_db = enroll_db
# Registration and login both require a physical card touch, which can
# take up to ~60 s in practice; 90 s gives a generous margin.
self.interactive_timeout_s = interactive_timeout_s
self.default_timeout_s = default_timeout_s
self.lock = threading.Lock()
self.preferred_enrollment: EnrollmentRecord | None = None
self.session_token: str | None = None
self.session_expires_at: int | None = None
# Build the TLS context once; creating it on every request is expensive
# and the CA file doesn't change at runtime.
self._ssl_ctx: ssl.SSLContext | None = (
ssl.create_default_context(cafile=self.proxy_ca_file)
if proxy_base_url.startswith("https://")
else None
)
self._load_preferred_enrollment()
def _ssl_context(self) -> ssl.SSLContext | None:
return self._ssl_ctx
def _ssl_context(self):
if self.proxy_base_url.startswith("https://"):
return ssl.create_default_context(cafile=self.proxy_ca_file)
return None
def _proxy_json(
self,
@ -635,12 +626,6 @@ class ClientState:
username = username.strip()
if not username:
return {"ok": False, "error": "username required"}
# Best-effort: invalidate any active session on k_proxy before re-enrolling.
# The new credential will differ from what the old session was issued for.
with self.lock:
old_token = self.session_token
if old_token:
self._proxy_json("POST", "/session/logout")
status, data = self._proxy_json(
"POST",
"/enroll/register",
@ -756,15 +741,6 @@ class Handler(BaseHTTPRequestHandler):
return {}
return json.loads(raw.decode("utf-8"))
def _require_json(self) -> dict[str, Any] | None:
# Returns None and sends 400 when the body is unparseable; the caller
# should return immediately without sending a second response.
try:
return self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return None
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/":
@ -785,22 +761,28 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/enroll":
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
result = self.state.enroll(str(data.get("username", "")))
self._json(200 if result.get("ok") else 400, result)
return
if path == "/api/login":
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
status, data = self.state.login(str(data.get("username", "")))
self._json(status, data)
return
if path == "/api/enroll/delete":
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
status, data = self.state.delete_enrollment(str(data.get("username", "")))
self._json(status, data)

View File

@ -1,57 +0,0 @@
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

@ -1,62 +0,0 @@
<?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

@ -1,44 +0,0 @@
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 dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", 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

@ -1,225 +0,0 @@
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

@ -1,11 +0,0 @@
<?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

@ -1,9 +0,0 @@
<?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

@ -1,7 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,18 +0,0 @@
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

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

Binary file not shown.

View File

@ -1,5 +0,0 @@
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

View File

@ -1,160 +0,0 @@
#!/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 "$@"

View File

@ -1,90 +0,0 @@
@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

@ -1,25 +0,0 @@
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

@ -1,123 +0,0 @@
// Integration test: registration and login flows on device.
// Runs EnrollmentDb and SessionManager directly; no card required.
//
// Run with: flutter test integration_test/ -d emulator-5554
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:k_phone/enrollment_db.dart';
import 'package:k_phone/session_manager.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Registration flow', () {
late EnrollmentDb db;
setUp(() {
db = EnrollmentDb();
});
testWidgets('register new user produces log entries', (tester) async {
debugPrint('[REGISTRATION] Starting: enrolling user "alice"');
final enrollment = await db.register(
username: 'alice',
displayName: 'Alice Testuser',
);
debugPrint('[REGISTRATION] SUCCESS: user="${enrollment.username}" '
'displayName="${enrollment.displayName}" '
'createdAt=${enrollment.createdAt} '
'hasCredential=${enrollment.hasCredential}');
expect(enrollment.username, equals('alice'));
expect(enrollment.displayName, equals('Alice Testuser'));
expect(enrollment.hasCredential, isFalse);
debugPrint('[REGISTRATION] Verifying user appears in list...');
final list = await db.list();
expect(list.any((e) => e.username == 'alice'), isTrue);
debugPrint('[REGISTRATION] OK: "alice" found in enrollment list (${list.length} total)');
debugPrint('[REGISTRATION] Verifying duplicate registration is rejected...');
try {
await db.register(username: 'alice');
fail('Expected StateError for duplicate username');
} on StateError catch (e) {
debugPrint('[REGISTRATION] OK: duplicate rejected — ${e.message}');
}
debugPrint('[REGISTRATION] COMPLETE');
});
testWidgets('delete user removes from enrollment list', (tester) async {
await db.register(username: 'bob', displayName: 'Bob Testuser');
debugPrint('[REGISTRATION] Enrolled "bob"');
final deleted = await db.delete('bob');
debugPrint('[REGISTRATION] DELETE: removed user="${deleted.username}"');
final found = await db.get('bob');
expect(found, isNull);
debugPrint('[REGISTRATION] OK: "bob" no longer in database');
});
});
group('Login flow', () {
final session = SessionManager();
testWidgets('issue and validate session token', (tester) async {
debugPrint('[LOGIN] Starting: issuing session for "alice"');
final token = session.issue('alice');
debugPrint('[LOGIN] Token issued: ${token.substring(0, 8)}... (${token.length} chars)');
final valid = session.isValid(token);
debugPrint('[LOGIN] Session valid: $valid');
expect(valid, isTrue);
final entry = session.getSession(token);
debugPrint('[LOGIN] Session entry: username="${entry?.username}" '
'expires=${entry?.expires.toIso8601String()}');
debugPrint('[LOGIN] COMPLETE');
});
testWidgets('revoke session invalidates token', (tester) async {
final token = session.issue('alice');
debugPrint('[LOGIN] Token issued: ${token.substring(0, 8)}...');
session.revoke(token);
debugPrint('[LOGIN] Token revoked');
final valid = session.isValid(token);
debugPrint('[LOGIN] Session valid after revoke: $valid');
expect(valid, isFalse);
debugPrint('[LOGIN] OK: revoked token correctly rejected');
});
testWidgets('revokeAll removes all sessions for user', (tester) async {
final t1 = session.issue('charlie');
final t2 = session.issue('charlie');
debugPrint('[LOGIN] Issued 2 sessions for "charlie"');
session.revokeAll('charlie');
debugPrint('[LOGIN] revokeAll("charlie") called');
expect(session.isValid(t1), isFalse);
expect(session.isValid(t2), isFalse);
debugPrint('[LOGIN] OK: both sessions for "charlie" invalidated');
});
testWidgets('unknown token is rejected', (tester) async {
debugPrint('[LOGIN] Testing unknown token...');
final valid = session.isValid('0000000000000000000000000000000000000000000000000000000000000000');
debugPrint('[LOGIN] Unknown token valid: $valid');
expect(valid, isFalse);
debugPrint('[LOGIN] OK: unknown token correctly rejected');
});
});
}

View File

@ -1,362 +0,0 @@
// 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.
// _emulatorRxWaiter is replaced on each call to _receivePacket so that
// concurrent waiters don't share a Completer and accidentally wake each other.
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.
// Limitation: keepalives and continuation packets after the last request
// packet call _receivePacket(), which only works in emulator mode.
// In practice this is safe because CTAP2 responses for typical credential
// sizes fit in a single init packet and the card does not send keepalives
// synchronously before the response to the last request packet.
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 = await _receivePacket(); // USB continuation unimplemented see _ctaphidRoundtrip
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

@ -1,259 +0,0 @@
// 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();
// 'enrolled_at' was the field name in the Python k_proxy JSON schema; accept both for portability.
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 {
// [baseDir] can be injected in tests to bypass path_provider.
EnrollmentDb({Directory? baseDir}) : _baseDir = baseDir;
final Directory? _baseDir;
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: each _serialize call chains its op
// onto _pending so concurrent callers queue up rather than interleave.
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 (_) {} // previous op error must not block the queue
}
await op();
}
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
Future<File> _dbFile() async {
final dir = _baseDir ?? 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; // no file = fresh install; start with empty DB
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(); // treat a corrupt/unreadable DB as empty; next save overwrites it
}
}
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;
}
}

View File

@ -1,347 +0,0 @@
// 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;
final String clientDataJson;
GetAssertionResult({
required this.authData,
required this.signature,
required this.clientDataHash,
required this.clientDataJson,
});
}
// ---------------------------------------------------------------------------
// 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({
// rk=false: non-resident credential ID is stored externally in EnrollmentDb
// rather than on the card, so multiple users can enroll on one card.
// uv=false: no PIN; authentication uses user-presence (fingerprint touch) only.
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.
/// [challenge] overrides the random challenge use for per-request token binding.
Future<GetAssertionResult> getAssertion(
int cid,
String credentialDataB64, {
Uint8List? challenge,
}) async {
final credData = _b64uDecode(credentialDataB64);
final credId = _extractCredentialId(credData);
final actualChallenge = challenge ?? _randomBytes(32);
final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge);
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), // require fingerprint touch (user presence)
CborString('uv'): CborBool(false), // no PIN
}),
});
// 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,
clientDataJson: clientDataJson,
);
}
// ---------------------------------------------------------------------------
// 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);
// CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON).
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');
// P-256 signatures are always 72 bytes, so the SEQUENCE length fits in one byte.
// Multi-byte BER length encoding (0x81/0x82 prefix) is not handled here.
var offset = 2; // skip 0x30 + one-byte 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) {
// base64url strips trailing '='; restore padding to the nearest multiple of 4.
final padded = s + '=' * ((4 - s.length % 4) % 4);
return Uint8List.fromList(base64Url.decode(padded));
}

View File

@ -1,575 +0,0 @@
// Component 1 HTTP proxy with URL gating filter (v2 architecture).
//
// Routing rule binary decision per request:
// gated host ask Component 2 for a bearer token (POST /auth/get-token),
// then call the endpoint directly with Authorization: Bearer.
// other host forward directly to the target host:port (no auth, port 80)
//
// For HTTPS (CONNECT) to gated hosts the CONNECT is still relayed through
// Component 2 (session-gate check), with Component 2 opening the upstream TCP
// connection. TODO: replace with local MITM so Component 2 never contacts
// endpoints directly.
//
// Gated hosts file (gated_hosts.txt in the app documents directory): one entry
// per line, either "host" or "host:port". Lines starting with "#" and blank
// lines are ignored.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path_provider/path_provider.dart';
const int kFilterProxyPort = 8888;
const int kComponent2Port = 8771;
const String _kGatedHostsFilename = 'gated_hosts.txt';
const int _kMaxHeaderBytes = 64 * 1024;
final _kBytesConnectOk = utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n');
class FilterProxy {
FilterProxy({
int listenPort = kFilterProxyPort,
int component2Port = kComponent2Port,
}) : _listenPort = listenPort,
_component2Port = component2Port;
final int _listenPort;
final int _component2Port;
final Set<String> _gatedHosts = {};
ServerSocket? _server;
void Function(String)? onLog;
// The actual bound port valid after start() returns.
int get port => _server?.port ?? _listenPort;
// Populate the gated hosts directly without file I/O.
// Call this before start() in tests; in production call loadGatedHosts() instead.
void setGatedEntries(Iterable<String> entries) {
_gatedHosts
..clear()
..addAll(entries.map((e) => e.trim().toLowerCase()));
}
// Exposed for unit tests.
bool isGatedForTest(String host, int port) => _isGated(host, port);
// Creates the default gated_hosts.txt (containing httpbin.org) if the file
// does not already exist. Call before loadGatedHosts() during startup.
Future<void> seedDefaultGatedHosts() async {
try {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$_kGatedHostsFilename');
if (!file.existsSync()) {
await file.writeAsString(
'# Gated hosts — traffic to these is forwarded through Component 2,\n'
'# which requires an active card session before relaying.\n'
'#\n'
'# One entry per line: "host" or "host:port".\n'
'httpbin.org\n',
);
_log('Created default $_kGatedHostsFilename with httpbin.org');
}
} catch (e) {
_log('Could not seed default $_kGatedHostsFilename: $e');
}
}
Future<void> loadGatedHosts() async {
_gatedHosts.clear();
try {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$_kGatedHostsFilename');
var count = 0;
for (final raw in await file.readAsLines()) {
final entry = raw.trim().toLowerCase();
if (entry.isEmpty || entry.startsWith('#')) continue;
_gatedHosts.add(entry);
count++;
}
_log('Gated hosts loaded: $count ${count == 1 ? 'entry' : 'entries'}');
} on FileSystemException {
_log('No $_kGatedHostsFilename — all traffic forwarded directly to target');
} catch (e) {
_log('Gated hosts load error: $e');
}
}
bool _isGated(String host, int port) {
final h = host.toLowerCase();
return _gatedHosts.contains(h) || _gatedHosts.contains('$h:$port');
}
// start() does NOT call loadGatedHosts(). Callers are responsible:
// production: await proxy.seedDefaultGatedHosts();
// await proxy.loadGatedHosts();
// await proxy.start();
// tests: proxy.setGatedEntries([...]); await proxy.start();
Future<void> start() async {
_server = await ServerSocket.bind(InternetAddress.anyIPv4, _listenPort);
_log('Filter proxy listening on :${_server!.port}');
_server!.listen(
(client) => _handle(client).catchError((e) {
_log('Connection error: $e');
try { client.destroy(); } catch (_) {}
}),
onError: (e) => _log('Server error: $e'),
);
}
Future<void> stop() async {
await _server?.close();
_server = null;
_log('Filter proxy stopped');
}
// ---------------------------------------------------------------------------
// Per-connection handler
// ---------------------------------------------------------------------------
Future<void> _handle(Socket client) async {
final buf = <int>[];
int headerEnd = -1;
late StreamSubscription<List<int>> sub;
final headersReady = Completer<void>();
sub = client.listen(
(data) {
if (headersReady.isCompleted) return;
buf.addAll(data);
if (buf.length > _kMaxHeaderBytes) {
sub.cancel();
headersReady.complete();
return;
}
// Scan only the overlap between previous and new data to avoid O(n²).
// Must look back 3 bytes into the previous chunk when scanning for \r\n\r\n.
final searchFrom = (buf.length - data.length - 3).clamp(0, buf.length);
for (int i = searchFrom; i <= buf.length - 4; i++) {
if (buf[i] == 13 && buf[i + 1] == 10 && buf[i + 2] == 13 && buf[i + 3] == 10) {
headerEnd = i;
break;
}
}
if (headerEnd >= 0) {
sub.pause();
headersReady.complete();
}
},
onError: (e) { if (!headersReady.isCompleted) headersReady.completeError(e); },
onDone: () { if (!headersReady.isCompleted) headersReady.complete(); },
cancelOnError: true,
);
try {
await headersReady.future.timeout(const Duration(seconds: 15));
} on TimeoutException {
sub.cancel();
client.destroy();
return;
}
if (headerEnd < 0) {
sub.cancel();
client.destroy();
return;
}
final headerText = String.fromCharCodes(buf.sublist(0, headerEnd));
final remainder = buf.sublist(headerEnd + 4);
final lines = headerText.split('\r\n');
final requestParts = lines[0].trim().split(' ');
if (requestParts.length < 2) {
sub.cancel();
client.destroy();
return;
}
final method = requestParts[0].toUpperCase();
final target = requestParts[1];
if (method == 'CONNECT') {
final (:host, :port) = _parseHostPort(target);
if (_isGated(host, port)) {
await _handleGatedConnect(client, sub, target, remainder);
} else {
await _handleDirectConnect(client, sub, host, port, remainder);
}
} else {
await _handleHttp(client, sub, method, target, lines.sublist(1), remainder);
}
}
// ---------------------------------------------------------------------------
// Direct CONNECT tunnel (non-gated hosts)
// ---------------------------------------------------------------------------
Future<void> _handleDirectConnect(
Socket client,
StreamSubscription<List<int>> sub,
String host,
int port,
List<int> remainder,
) async {
Socket upstream;
try {
upstream = await Socket.connect(host, port).timeout(const Duration(seconds: 10));
} catch (e) {
_deny(client, sub, 502, 'Bad Gateway');
return;
}
client.add(_kBytesConnectOk);
if (remainder.isNotEmpty) upstream.add(remainder);
upstream.listen(
client.add,
onDone: client.destroy,
onError: (_) { upstream.destroy(); client.destroy(); },
cancelOnError: true,
);
sub.onData(upstream.add);
sub.onDone(upstream.destroy);
sub.onError((_) { upstream.destroy(); client.destroy(); });
sub.resume();
}
// ---------------------------------------------------------------------------
// Gated CONNECT tunnel relay CONNECT request through Component 2
//
// We MUST NOT pipe raw TLS to Component 2's HttpServer. Instead we forward
// a CONNECT request to it, wait for its HTTP response (200 = auth OK,
// 403 = no active session, 502 = upstream error), and only then tell the
// browser whether the tunnel was established.
// ---------------------------------------------------------------------------
Future<void> _handleGatedConnect(
Socket client,
StreamSubscription<List<int>> sub,
String target,
List<int> remainder,
) async {
Socket comp2;
try {
comp2 = await Socket.connect('127.0.0.1', _component2Port)
.timeout(const Duration(seconds: 5));
} catch (e) {
_deny(client, sub, 502, 'Bad Gateway');
return;
}
// Forward the CONNECT request to Component 2.
comp2.add(utf8.encode('CONNECT $target HTTP/1.1\r\nHost: $target\r\n\r\n'));
// Read Component 2's response headers.
final respBuf = <int>[];
int respHeaderEnd = -1;
final respReady = Completer<void>();
late StreamSubscription<List<int>> comp2Sub;
comp2Sub = comp2.listen(
(data) {
if (respReady.isCompleted) return;
respBuf.addAll(data);
if (respBuf.length > _kMaxHeaderBytes) {
comp2Sub.pause();
respReady.complete();
return;
}
final searchFrom = (respBuf.length - data.length - 3).clamp(0, respBuf.length);
for (int i = searchFrom; i <= respBuf.length - 4; i++) {
if (respBuf[i] == 13 && respBuf[i + 1] == 10 &&
respBuf[i + 2] == 13 && respBuf[i + 3] == 10) {
respHeaderEnd = i;
break;
}
}
if (respHeaderEnd >= 0) {
comp2Sub.pause();
respReady.complete();
}
},
onError: (e) { if (!respReady.isCompleted) respReady.completeError(e); },
onDone: () { if (!respReady.isCompleted) respReady.complete(); },
cancelOnError: true,
);
try {
await respReady.future.timeout(const Duration(seconds: 10));
} on TimeoutException {
comp2Sub.cancel();
comp2.destroy();
_deny(client, sub, 504, 'Gateway Timeout');
return;
} catch (_) {
comp2Sub.cancel();
comp2.destroy();
_deny(client, sub, 502, 'Bad Gateway');
return;
}
if (respHeaderEnd < 0) {
comp2Sub.cancel();
comp2.destroy();
_deny(client, sub, 502, 'Bad Gateway');
return;
}
// Parse the status code from Component 2's response.
final respText = String.fromCharCodes(respBuf.sublist(0, respHeaderEnd));
final statusLine = respText.split('\r\n').first;
final statusParts = statusLine.split(' ');
final statusCode = statusParts.length >= 2 ? int.tryParse(statusParts[1]) ?? 0 : 0;
if (statusCode != 200) {
comp2Sub.cancel();
comp2.destroy();
_deny(client, sub,
statusCode == 403 ? 403 : 502,
statusCode == 403 ? 'Forbidden' : 'Bad Gateway');
return;
}
// Tunnel is established. Any bytes Component 2 sent after the CONNECT
// headers are already tunneled data (rare but possible).
final comp2Remainder = respBuf.sublist(respHeaderEnd + 4);
// Tell the browser the tunnel is open.
client.add(_kBytesConnectOk);
if (remainder.isNotEmpty) comp2.add(remainder);
if (comp2Remainder.isNotEmpty) client.add(comp2Remainder);
// Pipe browser Component 2 upstream (Component 2 owns the upstream half).
comp2Sub.onData(client.add);
comp2Sub.onDone(client.destroy);
comp2Sub.onError((_) { comp2.destroy(); client.destroy(); });
comp2Sub.resume();
sub.onData(comp2.add);
sub.onDone(comp2.destroy);
sub.onError((_) { comp2.destroy(); client.destroy(); });
sub.resume();
}
// ---------------------------------------------------------------------------
// Plain HTTP request
// ---------------------------------------------------------------------------
Future<void> _handleHttp(
Socket client,
StreamSubscription<List<int>> sub,
String method,
String targetUrl,
List<String> headerLines,
List<int> remainder,
) async {
Uri uri;
try {
uri = Uri.parse(targetUrl);
} catch (_) {
sub.cancel();
client.destroy();
return;
}
final host = uri.host;
final port = uri.hasPort ? uri.port : 80;
int contentLength = 0;
for (final h in headerLines) {
if (h.toLowerCase().startsWith('content-length:')) {
contentLength = int.tryParse(h.split(':').last.trim()) ?? 0;
break;
}
}
if (_isGated(host, port)) {
await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength);
} else {
await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength);
}
}
// Gated plain HTTP (v2): get token from Component 2, then call endpoint directly.
Future<void> _handleGatedHttp(
Socket client,
StreamSubscription<List<int>> sub,
String method,
Uri uri,
List<String> headerLines,
List<int> remainder,
int contentLength,
) async {
String token;
try {
token = await _getAuthToken(uri);
} catch (_) {
_deny(client, sub, 407, 'Proxy Authentication Required');
return;
}
Socket upstream;
try {
upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80)
.timeout(const Duration(seconds: 10));
} catch (_) {
_deny(client, sub, 502, 'Bad Gateway');
return;
}
final out = StringBuffer();
_writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines, bearerToken: token);
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
}
// Non-gated plain HTTP: forward directly, no auth.
Future<void> _handleDirectHttp(
Socket client,
StreamSubscription<List<int>> sub,
String method,
Uri uri,
List<String> headerLines,
List<int> remainder,
int contentLength,
) async {
Socket upstream;
try {
upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80)
.timeout(const Duration(seconds: 10));
} catch (_) {
_deny(client, sub, 502, 'Bad Gateway');
return;
}
final out = StringBuffer();
_writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines);
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
}
// Calls POST /auth/get-token on Component 2 with domain-level binding and
// returns the bearer token (a self-contained FIDO2 assertion bundle).
// Throws if card is unavailable or Component 2 is unreachable.
Future<String> _getAuthToken(Uri uri) async {
final nonce = _secureHex(16);
final payload = utf8.encode(jsonEncode({
'host': uri.host,
'nonce': nonce,
}));
final httpClient = HttpClient()
..connectionTimeout = const Duration(seconds: 5);
try {
final req = await httpClient.postUrl(
Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'),
);
req.headers.contentType = ContentType.json;
req.contentLength = payload.length;
req.add(payload);
final resp = await req.close();
final body = await resp.transform(utf8.decoder).join();
final json = jsonDecode(body) as Map<String, dynamic>;
if (json['ok'] == true) {
return json['token'] as String;
}
throw Exception(json['error'] ?? 'auth failed');
} finally {
httpClient.close();
}
}
String _secureHex(int bytes) {
final rng = Random.secure();
return List.generate(bytes, (_) => rng.nextInt(256))
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
void _writeProxyHeaders(
StringBuffer out,
String method,
String path,
Uri uri,
List<String> headerLines, {
String? bearerToken,
}) {
out
..write('$method $path HTTP/1.1\r\n')
..write('Host: ${uri.host}${uri.hasPort ? ":${uri.port}" : ""}\r\n');
if (bearerToken != null) out.write('Authorization: Bearer $bearerToken\r\n');
for (final h in headerLines) {
if (h.isEmpty) continue;
final lower = h.toLowerCase();
if (lower.startsWith('host:') ||
lower.startsWith('proxy-connection:') ||
lower.startsWith('proxy-authorization:') ||
(bearerToken != null && lower.startsWith('authorization:'))) continue;
out.write('$h\r\n');
}
out.write('Connection: close\r\n\r\n');
}
Future<void> _forwardHttpRequest(
Socket client,
StreamSubscription<List<int>> sub,
Socket upstream,
String headers,
List<int> remainder,
int contentLength,
) async {
upstream.add(utf8.encode(headers));
if (remainder.isNotEmpty) upstream.add(remainder);
final bodyLeft = contentLength - remainder.length;
if (bodyLeft > 0) {
sub.onData(upstream.add);
sub.onDone(upstream.destroy);
sub.onError((_) { upstream.destroy(); client.destroy(); });
sub.resume();
} else {
sub.cancel();
}
final done = Completer<void>();
upstream.listen(
client.add,
// flush() drains the write buffer before closing; destroy() would drop it.
onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); },
onError: (_) { upstream.destroy(); client.destroy(); if (!done.isCompleted) done.complete(); },
cancelOnError: true,
);
await done.future;
}
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
sub.cancel();
client.add(utf8.encode(
'HTTP/1.1 $code $reason\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
));
client.flush().then((_) => client.destroy()).catchError((_) => client.destroy());
}
// Parses "host:port" strings (e.g. CONNECT target). Uses [defaultPort] when no port present.
({String host, int port}) _parseHostPort(String target, {int defaultPort = 443}) {
final colon = target.lastIndexOf(':');
if (colon < 0) return (host: target, port: defaultPort);
return (
host: target.substring(0, colon),
port: int.tryParse(target.substring(colon + 1)) ?? defaultPort,
);
}
// Converts a proxy-style absolute URI to a relative path+query string.
String _relativePath(Uri uri) {
final base = uri.path.isEmpty ? '/' : uri.path;
return uri.hasQuery ? '$base?${uri.query}' : base;
}
void _log(String msg) => onLog?.call('[FilterProxy] $msg');
}

View File

@ -1,259 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'enrollment_db.dart';
import 'filter_proxy.dart';
import 'proxy_service.dart';
import 'session_manager.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 = [];
// Debug-only test state stripped in release builds via kDebugMode.
bool _testRunning = false;
final _db = EnrollmentDb();
final _session = SessionManager();
@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();
}
});
}
});
}
void _addLog(String msg) {
setState(() {
_log.insert(0, msg);
if (_log.length > 200) _log.removeLast();
});
}
Future<void> _runRegistrationLoginTest() async {
if (_testRunning) return;
setState(() => _testRunning = true);
try {
// --- Registrering ---
_addLog('[REGISTRATION] Starter: enrolling "testbruger"');
try {
final enrollment = await _db.register(
username: 'testbruger',
displayName: 'Test Bruger',
);
_addLog('[REGISTRATION] OK: bruger="${enrollment.username}" '
'displayName="${enrollment.displayName}"');
} on StateError {
_addLog('[REGISTRATION] INFO: "testbruger" allerede enrollet — sletter og prøver igen');
await _db.delete('testbruger');
final enrollment = await _db.register(
username: 'testbruger',
displayName: 'Test Bruger',
);
_addLog('[REGISTRATION] OK: bruger="${enrollment.username}" genregistreret');
}
final list = await _db.list();
_addLog('[REGISTRATION] Enrollment-liste: ${list.map((e) => e.username).join(', ')}');
_addLog('[REGISTRATION] Test duplikat afvisning...');
try {
await _db.register(username: 'testbruger');
_addLog('[REGISTRATION] FEJL: duplikat burde være afvist!');
} on StateError catch (e) {
_addLog('[REGISTRATION] OK: duplikat afvist — ${e.message}');
}
// --- Login ---
_addLog('[LOGIN] Udsteder session for "testbruger"...');
final token = _session.issue('testbruger');
_addLog('[LOGIN] Token: ${token.substring(0, 8)}... (${token.length} tegn)');
final valid = _session.isValid(token);
_addLog('[LOGIN] Session gyldig: $valid');
final entry = _session.getSession(token);
_addLog('[LOGIN] Udløber: ${entry?.expires.toLocal().toString().substring(0, 19)}');
_addLog('[LOGIN] Tilbagekalder session...');
_session.revoke(token);
_addLog('[LOGIN] Session gyldig efter revoke: ${_session.isValid(token)}');
_addLog('[LOGIN] FÆRDIG — alle flows OK');
} catch (e) {
_addLog('[FEJL] $e');
} finally {
setState(() => _testRunning = false);
}
}
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: 'Filter proxy (Comp 1)',
ok: _serviceRunning,
value: _serviceRunning ? 'Running on :$kFilterProxyPort' : 'Stopped',
),
const SizedBox(height: 8),
_StatusTile(
label: 'Auth proxy (Comp 2)',
ok: _serviceRunning,
value: _serviceRunning ? 'Running on :$kProxyPort' : '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),
Row(
children: [
FilledButton(
onPressed: _toggleService,
child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'),
),
if (kDebugMode) ...[
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: _testRunning ? null : _runRegistrationLoginTest,
child: _testRunning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Kør registrering & login'),
),
],
],
),
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

@ -1,231 +0,0 @@
import 'dart:convert';
final List<int> kPortalHtmlBytes = utf8.encode(kPortalHtml);
final List<int> kEnrollHtmlBytes = utf8.encode(kEnrollHtml);
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">Get Auth Token</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","/auth/get-token",{});log("Auth token acquired — Component 1/3 uses this to call endpoint directly",data);}catch(err){log("Get token 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>''';
const String kEnrollHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Registration</title>
<style>
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
h1{font-size:1.25rem;font-weight:700}
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
/* user list */
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
#userList table{width:100%;border-collapse:collapse}
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
#userList tr:last-child td{border-bottom:none}
.uname{font-weight:600;font-size:.95rem}
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
.fido2{background:#d1fae5;color:#065f46}
.probe{background:#fef3c7;color:#92400e}
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
.btn-del:hover{background:var(--r);color:#fff}
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
/* form */
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
label{font-size:.8rem;color:var(--muted)}
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
input:focus{outline:2px solid var(--g);border-color:transparent}
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
#regBtn:disabled{opacity:.5;cursor:default}
/* status */
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
#msg.ok{color:#065f46}
#msg.err{color:var(--r)}
</style>
</head>
<body>
<main>
<h1>ChromeCard User Registration</h1>
<section>
<h2>Registered users</h2>
<div id="userList"><div class="empty">Loading</div></div>
</section>
<section>
<h2>Register new user</h2>
<form id="regForm">
<label for="uname">Username</label>
<input id="uname" placeholder="alice" autocomplete="off" required>
<label for="dname">Display name (optional)</label>
<input id="dname" placeholder="Alice Example" autocomplete="off">
<button type="submit" id="regBtn">Register touch card fingerprint</button>
</form>
<div id="msg"></div>
</section>
</main>
<script>
var listEl=document.getElementById("userList"),
regForm=document.getElementById("regForm"),
unameEl=document.getElementById("uname"),
dnameEl=document.getElementById("dname"),
regBtn=document.getElementById("regBtn"),
msgEl=document.getElementById("msg");
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
function clearMsg(){msgEl.textContent="";msgEl.className="";}
function renderUsers(users){
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
var rows=users.map(function(u){
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
var mode=u.has_credential?'fido2':'probe';
var label=u.has_credential?'FIDO2':'probe';
return '<tr>'
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
+'</tr>';
}).join("");
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
listEl.querySelectorAll(".btn-del").forEach(function(b){
b.addEventListener("click",function(){del(b.dataset.u);});
});
}
async function loadUsers(){
try{
var r=await fetch("/enroll/list"),d=await r.json();
renderUsers(d.users||[]);
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
}
async function del(username){
if(!confirm('Delete user "'+username+'"?'))return;
clearMsg();
try{
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Delete failed");
renderUsers(d.users||[]);
setMsg('"'+username+'" deleted.',true);
}catch(e){setMsg(e.message,false);}
}
regForm.addEventListener("submit",async function(e){
e.preventDefault();clearMsg();
var username=unameEl.value.trim();
var display_name=dnameEl.value.trim()||undefined;
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
try{
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Registration failed");
renderUsers(d.users||[]);
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
unameEl.value="";dnameEl.value="";
}catch(e){setMsg(e.message,false);}
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
});
loadUsers();
</script>
</body>
</html>''';

View File

@ -1,655 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
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 'filter_proxy.dart';
import 'fido2_ops.dart';
import 'portal_html.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 FilterProxy _filterProxy = FilterProxy();
final SessionManager _sessions = SessionManager();
final EnrollmentDb _db = EnrollmentDb();
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');
_filterProxy.onLog = _emit;
try {
await _filterProxy.seedDefaultGatedHosts();
await _filterProxy.loadGatedHosts();
await _filterProxy.start();
} catch (e) {
_emit('Filter proxy failed to start: $e');
}
// Card detection and DB loading are independent run in parallel.
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
_emit('No TLS certs found — running plain HTTP (dev mode)');
try {
_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;
try {
await _filterProxy.stop();
await _server?.close(force: true);
await closeCard();
} finally {
_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 _serveHtmlBytes(req, kPortalHtmlBytes);
case '/enroll':
await _serveHtmlBytes(req, kEnrollHtmlBytes);
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 '/auth/get-token':
await _handleAuthGetToken(req);
default:
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
}
} else if (req.method == 'CONNECT') {
await _handleConnect(req);
} 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 r = await _parseUsernameAndDisplay(req, body);
if (r == null) return;
final (canonical, pretty) = r;
await _ensureCardOpen();
MakeCredentialResult? credential;
if (_cardAttached && _cardCid != null) {
try {
credential = 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: credential?.userIdB64,
credentialDataB64: credential?.credentialDataB64,
);
final users = await _db.list();
await _send(req.response, 200, {
..._enrollmentPayload(enrollment, created: true),
'users': users.map(_userSummary).toList(),
});
} 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 r = await _parseUsernameAndDisplay(req, body);
if (r == null) return;
final (canonical, pretty) = r;
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 canonical = await _parseUsername(req, body);
if (canonical == null) return;
try {
final enrollment = await _db.delete(canonical);
_sessions.revokeAll(canonical);
final users = await _db.list();
await _send(req.response, 200, {
'ok': true,
'username': enrollment.username,
'deleted': true,
'users': users.map(_userSummary).toList(),
});
} 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(_userSummary).toList(),
});
}
// -------------------------------------------------------------------------
// Session endpoints
// -------------------------------------------------------------------------
Future<void> _handleSessionLogin(HttpRequest req) async {
final body = await _readJson(req);
if (body == null) return;
final canonical = await _parseUsername(req, body);
if (canonical == null) return;
final enrollment = await _db.get(canonical);
if (enrollment == null) {
await _send(req.response, 403, {'ok': false, 'error': 'user not enrolled', 'username': canonical});
return;
}
await _ensureCardOpen();
if (enrollment.hasCredential && _cardCid != null) {
// FIDO2-direct: getAssertion + local verify.
// Random challenge is intentional here: session login only proves the
// user CAN authenticate (user-presence check). The resulting session token
// is for portal access. Per-request resource binding (challenge = SHA256
// of url|method|nonce) happens in _handleAuthGetToken, not here.
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 (no FIDO2 credential stored) and card is
// physically attached card presence is the only factor, accept the login.
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': SessionManager.ttlSeconds,
'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});
}
// -------------------------------------------------------------------------
// CONNECT tunnel (gated HTTPS)
// -------------------------------------------------------------------------
Future<void> _handleConnect(HttpRequest req) async {
if (!_sessions.hasAnyActiveSession()) {
await _send(req.response, 407, {'ok': false, 'error': 'authentication required'});
return;
}
// Extract host:port from the Host header (CONNECT target is "host:port")
final hostHeader = req.headers.value('host') ?? '';
final lastColon = hostHeader.lastIndexOf(':');
String connectHost;
int connectPort;
if (lastColon > 0) {
connectHost = hostHeader.substring(0, lastColon);
connectPort = int.tryParse(hostHeader.substring(lastColon + 1)) ?? 443;
} else {
connectHost = hostHeader;
connectPort = 443;
}
if (connectHost.isEmpty) {
await _send(req.response, 400, {'ok': false, 'error': 'missing CONNECT target'});
return;
}
Socket upstream;
try {
upstream = await Socket.connect(connectHost, connectPort)
.timeout(const Duration(seconds: 10));
} catch (e) {
_emit('CONNECT $connectHost:$connectPort failed: $e');
await _send(req.response, 502, {'ok': false, 'error': 'cannot connect to upstream'});
return;
}
final clientSocket = await req.response.detachSocket(writeHeaders: false);
clientSocket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
await clientSocket.flush();
clientSocket.listen(
upstream.add,
onDone: upstream.destroy,
onError: (_) => upstream.destroy(),
cancelOnError: true,
);
upstream.listen(
clientSocket.add,
onDone: clientSocket.destroy,
onError: (_) => clientSocket.destroy(),
cancelOnError: true,
);
}
// -------------------------------------------------------------------------
// Auth token endpoint (v2 architecture domain-level token binding)
//
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
// {host, nonce} for each gated request. A fresh FIDO2 assertion is
// produced with challenge = SHA256(host|nonce). The self-contained
// assertion bundle is returned as a base64url Bearer token the server can
// verify without calling back to this service.
// -------------------------------------------------------------------------
Future<void> _handleAuthGetToken(HttpRequest req) async {
final body = await _readJson(req);
if (body == null) return;
final host = body['host'] as String? ?? '';
final nonce = body['nonce'] as String? ?? '';
if (host.isEmpty || nonce.isEmpty) {
await _send(req.response, 400, {'ok': false, 'error': 'host, nonce required'});
return;
}
await _ensureCardOpen();
if (!_cardAttached || _cardCid == null) {
await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
return;
}
// Find first enrolled user with a FIDO2 credential.
final users = await _db.list();
Enrollment? enrolled;
for (final u in users) {
if (u.hasCredential) { enrolled = u; break; }
}
if (enrolled == null) {
await _send(req.response, 401, {'ok': false, 'error': 'no enrolled credential'});
return;
}
// Challenge = SHA256(host | "|" | nonce)
final challenge = Uint8List.fromList(
sha256.convert(utf8.encode('$host|$nonce')).bytes,
);
GetAssertionResult assertionResult;
try {
assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge);
} catch (e) {
await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'});
return;
}
// Self-contained bundle the server can verify without calling back to the phone.
final bundleJson = jsonEncode({
'v': 1,
'host': host,
'nonce': nonce,
'authData': base64Url.encode(assertionResult.authData).replaceAll('=', ''),
'sig': base64Url.encode(assertionResult.signature).replaceAll('=', ''),
'cdj': base64Url.encode(utf8.encode(assertionResult.clientDataJson)).replaceAll('=', ''),
'cred': enrolled.credentialDataB64,
'user': enrolled.username,
});
final token = base64Url.encode(utf8.encode(bundleJson)).replaceAll('=', '');
await _send(req.response, 200, {'ok': true, 'token': token, 'username': enrolled.username});
}
// -------------------------------------------------------------------------
// Health + HTML
// -------------------------------------------------------------------------
Future<void> _handleHealth(HttpRequest req) async {
await _send(req.response, 200, {
'ok': true,
'service': 'k_phone',
'card': _cardAttached,
'active_sessions': 0,
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
});
}
Future<void> _serveHtmlBytes(HttpRequest req, List<int> bytes) async {
req.response.statusCode = 200;
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
req.response.headers.contentLength = bytes.length;
req.response.add(bytes);
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;
}
}
/// Re-open the card if the socket has been closed since startup.
/// Called before card operations so a bridge restart doesn't require an app restart.
Future<void> _ensureCardOpen() async {
if (!_cardAttached || _cardCid == null || !(await isCardAttached())) {
_emit('Card not open — reconnecting...');
_cardAttached = false;
_cardCid = null;
await _tryOpenCard();
}
}
// -------------------------------------------------------------------------
// 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 bb = BytesBuilder(copy: false);
await for (final chunk in req) {
bb.add(chunk);
}
final bytes = bb.takeBytes();
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();
}
// Compact user entry for embedded lists in register/delete/list responses.
Map<String, dynamic> _userSummary(Enrollment e) => {
'username': e.username,
'display_name': e.displayName,
'has_credential': e.hasCredential,
};
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<String?> _parseUsername(HttpRequest req, Map<String, dynamic> body) async {
try {
return normalizeUsername(body['username'] as String? ?? '');
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return null;
}
}
Future<(String, String?)?> _parseUsernameAndDisplay(
HttpRequest req, Map<String, dynamic> body) async {
try {
return (
normalizeUsername(body['username'] as String? ?? ''),
normalizeDisplayName(body['display_name'] as String?),
);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return null;
}
}
}

View File

@ -1,79 +0,0 @@
// 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 int ttlSeconds = 300;
static const Duration _ttl = Duration(seconds: ttlSeconds);
/// Issue a new session token for [username].
/// _purgeExpired is only called here, not on every lookup, so tokens accumulate
/// until the next login acceptable for the low-traffic embedded use case.
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);
/// Returns true if at least one session is currently active (not expired).
/// Used by gated-proxy forwarding: personal-device model means any live
/// login counts as authorisation for the proxied request.
bool hasAnyActiveSession() {
_purgeExpired();
return _sessions.isNotEmpty;
}
/// Returns the token and session entry of any currently active session.
/// Used by /auth/get-token to return an existing token without card interaction.
(String token, SessionEntry session)? anyActive() {
_purgeExpired();
if (_sessions.isEmpty) return null;
final e = _sessions.entries.first;
return (e.key, e.value);
}
/// 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();
}
}

View File

@ -1,620 +0,0 @@
# 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_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
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"
fuchsia_remote_debug_protocol:
dependency: transitive
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"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
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"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
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"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.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"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
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"

View File

@ -1,29 +0,0 @@
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
integration_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View File

@ -1,240 +0,0 @@
// Tests for EnrollmentDb verifies that users are created, listed, and
// deleted correctly on the phone.
//
// All tests use an injected temp directory so path_provider is not needed.
//
// Run: flutter test test/enrollment_test.dart
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../lib/enrollment_db.dart';
void main() {
late Directory tmp;
late EnrollmentDb db;
setUp(() async {
tmp = await Directory.systemTemp.createTemp('enrollment_test_');
db = EnrollmentDb(baseDir: tmp);
});
tearDown(() => tmp.delete(recursive: true));
// -------------------------------------------------------------------------
// Registration
// -------------------------------------------------------------------------
group('register', () {
test('creates probe-mode enrollment when no credential data provided', () async {
final e = await db.register(username: 'alice', displayName: 'Alice Example');
expect(e.username, 'alice');
expect(e.displayName, 'Alice Example');
expect(e.hasCredential, isFalse);
expect(e.credentialDataB64, isNull);
expect(e.userIdB64, isNull);
});
test('creates FIDO2 enrollment when credential data provided', () async {
final e = await db.register(
username: 'alice',
userIdB64: 'dXNlcklk',
credentialDataB64: 'Y3JlZERhdGE=',
);
expect(e.hasCredential, isTrue);
expect(e.credentialDataB64, 'Y3JlZERhdGE=');
expect(e.userIdB64, 'dXNlcklk');
});
test('normalizes username to lowercase and trims whitespace', () async {
final e = await db.register(username: ' BOB ');
expect(e.username, 'bob');
});
test('sets createdAt and updatedAt to current time', () async {
final before = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final e = await db.register(username: 'alice');
final after = DateTime.now().millisecondsSinceEpoch ~/ 1000;
expect(e.createdAt, inInclusiveRange(before, after));
expect(e.updatedAt, e.createdAt);
});
test('throws StateError on duplicate username', () async {
await db.register(username: 'alice');
await expectLater(
db.register(username: 'alice'),
throwsA(isA<StateError>()),
);
});
test('duplicate check is case-insensitive', () async {
await db.register(username: 'alice');
await expectLater(
db.register(username: 'ALICE'),
throwsA(isA<StateError>()),
);
});
test('rejects invalid username', () async {
// Two-char usernames are rejected by the regex (1 or 332 chars only).
await expectLater(
db.register(username: 'ab'),
throwsA(isA<ArgumentError>()),
);
});
test('rejects username with special characters', () async {
await expectLater(
db.register(username: 'alice!'),
throwsA(isA<ArgumentError>()),
);
});
});
// -------------------------------------------------------------------------
// List
// -------------------------------------------------------------------------
group('list', () {
test('returns empty list when no users registered', () async {
expect(await db.list(), isEmpty);
});
test('returns all registered users sorted alphabetically', () async {
await db.register(username: 'charlie');
await db.register(username: 'alice');
await db.register(username: 'bob');
final names = (await db.list()).map((e) => e.username).toList();
expect(names, ['alice', 'bob', 'charlie']);
});
test('reflects correct hasCredential for each user', () async {
await db.register(username: 'probe');
await db.register(
username: 'fido',
userIdB64: 'dXNlcg==',
credentialDataB64: 'Y3JlZA==',
);
final list = await db.list();
expect(list.firstWhere((e) => e.username == 'probe').hasCredential, isFalse);
expect(list.firstWhere((e) => e.username == 'fido').hasCredential, isTrue);
});
});
// -------------------------------------------------------------------------
// Delete
// -------------------------------------------------------------------------
group('delete', () {
test('removes the user from the list', () async {
await db.register(username: 'alice');
await db.delete('alice');
expect(await db.list(), isEmpty);
});
test('returns the deleted enrollment', () async {
await db.register(username: 'alice', displayName: 'Alice');
final deleted = await db.delete('alice');
expect(deleted.username, 'alice');
expect(deleted.displayName, 'Alice');
});
test('only removes the target user, not others', () async {
await db.register(username: 'alice');
await db.register(username: 'bob');
await db.delete('alice');
final names = (await db.list()).map((e) => e.username).toList();
expect(names, ['bob']);
});
test('throws StateError when user does not exist', () async {
await expectLater(
db.delete('nobody'),
throwsA(isA<StateError>()),
);
});
});
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
group('persistence', () {
test('enrollments survive across new EnrollmentDb instances', () async {
await db.register(username: 'alice', displayName: 'Alice');
await db.register(username: 'bob');
final db2 = EnrollmentDb(baseDir: tmp);
final names = (await db2.list()).map((e) => e.username).toList();
expect(names, ['alice', 'bob']);
});
test('credential data survives reload', () async {
await db.register(
username: 'alice',
userIdB64: 'dXNlcg==',
credentialDataB64: 'Y3JlZA==',
);
final db2 = EnrollmentDb(baseDir: tmp);
final e = await db2.get('alice');
expect(e, isNotNull);
expect(e!.hasCredential, isTrue);
expect(e.credentialDataB64, 'Y3JlZA==');
});
test('deletion persists across new instances', () async {
await db.register(username: 'alice');
await db.delete('alice');
final db2 = EnrollmentDb(baseDir: tmp);
expect(await db2.list(), isEmpty);
});
test('two concurrent writes both complete without corruption', () async {
// Simultaneous register calls must queue, not corrupt the file.
await Future.wait([
db.register(username: 'alice'),
db.register(username: 'bob'),
db.register(username: 'charlie'),
]);
final names = (await db.list()).map((e) => e.username).toList();
expect(names, containsAll(['alice', 'bob', 'charlie']));
});
});
// -------------------------------------------------------------------------
// Update
// -------------------------------------------------------------------------
group('update', () {
test('changes display name', () async {
await db.register(username: 'alice', displayName: 'Old Name');
await db.update(username: 'alice', displayName: 'New Name');
final e = await db.get('alice');
expect(e!.displayName, 'New Name');
});
test('updatedAt advances after update', () async {
final e1 = await db.register(username: 'alice');
await Future.delayed(const Duration(seconds: 1));
await db.update(username: 'alice', displayName: 'Alice');
final e2 = await db.get('alice');
expect(e2!.updatedAt, greaterThanOrEqualTo(e1.updatedAt));
});
test('throws StateError when user does not exist', () async {
await expectLater(
db.update(username: 'nobody'),
throwsA(isA<StateError>()),
);
});
});
}

View File

@ -1,167 +0,0 @@
// 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

@ -1,575 +0,0 @@
// Tests for Component 1 (FilterProxy).
//
// All tests are self-contained: they bind local servers and never hit the
// internet. Port 0 lets the OS assign a free port for each server.
//
// Run: flutter test test/filter_proxy_test.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../lib/filter_proxy.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const _kTimeout = Duration(seconds: 5);
// Start an HttpServer that records the first request and replies 200 OK.
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
final server = await HttpServer.bind('127.0.0.1', 0);
final c = Completer<HttpRequest>();
server.listen((req) async {
await req.drain<void>();
req.response
..statusCode = 200
..headers.set('content-type', 'text/plain')
..headers.set('content-length', '2')
..headers.set('connection', 'close')
..write('OK');
await req.response.close();
if (!c.isCompleted) c.complete(req);
});
return (server: server, completer: c);
}
// Mock for Component 2's /auth/get-token endpoint.
// Reads and captures the full request body; completes [tokenReq] on the first call.
// When [ok] is false, returns 401.
Future<({HttpServer server, Completer<({HttpRequest req, String rawBody})> tokenReq})>
_mockTokenServer({
String token = 'test-bearer-token',
bool ok = true,
}) async {
final server = await HttpServer.bind('127.0.0.1', 0);
final c = Completer<({HttpRequest req, String rawBody})>();
server.listen((req) async {
final bb = BytesBuilder(copy: false);
await for (final chunk in req) bb.add(chunk);
final rawBody = utf8.decode(bb.takeBytes());
if (!c.isCompleted) c.complete((req: req, rawBody: rawBody));
final resp = ok
? '{"ok":true,"token":"$token","expires_in":300}'
: '{"ok":false,"error":"card not available","login_required":true}';
req.response
..statusCode = ok ? 200 : 401
..headers.set('content-type', 'application/json')
..headers.set('content-length', '${resp.length}')
..headers.set('connection', 'close')
..write(resp);
await req.response.close();
});
return (server: server, tokenReq: c);
}
// Start a raw TCP server that hands back the accepted Socket.
Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
final server = await ServerSocket.bind('127.0.0.1', 0);
final c = Completer<Socket>();
server.listen((sock) { if (!c.isCompleted) c.complete(sock); });
return (server: server, socket: c.future);
}
// Start a mock Component 2 TCP server that speaks the CONNECT handshake:
// reads incoming request headers, responds HTTP 200, then completes with
// the socket and its paused subscription for bidirectional tunnel testing.
// The subscription is paused so the caller can install its own onData
// handler before resuming, guaranteeing no data is missed.
Future<({
ServerSocket server,
Future<({Socket socket, StreamSubscription<List<int>> sub})> conn,
})> _mockComp2Tcp() async {
final server = await ServerSocket.bind('127.0.0.1', 0);
final c = Completer<({Socket socket, StreamSubscription<List<int>> sub})>();
server.listen((sock) {
if (c.isCompleted) return;
final buf = <int>[];
late StreamSubscription<List<int>> sub;
sub = sock.listen((data) {
buf.addAll(data);
if (!c.isCompleted &&
utf8.decode(buf, allowMalformed: true).contains('\r\n\r\n')) {
sub.pause();
sock.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
sock.flush().then((_) => c.complete((socket: sock, sub: sub)));
}
});
});
return (server: server, conn: c.future);
}
// Send [request] to [proxyPort] and collect the full response.
// Assumes the server closes the connection after the response.
Future<String> _round(int proxyPort, String request) async {
final sock = await Socket.connect('127.0.0.1', proxyPort)
.timeout(_kTimeout);
sock.write(request);
await sock.flush();
final buf = <int>[];
await sock.listen(buf.addAll).asFuture<void>().timeout(_kTimeout);
return utf8.decode(buf);
}
// Reads from [client] until the CONNECT 200 response header block arrives.
Future<String> _waitForConnectResponse(Socket client) async {
final buf = <int>[];
final done = Completer<void>();
late StreamSubscription<List<int>> sub;
sub = client.listen((d) {
buf.addAll(d);
if (!done.isCompleted &&
utf8.decode(buf, allowMalformed: true).contains('\r\n\r\n')) {
done.complete();
}
});
await done.future.timeout(_kTimeout);
sub.cancel();
return utf8.decode(buf);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// -------------------------------------------------------------------------
// Group 1: gated host matching unit tests, no network
// -------------------------------------------------------------------------
group('gated host matching (unit)', () {
late FilterProxy proxy;
setUp(() {
proxy = FilterProxy(component2Port: 0);
});
test('host-only entry matches any port', () {
proxy.setGatedEntries(['example.com']);
expect(proxy.isGatedForTest('example.com', 80), isTrue);
expect(proxy.isGatedForTest('example.com', 443), isTrue);
expect(proxy.isGatedForTest('example.com', 8771), isTrue);
});
test('host:port entry matches only that port', () {
proxy.setGatedEntries(['example.com:8771']);
expect(proxy.isGatedForTest('example.com', 8771), isTrue);
expect(proxy.isGatedForTest('example.com', 80), isFalse);
expect(proxy.isGatedForTest('example.com', 443), isFalse);
});
test('matching is case-insensitive', () {
proxy.setGatedEntries(['EXAMPLE.COM:8771']);
expect(proxy.isGatedForTest('example.com', 8771), isTrue);
expect(proxy.isGatedForTest('EXAMPLE.COM', 8771), isTrue);
});
test('different hostname is not matched', () {
proxy.setGatedEntries(['example.com']);
expect(proxy.isGatedForTest('other.com', 80), isFalse);
});
test('empty gated list matches nothing', () {
proxy.setGatedEntries([]);
expect(proxy.isGatedForTest('example.com', 80), isFalse);
});
test('partial hostname is not matched', () {
proxy.setGatedEntries(['example.com']);
expect(proxy.isGatedForTest('sub.example.com', 80), isFalse);
});
test('setGatedEntries replaces previous entries', () {
proxy.setGatedEntries(['first.com']);
proxy.setGatedEntries(['second.com']);
expect(proxy.isGatedForTest('first.com', 80), isFalse);
expect(proxy.isGatedForTest('second.com', 80), isTrue);
});
});
// -------------------------------------------------------------------------
// Group 2: HTTP routing (v2 semantics)
//
// Gated HTTP: proxy calls comp2 POST /auth/get-token, then forwards the
// request directly to the endpoint with Authorization: Bearer <token>.
// Non-gated HTTP: proxy forwards directly, no token fetch.
// -------------------------------------------------------------------------
group('HTTP routing', () {
late FilterProxy proxy;
late HttpServer comp2;
late Completer<({HttpRequest req, String rawBody})> comp2TokenReq;
late HttpServer endpoint;
late Completer<HttpRequest> endpointReq;
late HttpServer directServer;
late Completer<HttpRequest> directReq;
const testToken = 'test-bearer-token';
setUp(() async {
// Component 2 mock: handles POST /auth/get-token returns token.
final c2 = await _mockTokenServer(token: testToken);
comp2 = c2.server;
comp2TokenReq = c2.tokenReq;
// Gated endpoint mock: the actual resource the proxy calls directly.
final ep = await _mockHttp();
endpoint = ep.server;
endpointReq = ep.completer;
// Non-gated target mock.
final d = await _mockHttp();
directServer = d.server;
directReq = d.completer;
proxy = FilterProxy(
listenPort: 0,
component2Port: comp2.port,
);
// Gate the endpoint address; 127.0.0.1 + endpoint port is resolvable in tests.
proxy.setGatedEntries(['127.0.0.1:${endpoint.port}']);
await proxy.start();
});
tearDown(() async {
await proxy.stop();
await comp2.close(force: true);
await endpoint.close(force: true);
await directServer.close(force: true);
});
test('gated host: token is fetched from component2', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
);
final (:req, rawBody: _) = await comp2TokenReq.future.timeout(_kTimeout);
expect(req.method, 'POST');
expect(req.uri.path, '/auth/get-token');
});
test('gated host: /auth/get-token body carries host, nonce', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/api?x=1 HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
);
final (:req, :rawBody) = await comp2TokenReq.future.timeout(_kTimeout);
expect(req.uri.path, '/auth/get-token');
final body = jsonDecode(rawBody) as Map<String, dynamic>;
expect(body['host'], '127.0.0.1');
expect(body.containsKey('url'), isFalse);
expect(body.containsKey('method'), isFalse);
expect(body['nonce'], isA<String>());
expect((body['nonce'] as String).length, greaterThan(8));
});
test('gated POST: /auth/get-token body carries host, nonce (no method)', () async {
const postBody = '{"key":"val"}';
await _round(
proxy.port,
'POST http://127.0.0.1:${endpoint.port}/submit HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n'
'Content-Type: application/json\r\n'
'Content-Length: ${postBody.length}\r\n\r\n'
'$postBody',
);
final (:req, :rawBody) = await comp2TokenReq.future.timeout(_kTimeout);
expect(req.uri.path, '/auth/get-token');
final body = jsonDecode(rawBody) as Map<String, dynamic>;
expect(body['host'], '127.0.0.1');
expect(body.containsKey('method'), isFalse);
expect(body['nonce'], isA<String>());
});
test('gated host: request goes directly to endpoint with Bearer token', () async {
final response = await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.method, 'GET');
expect(req.uri.path, '/api');
expect(req.headers.value('authorization'), 'Bearer $testToken');
expect(response, contains('200 OK'));
});
test('non-gated host is forwarded directly', () async {
final response = await _round(
proxy.port,
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
);
final req = await directReq.future.timeout(_kTimeout);
expect(req.method, 'GET');
expect(req.uri.path, '/page');
expect(response, contains('200 OK'));
});
test('non-gated request does NOT reach component2', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
);
await directReq.future.timeout(_kTimeout);
expect(comp2TokenReq.isCompleted, isFalse);
});
test('gated: request line is rewritten to relative path', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/session/login?foo=bar HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.uri.path, '/session/login');
expect(req.uri.query, 'foo=bar');
});
test('gated: Proxy-Connection header is stripped', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n'
'Proxy-Connection: keep-alive\r\n\r\n',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('proxy-connection'), isNull);
});
test('gated: Proxy-Authorization header is stripped', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n'
'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('proxy-authorization'), isNull);
});
test('gated: existing Authorization header is replaced with Bearer token', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n'
'Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('authorization'), 'Bearer $testToken');
});
test('gated: custom header is preserved', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n'
'X-Custom: hello\r\n\r\n',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('x-custom'), 'hello');
});
test('gated: POST body is forwarded to endpoint', () async {
const body = '{"username":"alice"}';
await _round(
proxy.port,
'POST http://127.0.0.1:${endpoint.port}/session/login HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n'
'Content-Type: application/json\r\n'
'Content-Length: ${body.length}\r\n\r\n'
'$body',
);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.method, 'POST');
expect(req.uri.path, '/session/login');
expect(req.headers.value('authorization'), 'Bearer $testToken');
});
test('gated: 407 returned when component2 has no active session', () async {
final c2Err = await _mockTokenServer(ok: false);
final proxy2 = FilterProxy(
listenPort: 0,
component2Port: c2Err.server.port,
);
proxy2.setGatedEntries(['127.0.0.1:${endpoint.port}']);
await proxy2.start();
addTearDown(() async {
await proxy2.stop();
await c2Err.server.close(force: true);
});
final response = await _round(
proxy2.port,
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
);
expect(response, contains('407'));
});
});
// -------------------------------------------------------------------------
// Group 3: CONNECT tunnel routing
// (Gated CONNECT is still relayed through Component 2 unchanged in v2.)
// -------------------------------------------------------------------------
group('CONNECT routing', () {
late FilterProxy proxy;
late ServerSocket comp2Tcp;
late Future<({Socket socket, StreamSubscription<List<int>> sub})> comp2Conn;
late ServerSocket directTcp;
late Future<Socket> directConn;
setUp(() async {
final c2 = await _mockComp2Tcp();
comp2Tcp = c2.server;
comp2Conn = c2.conn;
final d = await _mockTcp();
directTcp = d.server;
directConn = d.socket;
proxy = FilterProxy(
listenPort: 0,
component2Port: comp2Tcp.port,
);
proxy.setGatedEntries(['auth.local:443']);
await proxy.start();
});
tearDown(() async {
await proxy.stop();
await comp2Tcp.close();
await directTcp.close();
});
test('gated CONNECT returns 200 and tunnels to component2', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n');
await client.flush();
final response = await _waitForConnectResponse(client);
expect(response, contains('200 Connection Established'));
// Verify the tunnel endpoint is component2
final conn = await comp2Conn.timeout(_kTimeout);
conn.sub.cancel();
client.destroy();
});
test('non-gated CONNECT returns 200 and tunnels to direct target', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
// Use 127.0.0.1:${directTcp.port} as the CONNECT target (not gated)
client.write(
'CONNECT 127.0.0.1:${directTcp.port} HTTP/1.1\r\n'
'Host: 127.0.0.1:${directTcp.port}\r\n\r\n');
await client.flush();
final response = await _waitForConnectResponse(client);
expect(response, contains('200 Connection Established'));
await directConn.timeout(_kTimeout);
client.destroy();
});
test('data flows through CONNECT tunnel in both directions', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
// Single listener + broadcast controller so multiple await-for loops can
// consume the stream without "already listened" errors.
final clientBuf = <int>[];
final clientCtrl = StreamController<List<int>>.broadcast();
client.listen((d) { clientBuf.addAll(d); clientCtrl.add(d); });
// Wait until clientBuf contains [expected]. Checks buffer first so data
// that arrived before the await-for subscription is never lost.
Future<void> waitClient(String expected) async {
if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return;
await for (final _ in clientCtrl.stream) {
if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return;
}
}
client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n');
await client.flush();
await waitClient('\r\n\r\n').timeout(_kTimeout);
expect(utf8.decode(clientBuf), contains('200 Connection Established'));
// Component2 side of the tunnel: mock already consumed the CONNECT
// request and sent 200; sub is paused so we can install our handler
// before any tunnel data arrives.
final conn = await comp2Conn.timeout(_kTimeout);
final serverSide = conn.socket;
final serverSub = conn.sub;
// Client component2
final serverBuf = <int>[];
final serverGotPing = Completer<void>();
serverSub.onData((d) {
serverBuf.addAll(d);
if (!serverGotPing.isCompleted &&
utf8.decode(serverBuf, allowMalformed: true).contains('PING')) {
serverGotPing.complete();
}
});
serverSub.resume();
client.write('PING');
await client.flush();
await serverGotPing.future.timeout(_kTimeout);
expect(utf8.decode(serverBuf), 'PING');
// Component2 client
final prevLen = clientBuf.length;
serverSide.write('PONG');
await serverSide.flush();
await waitClient('PONG').timeout(_kTimeout);
expect(utf8.decode(clientBuf.sublist(prevLen)), 'PONG');
await clientCtrl.close();
client.destroy();
serverSide.destroy();
});
});
// -------------------------------------------------------------------------
// Group 4: edge cases
// -------------------------------------------------------------------------
group('edge cases', () {
late FilterProxy proxy;
setUp(() async {
proxy = FilterProxy(listenPort: 0, component2Port: 0);
proxy.setGatedEntries([]);
await proxy.start();
});
tearDown(() => proxy.stop());
test('connection with no headers is closed cleanly', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
// Send nothing proxy should close when client closes
await client.close();
// No exception means the proxy handled it gracefully
});
test('malformed request line is closed cleanly', () async {
final response = await _round(proxy.port, 'NOT-HTTP\r\n\r\n');
// Proxy should close the connection without crashing
expect(response, isEmpty);
});
test('request with only spaces in request line is closed cleanly', () async {
final response = await _round(proxy.port, ' \r\n\r\n');
expect(response, isEmpty);
});
});
}

View File

@ -1,6 +0,0 @@
// 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));
}

View File

@ -1,14 +1,16 @@
#!/usr/bin/env python3
"""
k_proxy session gateway and card authentication bridge.
Minimal k_proxy service for Phase 5 bring-up.
Creates short-lived bearer sessions after a card-backed auth gate, then
proxies authenticated requests to k_server. Enrollment metadata and session
state are both process-local; sessions do not survive a restart.
Behavior:
- Creates short-lived sessions after a card-backed auth gate.
- Reuses valid sessions to access k_server protected counter endpoint.
- Supports enrollment, session status, and logout.
Default auth mode is a lightweight card-presence probe (subprocess call to
fido2_probe.py). Pass --auth-mode fido2-direct for real CTAP2
makeCredential/getAssertion against the attached ChromeCard.
Notes:
- Default runtime still uses the legacy card-presence probe gate.
- Experimental direct FIDO2 registration/assertion lives behind `--auth-mode fido2-direct`.
- This is still a prototype and not a final production auth design.
"""
from __future__ import annotations
@ -53,11 +55,8 @@ from fido2.webauthn import (
UserVerificationRequirement,
)
try:
if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None:
fido2.features.webauthn_json_mapping.enabled = True
except AttributeError:
pass
HTML = """<!doctype html>
@ -544,7 +543,6 @@ class ProxyState:
return time.time()
def _gc_locked(self) -> None:
# Caller must hold self.lock.
now = self._now()
dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now]
for token in dead:
@ -673,9 +671,6 @@ class ProxyState:
self._drop_direct_device_locked()
def _with_direct_ctap2(self, action):
# First attempt reuses the cached handle; if it fails (e.g. the card was
# briefly removed or the CTAPHID channel desynchronised), we reopen once
# and retry before propagating the error.
with self.direct_device_lock:
last_exc: Exception | None = None
for reopen in (False, True):
@ -964,8 +959,6 @@ class UpstreamPool:
conn.request("POST", full_path, body=body, headers=req_headers)
resp = conn.getresponse()
raw = resp.read()
# will_close is set by the server when it intends to close the connection
# after this response; reusing such a connection would hit an EOF.
reusable = not resp.will_close
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
@ -1008,20 +1001,10 @@ class Handler(BaseHTTPRequestHandler):
return json.loads(raw.decode("utf-8"))
def _discard_request_body(self) -> None:
# HTTP/1.1 keep-alive: body must be consumed before the response is sent.
length = int(self.headers.get("Content-Length", "0"))
if length > 0:
self.rfile.read(length)
def _require_json(self) -> dict[str, Any] | None:
# Returns None and sends 400 when the body is unparseable; callers must
# return immediately without sending a second response.
try:
return self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return None
def _bearer_token(self) -> str | None:
value = self.headers.get("Authorization", "")
if not value.startswith("Bearer "):
@ -1030,8 +1013,6 @@ class Handler(BaseHTTPRequestHandler):
return token or None
def _require_session(self) -> tuple[str, Session] | None:
# Returns None when auth fails; the 401 has already been sent, so callers
# must return immediately without writing a second response.
token = self._bearer_token()
if not token:
self._json(401, {"ok": False, "error": "missing bearer token"})
@ -1092,8 +1073,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_error(404)
def _session_login(self) -> None:
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
@ -1124,8 +1107,10 @@ class Handler(BaseHTTPRequestHandler):
)
def _enroll_register(self) -> None:
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
@ -1146,8 +1131,10 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at))
def _enroll_update(self) -> None:
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
enrollment = self.state.update_enrollment(
@ -1163,8 +1150,10 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, enrollment_payload(enrollment))
def _enroll_delete(self) -> None:
data = self._require_json()
if data is None:
try:
data = self._read_json()
except Exception:
self._json(400, {"ok": False, "error": "invalid json"})
return
try:
enrollment = self.state.delete_enrollment(str(data.get("username", "")))

View File

@ -1,17 +1,16 @@
#!/usr/bin/env python3
"""
k_server protected resource backend.
Minimal k_server service for Phase 5/5.5 bring-up.
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.
Behavior:
- Exposes a protected monotonic counter endpoint.
- Accepts only requests from k_proxy via a shared proxy token header.
- Uses thread-safe counter increments.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import ssl
import threading
@ -21,88 +20,9 @@ from typing import Any
from urllib.parse import urlparse
def _b64u_decode(s: str) -> bytes:
padded = s + "=" * ((4 - len(s) % 4) % 4)
return base64.urlsafe_b64decode(padded)
def _verify_assertion_token(token: str, expected_host: str) -> bool:
"""Verify a base64url-encoded FIDO2 domain-level assertion bundle.
Bundle fields (JSON, then base64url-encoded):
v version (1)
host hostname used to derive the challenge
nonce random hex nonce used to derive the challenge
authData base64url authenticator data
sig base64url ECDSA signature
cdj base64url clientDataJson bytes
cred base64url AttestedCredentialData (aaguid+credIdLen+credId+coseKey)
user enrolled username (informational)
expected_host must match bundle["host"] exactly (case-insensitive) to prevent
cross-server replay: a token issued for server-a must not pass on server-b.
"""
try:
import cbor2
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.ec import (
ECDSA,
EllipticCurvePublicNumbers,
SECP256R1,
)
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.exceptions import InvalidSignature
bundle = json.loads(_b64u_decode(token).decode("utf-8"))
host = bundle["host"]
nonce = bundle["nonce"]
if host.lower() != expected_host.lower():
return False
# Verify challenge claim: challenge == b64u(SHA256(host|nonce))
binding = f"{host}|{nonce}".encode()
expected_challenge = base64.urlsafe_b64encode(hashlib.sha256(binding).digest()).rstrip(b"=").decode()
cdj_bytes = _b64u_decode(bundle["cdj"])
cdj = json.loads(cdj_bytes)
if cdj.get("type") != "webauthn.get":
return False
if cdj.get("challenge") != expected_challenge:
return False
# Verify ECDSA-P256 signature over authData || SHA256(clientDataJson).
auth_data = _b64u_decode(bundle["authData"])
signature = _b64u_decode(bundle["sig"])
client_data_hash = hashlib.sha256(cdj_bytes).digest()
message = auth_data + client_data_hash
# Extract P-256 public key from AttestedCredentialData.
cred_data = _b64u_decode(bundle["cred"])
cred_id_len = (cred_data[16] << 8) | cred_data[17]
cose_bytes = cred_data[18 + cred_id_len:]
cose_key = cbor2.loads(cose_bytes)
x = cose_key[-2]
y = cose_key[-3]
pub_key = EllipticCurvePublicNumbers(
x=int.from_bytes(x, "big"),
y=int.from_bytes(y, "big"),
curve=SECP256R1(),
).public_key(default_backend())
pub_key.verify(signature, message, ECDSA(SHA256()))
return True
except (InvalidSignature, Exception):
return False
class ServerState:
# All state is process-local; a restart resets the counter to zero.
def __init__(self, proxy_token: str, protected_host: str = "127.0.0.1"):
def __init__(self, proxy_token: str):
self.proxy_token = proxy_token
self.protected_host = protected_host
self.counter = 0
self.lock = threading.Lock()
@ -125,20 +45,12 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body)
def _discard_request_body(self) -> None:
# HTTP/1.1 keep-alive: the connection is reused, so the body must be fully
# consumed before we send the response, even for endpoints that ignore it.
length = int(self.headers.get("Content-Length", "0"))
if length > 0:
self.rfile.read(length)
def _is_proxy_authorized(self) -> bool:
# Accept legacy X-Proxy-Token (k_proxy_app.py) or FIDO2 assertion Bearer.
if self.headers.get("X-Proxy-Token") == self.state.proxy_token:
return True
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return _verify_assertion_token(auth[7:].strip(), self.state.protected_host)
return False
return self.headers.get("X-Proxy-Token") == self.state.proxy_token
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
@ -187,11 +99,6 @@ def parse_args() -> argparse.Namespace:
default="dev-proxy-token",
help="Shared token expected in X-Proxy-Token from k_proxy",
)
parser.add_argument(
"--protected-host",
default="127.0.0.1",
help="Hostname this server protects; Bearer tokens must be issued for this host",
)
return parser.parse_args()
@ -200,7 +107,7 @@ def main() -> int:
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, protected_host=args.protected_host)
state = ServerState(proxy_token=args.proxy_token)
Handler.state = state
server = ThreadingHTTPServer((args.host, args.port), Handler)
scheme = "http"

6
package-lock.json generated
View File

@ -7,9 +7,6 @@
"": {
"name": "chromecard-browser-regression",
"version": "0.1.0",
"dependencies": {
"playwright": "^1.59.1"
},
"devDependencies": {
"@playwright/test": "^1.54.2"
}
@ -34,6 +31,7 @@
"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,
@ -48,6 +46,7 @@
"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"
@ -66,6 +65,7 @@
"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"

View File

@ -8,8 +8,5 @@
},
"devDependencies": {
"@playwright/test": "^1.54.2"
},
"dependencies": {
"playwright": "^1.59.1"
}
}

View File

@ -3,7 +3,7 @@ const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./tests",
timeout: 60_000,
timeout: 180_000,
expect: {
timeout: 15_000,
},

View File

@ -1,339 +0,0 @@
"""
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

@ -1,349 +0,0 @@
#!/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

@ -1,288 +0,0 @@
/**
* Acceptance tests for k_phone proxy routing Chrome inside the Android emulator.
*
* Same four serial tests as k_phone_proxy.spec.js, but the browser runs inside
* the emulator via Playwright's Android module. From inside the emulator
* 127.0.0.1:8888 IS Component 1 (filter_proxy.dart) no adb port-forward needed.
*
* Prerequisites:
* 1. Android emulator running with the k_phone app started.
* 2. ADB connected: adb devices shows the emulator.
* 3. card_emulator_bridge.py running on the Mac (auto-approves FIDO2 assertions):
* uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
* tests/card_emulator_bridge.py
*
* Run:
* npx playwright test tests/k_phone_android.spec.js
* npx playwright test tests/k_phone_android.spec.js --headed # shows emulator Chrome
*
* Tests skip automatically if no Android device/emulator is found via ADB.
*
* Env vars:
* GATED_URL URL of a gated resource (default: http://httpbin.org/get)
* GATED_METHOD HTTP method for gated request (default: GET)
* UNGATED_URL URL of a non-gated resource (default: http://example.com)
* CARD_REGISTRATION_TIMEOUT_MS (default: 90000)
* CARD_LOGIN_TIMEOUT_MS (default: 90000)
*
* Note on proxy bypass:
* Chrome bypasses --proxy-server for 127.0.0.1 / localhost by default.
* Portal API calls (127.0.0.1:8771) therefore reach Component 2 directly.
* External host requests (httpbin.org, example.com) go through Component 1.
*/
const { test, expect, chromium } = require('@playwright/test');
const { execSync } = require('child_process');
const ADB = (() => {
const candidates = [
process.env.ADB,
`${process.env.HOME}/Library/Android/sdk/platform-tools/adb`,
'/usr/local/bin/adb',
].filter(Boolean);
for (const p of candidates) {
try { execSync(`"${p}" version`, { stdio: 'pipe' }); return p; } catch {}
}
return null;
})();
const CDP_PORT = 9222;
const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get';
const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase();
const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com';
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000');
const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000');
// Component 2 is always at 127.0.0.1:8771 from inside the emulator.
const PORTAL = 'http://127.0.0.1:8771';
function uniqueUsername() {
return `pw_${Date.now().toString(36)}`;
}
// ---------------------------------------------------------------------------
// chromeFetch — makes an HTTP request from inside Android Chrome.
//
// fetch() in the page context uses Chrome's --proxy-server for external hosts
// and bypasses the proxy for 127.0.0.1. Returns { status, ok, body }.
// ---------------------------------------------------------------------------
async function chromeFetch(page, url, { method = 'GET', data = null, timeoutMs = 15_000 } = {}) {
return page.evaluate(
async ({ url, method, data, timeoutMs }) => {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const opts = { method, signal: ctrl.signal };
if (data !== null) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(data);
}
const r = await fetch(url, opts);
clearTimeout(tid);
let body = null;
try { body = await r.clone().json(); } catch { body = await r.text(); }
return { status: r.status, ok: r.ok, body };
} catch (e) {
clearTimeout(tid);
return { status: 0, ok: false, error: e.message };
}
},
{ url, method, data, timeoutMs },
);
}
// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
test.describe.serial('k_phone proxy routing — Android Chrome', () => {
let browser = null; // CDP-connected browser (chromium.connectOverCDP)
let proxyCtx = null; // BrowserContext for test pages
let skipReason = null;
let enrolledUser = null;
test.beforeAll(async () => {
if (!ADB) {
skipReason = 'adb not found — install Android SDK platform-tools';
return;
}
try {
const devicesOut = execSync(`"${ADB}" devices`, { stdio: 'pipe' }).toString();
const connected = devicesOut.split('\n').slice(1).some(l => l.includes('\tdevice'));
if (!connected) {
skipReason = 'No Android emulator connected — run: adb devices';
return;
}
// Write --proxy-server flag into Chrome's command-line file, then restart.
// Chrome on Android reads /data/local/tmp/chrome-command-line on startup.
execSync(`"${ADB}" shell "echo 'chrome --proxy-server=127.0.0.1:8888' > /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
execSync(`"${ADB}" shell "chmod 644 /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
execSync(`"${ADB}" shell am force-stop com.android.chrome`, { stdio: 'pipe' });
await new Promise(r => setTimeout(r, 1200));
execSync(`"${ADB}" shell am start -n com.android.chrome/com.google.android.apps.chrome.Main about:blank`, { stdio: 'pipe' });
// Poll until Chrome's CDP socket appears (up to 15 s).
execSync(`"${ADB}" forward tcp:${CDP_PORT} localabstract:chrome_devtools_remote`, { stdio: 'pipe' });
browser = null;
for (let attempt = 0; attempt < 15; attempt++) {
await new Promise(r => setTimeout(r, 1000));
try {
browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`, { timeout: 3000 });
break;
} catch {}
}
if (!browser) throw new Error('Chrome CDP not ready after 15 s');
proxyCtx = browser.contexts()[0] ?? await browser.newContext();
// Clean state: delete any users left from previous runs.
// 127.0.0.1 bypasses the proxy, so these calls reach Component 2 directly.
const page = await proxyCtx.newPage();
const list = await chromeFetch(page, `${PORTAL}/enroll/list`);
for (const u of list.body?.users ?? []) {
await chromeFetch(page, `${PORTAL}/enroll/delete`, {
method: 'POST',
data: { username: u.username },
});
}
await page.close();
} catch (e) {
skipReason = `Android setup failed: ${e.message}`;
}
});
// Skip every test in the suite if the emulator was not found.
test.beforeEach(async () => {
if (skipReason) test.skip(true, skipReason);
});
test.afterAll(async () => {
if (enrolledUser && proxyCtx) {
const page = await proxyCtx.newPage().catch(() => null);
if (page) {
await chromeFetch(page, `${PORTAL}/enroll/delete`, {
method: 'POST',
data: { username: enrolledUser },
}).catch(() => {});
await page.close().catch(() => {});
}
}
await browser?.close().catch(() => {});
try { execSync(`"${ADB}" forward --remove tcp:${CDP_PORT}`, { stdio: 'pipe' }); } catch {}
try { execSync(`"${ADB}" shell rm /data/local/tmp/chrome-command-line`, { stdio: 'pipe' }); } catch {}
});
// ---------------------------------------------------------------------------
// Test 1: no users — non-gated request passes through.
//
// Chrome navigates to a non-gated host. Component 1 forwards the traffic
// directly without contacting Component 2 or the card.
// ---------------------------------------------------------------------------
test('1. no users: non-gated request passes through', async () => {
const page = await proxyCtx.newPage();
try {
const response = await page.goto(UNGATED_URL, {
timeout: 15_000,
waitUntil: 'commit',
});
expect(response?.status()).toBeLessThan(500);
} finally {
await page.close();
}
});
// ---------------------------------------------------------------------------
// Test 2: no users — gated request blocked.
//
// Component 1 asks Component 2 for a token; Component 2 finds no enrolled
// user and returns an error; Component 1 responds 407. Chrome's fetch()
// surfaces this as either a 407 response or a network error.
// ---------------------------------------------------------------------------
test('2. no users: gated request blocked', async () => {
const page = await proxyCtx.newPage();
try {
const result = await chromeFetch(page, GATED_URL, { method: GATED_METHOD });
// 407 (proxy auth required) or status 0 (network error) both mean blocked.
expect(result.ok).toBe(false);
} finally {
await page.close();
}
});
// ---------------------------------------------------------------------------
// Test 3: register user — non-gated request still passes through.
//
// Card step: makeCredential. card_emulator_bridge.py auto-approves instantly
// — no physical fingerprint touch needed.
// ---------------------------------------------------------------------------
test('3. enroll user: non-gated request still passes through', async () => {
test.setTimeout(registrationTimeoutMs + 30_000);
enrolledUser = uniqueUsername();
const page = await proxyCtx.newPage();
try {
// The portal at 127.0.0.1 bypasses the proxy and loads directly from Component 2.
await page.goto(`${PORTAL}/`);
await page.locator('#username').fill(enrolledUser);
await page.locator('#displayName').fill('Android Chrome Test');
await page.locator('#enrollBtn').click();
await expect(page.locator('#log')).toContainText('Enrolled', {
timeout: registrationTimeoutMs,
});
await expect(page.locator('#storedUser')).toHaveText(enrolledUser);
// Non-gated traffic must still be forwarded directly after enrollment.
const response = await page.goto(UNGATED_URL, {
timeout: 15_000,
waitUntil: 'commit',
});
expect(response?.status()).toBeLessThan(500);
} finally {
await page.close();
}
});
// ---------------------------------------------------------------------------
// Test 4: with enrolled user — gated request succeeds after card assertion.
//
// Card step: getAssertion. card_emulator_bridge.py auto-approves instantly.
//
// fetch() inside Chrome flows:
// Chrome → Component 1 (127.0.0.1:8888) → POST /auth/get-token →
// Component 2 → card emulator bridge (10.0.2.2:8772) → assertion bundle →
// Component 1 → gated endpoint with Authorization: Bearer → 200 response.
//
// Verification:
// httpbin.org echoes the Authorization: Bearer header back in the JSON body.
// k_server (GATED_URL=http://k-server-ip:8780/resource/counter) validates
// the assertion cryptographically and returns {ok, resource, value}.
// ---------------------------------------------------------------------------
test('4. enrolled user: gated request succeeds — card asserted', async () => {
test.setTimeout(cardAssertionTimeoutMs + 30_000);
const page = await proxyCtx.newPage();
try {
const result = await chromeFetch(page, GATED_URL, {
method: GATED_METHOD,
timeoutMs: cardAssertionTimeoutMs,
});
// 200 proves Component 2 performed the FIDO2 assertion successfully.
expect(result.status).toBe(200);
if (result.body?.headers?.Authorization !== undefined) {
// httpbin.org echoes request headers — the Bearer token must be present.
expect(result.body.headers.Authorization).toMatch(/^Bearer /i);
} else if (result.body?.resource !== undefined) {
// k_server validated the assertion token and incremented the counter.
expect(result.body.ok).toBe(true);
expect(result.body.resource).toBe('counter');
expect(typeof result.body.value).toBe('number');
}
} finally {
await page.close();
}
});
});

View File

@ -1,123 +0,0 @@
/**
* Playwright acceptance test for the k_phone portal (Component 2, port 8771).
*
* Run:
* K_PHONE_BASE_URL=http://192.168.x.x:8771 npx playwright test tests/k_phone_portal.spec.js
*
* Env vars:
* K_PHONE_BASE_URL Base URL of the k_phone proxy service (default: http://127.0.0.1:8771)
* CARD_REGISTRATION_TIMEOUT_MS Timeout for makeCredential card step (default: 90000)
* CARD_LOGIN_TIMEOUT_MS Timeout for getAssertion card step (default: 90000)
* PW_HEADLESS Set to "1" for headless mode
*
* Constraint: the test does not read the Android log all assertions are
* made against visible DOM state and the #log pre element.
*/
const { test, expect } = require("@playwright/test");
const BASE_URL = process.env.K_PHONE_BASE_URL || "http://127.0.0.1:8771";
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 waitForLog(page, expectedText, timeoutMs = 10_000) {
await expect(page.locator("#log")).toContainText(expectedText, { timeout: timeoutMs });
}
test.describe("k_phone portal regression", () => {
test(
"enrolls, logs in, checks session status, logs out, and deletes user",
async ({ page }) => {
const username = uniqueUsername();
test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 60_000);
await page.goto(BASE_URL + "/");
await expect(
page.getByRole("heading", { name: "ChromeCard k_phone Portal" })
).toBeVisible();
// Clear any leftover localStorage from a previous session so the test
// starts from a clean slate regardless of browser profile state.
await page.evaluate(() => localStorage.clear());
await page.reload();
await test.step("Initial state is unauthenticated", async () => {
await expect(page.locator("#storedUser")).toHaveText("none");
await expect(page.locator("#sessionActive")).toHaveText("no");
});
await test.step("Enroll user", async () => {
await page.locator("#username").fill(username);
await page.locator("#displayName").fill("Playwright Test");
// Card step: makeCredential — touch user fingerprint on ChromeCard.
await page.locator("#enrollBtn").click();
await waitForLog(page, "Enrolled", registrationTimeoutMs);
await expect(page.locator("#storedUser")).toHaveText(username);
});
await test.step("Login", async () => {
// Card step: getAssertion — touch user fingerprint on ChromeCard.
await page.locator("#loginBtn").click();
await waitForLog(page, "Login ok", loginTimeoutMs);
await expect(page.locator("#sessionActive")).toHaveText("yes");
});
await test.step("Session status reflects active session", async () => {
await page.locator("#statusBtn").click();
await waitForLog(page, "Session status");
});
await test.step("List users includes enrolled user", async () => {
await page.locator("#listBtn").click();
await waitForLog(page, username);
});
await test.step("Logout clears session", async () => {
await page.locator("#logoutBtn").click();
// "Logout" is a substring of "Logout failed", so assert the semantic
// outcome (sessionActive → no) rather than the log message text.
await expect(page.locator("#sessionActive")).toHaveText("no", {
timeout: 10_000,
});
});
await test.step("Delete user clears stored identity", async () => {
await page.locator("#deleteBtn").click();
// "Deleted" is not a substring of "Delete failed" — safe to match.
await waitForLog(page, "Deleted");
await expect(page.locator("#storedUser")).toHaveText("none");
await expect(page.locator("#sessionActive")).toHaveText("no");
});
}
);
test("enrollment failure is surfaced in log", async ({ page }) => {
await page.goto(BASE_URL + "/");
await page.evaluate(() => localStorage.clear());
await page.reload();
// Submit enroll with an empty username — server must reject it.
await page.locator("#username").fill("");
await page.locator("#enrollBtn").click();
await waitForLog(page, "Enroll failed");
// No username must have been stored on failure.
await expect(page.locator("#storedUser")).toHaveText("none");
});
test("login without enrollment fails gracefully", async ({ page }) => {
await page.goto(BASE_URL + "/");
await page.evaluate(() => localStorage.clear());
await page.reload();
// Attempt login with a username that is not enrolled.
await page.locator("#username").fill("no_such_user_pw");
await page.locator("#loginBtn").click();
await waitForLog(page, "Login failed");
await expect(page.locator("#sessionActive")).toHaveText("no");
});
});

View File

@ -1,204 +0,0 @@
/**
* Acceptance tests for k_phone Component 1 (filter_proxy) routing behaviour.
*
* Four tests run serially, building shared state:
* 1. No users non-gated request passes through directly.
* 2. No users gated request is rejected (407 Proxy Authentication Required).
* 3. Register user non-gated request still passes through.
* 4. (User enrolled) gated request succeeds after card assertion.
*
* HTTP proxy requests are made with Node's `http` module so the proxy protocol
* (absolute URI in the request line) is exact and Playwright's browser proxy
* handling is not involved. The portal page is used for enrollment (test 3)
* because that step requires the user to touch the card fingerprint.
*
* Run:
* K_PHONE_PROXY=http://phone-ip:8888 \
* K_PHONE_BASE_URL=http://phone-ip:8771 \
* GATED_URL=http://httpbin.org/get \
* npx playwright test tests/k_phone_proxy.spec.js
*
* Env vars:
* K_PHONE_PROXY Component 1 proxy URL (default: http://127.0.0.1:8888)
* K_PHONE_BASE_URL Component 2 portal URL (default: http://127.0.0.1:8771)
* GATED_URL URL of a gated resource (default: http://httpbin.org/get)
* GATED_METHOD HTTP method for gated request (default: GET)
* UNGATED_URL URL of a non-gated resource (default: http://example.com)
* CARD_REGISTRATION_TIMEOUT_MS makeCredential card step (default: 90000)
* CARD_LOGIN_TIMEOUT_MS getAssertion card step (default: 90000)
*
* Gated host configuration:
* gated_hosts.txt on the phone must contain the host from GATED_URL.
* The app seeds httpbin.org by default; no manual edit needed for the default case.
* For full chain validation against k_server (which verifies the FIDO2 token):
* GATED_URL=http://k-server-ip:8780/resource/counter GATED_METHOD=POST
*/
const { test, expect } = require('@playwright/test');
const http = require('http');
const PROXY_URL = process.env.K_PHONE_PROXY || 'http://127.0.0.1:8888';
const PORTAL_URL = process.env.K_PHONE_BASE_URL || 'http://127.0.0.1:8771';
const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get';
const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase();
const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com';
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000');
const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000');
function uniqueUsername() {
return `pw_${Date.now().toString(36)}`;
}
// ---------------------------------------------------------------------------
// HTTP proxy helper — sends one request through Component 1.
//
// Sends `method targetUrl HTTP/1.1` (absolute URI — the proxy protocol) to
// the proxy host:port and returns { status, body }. The caller sets the
// timeout via the options object.
// ---------------------------------------------------------------------------
function proxyRequest(proxyUrl, method, targetUrl, timeoutMs = 15_000) {
return new Promise((resolve, reject) => {
const proxy = new URL(proxyUrl);
const target = new URL(targetUrl);
const req = http.request(
{
hostname: proxy.hostname,
port: Number(proxy.port) || 80,
method,
path: targetUrl, // absolute URI → proxy protocol
headers: { Host: target.host },
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
},
);
req.setTimeout(timeoutMs, () => req.destroy(new Error(`proxy request to ${targetUrl} timed out after ${timeoutMs} ms`)));
req.on('error', reject);
req.end();
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe.serial('k_phone proxy routing', () => {
let enrolledUser = null;
// Ensure no users are enrolled before the suite runs so tests 1 and 2 start
// from a clean state — a gated request with no user must be rejected.
test.beforeAll(async ({ request }) => {
const resp = await request.get(`${PORTAL_URL}/enroll/list`);
const { users } = await resp.json();
for (const u of users ?? []) {
await request.post(`${PORTAL_URL}/enroll/delete`, {
data: { username: u.username },
headers: { 'Content-Type': 'application/json' },
});
}
});
// Remove the user enrolled in test 3 after the suite finishes.
test.afterAll(async ({ request }) => {
if (enrolledUser) {
await request.post(`${PORTAL_URL}/enroll/delete`, {
data: { username: enrolledUser },
headers: { 'Content-Type': 'application/json' },
});
}
});
// ---------------------------------------------------------------------------
// Test 1: no users — non-gated request passes through.
//
// Component 1 forwards non-gated traffic directly to the target host on
// port 80 without touching Component 2 or the card.
// ---------------------------------------------------------------------------
test('1. no users: non-gated request passes through', async () => {
const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL);
expect(status).toBeLessThan(500);
});
// ---------------------------------------------------------------------------
// Test 2: no users — gated request rejected with 407.
//
// Component 1 calls Component 2 for a Bearer token. Component 2 has no
// enrolled user and returns an error. Component 1 replies with
// 407 Proxy Authentication Required.
// ---------------------------------------------------------------------------
test('2. no users: gated request rejected with 407', async () => {
const { status } = await proxyRequest(PROXY_URL, GATED_METHOD, GATED_URL);
expect(status).toBe(407);
});
// ---------------------------------------------------------------------------
// Test 3: register user — non-gated request still passes through.
//
// Card step: makeCredential (touch user fingerprint on ChromeCard).
// ---------------------------------------------------------------------------
test('3. enroll user: non-gated request still passes through', async ({ page }) => {
test.setTimeout(registrationTimeoutMs + 30_000);
enrolledUser = uniqueUsername();
// Enroll via portal — requires card fingerprint for makeCredential.
await page.goto(`${PORTAL_URL}/`);
await page.locator('#username').fill(enrolledUser);
await page.locator('#displayName').fill('Playwright Proxy Test');
await page.locator('#enrollBtn').click();
await expect(page.locator('#log')).toContainText('Enrolled', {
timeout: registrationTimeoutMs,
});
await expect(page.locator('#storedUser')).toHaveText(enrolledUser);
// Non-gated traffic must still be forwarded directly — enrollment must not
// break the direct-forward path.
const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL);
expect(status).toBeLessThan(500);
});
// ---------------------------------------------------------------------------
// Test 4: enrolled user — gated request succeeds after card assertion.
//
// Card step: getAssertion (touch user fingerprint on ChromeCard).
//
// The 200 response proves:
// - Component 1 fetched a token from Component 2.
// - Component 2 performed a FIDO2 assertion against the enrolled credential.
// - Component 1 forwarded the request to the gated endpoint with the token.
//
// Response body check (both targets):
// httpbin.org — echoes the Authorization: Bearer header in its JSON response.
// k_server — validates the assertion cryptographically and returns
// { ok: true, resource: "counter", value: N }.
// ---------------------------------------------------------------------------
test('4. enrolled user: gated request succeeds — card asserted', async () => {
test.setTimeout(cardAssertionTimeoutMs + 30_000);
const { status, body } = await proxyRequest(
PROXY_URL, GATED_METHOD, GATED_URL, cardAssertionTimeoutMs,
);
// 200 proves the card assertion was performed and the token was accepted.
expect(status).toBe(200);
// Verify the token was actually forwarded to the target endpoint.
let parsed = null;
try { parsed = JSON.parse(body); } catch (_) {}
if (parsed?.headers?.Authorization !== undefined) {
// httpbin.org echoes request headers — the Bearer token must be present.
expect(parsed.headers.Authorization).toMatch(/^Bearer /i);
} else if (parsed?.resource !== undefined) {
// k_server validated the assertion and returned the counter value.
expect(parsed.ok).toBe(true);
expect(parsed.resource).toBe('counter');
expect(typeof parsed.value).toBe('number');
}
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,362 +0,0 @@
"""
Unit + round-trip tests for k_server_app._verify_assertion_token.
Run:
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
python3 -m unittest tests/test_k_server.py
The unit tests (TestVerifyAssertionToken) only need cbor2 + cryptography.
The round-trip tests (TestVerifyAssertionTokenRoundTrip) also need fido2
(through CardEmulator) they are skipped automatically if fido2 is absent.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import struct
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
sys.path.insert(0, os.path.dirname(__file__))
import k_server_app
# ---------------------------------------------------------------------------
# Dependency guards
# ---------------------------------------------------------------------------
try:
import cbor2 # noqa: F401
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.ec import (
ECDSA,
SECP256R1,
generate_private_key,
)
from cryptography.hazmat.primitives.hashes import SHA256
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
try:
from card_emulator import CardEmulator
HAS_FIDO2 = True
except ImportError:
HAS_FIDO2 = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _b64u_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def _b64u_decode(s: str) -> bytes:
padded = s + "=" * ((4 - len(s) % 4) % 4)
return base64.urlsafe_b64decode(padded)
# COSE ES256 key layout matching card_emulator._cose_es256 exactly.
def _cose_es256(x: bytes, y: bytes) -> bytes:
return (
bytes([0xA5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20])
+ x
+ bytes([0x22, 0x58, 0x20])
+ y
)
def _make_bundle(host: str, nonce: str) -> tuple[str, object]:
"""Return (base64url-token, private_key) for a fresh P-256 assertion.
Mirrors exactly what proxy_service.dart's _handleAuthGetToken produces.
"""
priv = generate_private_key(SECP256R1(), default_backend())
pub = priv.public_key().public_numbers()
x = pub.x.to_bytes(32, "big")
y = pub.y.to_bytes(32, "big")
cose_key = _cose_es256(x, y)
# AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey
aaguid = bytes.fromhex("1234567890abcdef0123456789abcdef")
cred_id = os.urandom(16)
cred_data = aaguid + struct.pack(">H", len(cred_id)) + cred_id + cose_key
# Challenge = SHA256(host|nonce) — same as proxy_service.dart
challenge_b64u = _b64u_encode(hashlib.sha256(f"{host}|{nonce}".encode()).digest())
cdj = json.dumps(
{
"type": "webauthn.get",
"challenge": challenge_b64u,
"origin": "https://localhost",
"crossOrigin": False,
},
separators=(",", ":"),
)
cdj_bytes = cdj.encode()
cdh = hashlib.sha256(cdj_bytes).digest()
# authData: rpIdHash(32) + flags(1) + signCount(4)
auth_data = hashlib.sha256(b"localhost").digest() + b"\x01" + struct.pack(">I", 1)
sig = priv.sign(auth_data + cdh, ECDSA(SHA256()))
bundle = {
"v": 1,
"host": host,
"nonce": nonce,
"authData": _b64u_encode(auth_data),
"sig": _b64u_encode(sig),
"cdj": _b64u_encode(cdj_bytes),
"cred": _b64u_encode(cred_data),
"user": "testuser",
}
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode()), priv
def _tamper(token: str, key: str, transform) -> str:
bundle = json.loads(_b64u_decode(token))
bundle[key] = transform(bundle[key])
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
# ---------------------------------------------------------------------------
# Group 1 — unit tests (cbor2 + cryptography only)
# ---------------------------------------------------------------------------
@unittest.skipUnless(HAS_CRYPTO, "cbor2 / cryptography not installed")
class TestVerifyAssertionToken(unittest.TestCase):
def setUp(self):
self.host = "127.0.0.1"
self.nonce = "deadbeef01234567"
self.token, _ = _make_bundle(self.host, self.nonce)
def _check(self, token=None, host=None) -> bool:
return k_server_app._verify_assertion_token(
self.token if token is None else token,
host if host is not None else self.host,
)
def test_valid_token_accepted(self):
self.assertTrue(self._check())
def test_token_valid_for_any_path_on_host(self):
# Domain-level binding: the same token covers all paths on the host.
self.assertTrue(self._check())
def test_cross_server_replay_rejected(self):
# Token issued for self.host must not pass when a different server verifies it.
self.assertFalse(self._check(host="other-server.com"))
def test_cross_server_replay_case_insensitive(self):
# Case variation of the expected host still rejects a token for a different host.
token_b, _ = _make_bundle("BANK.com", self.nonce)
self.assertFalse(k_server_app._verify_assertion_token(token_b, "evil.com"))
def test_tampered_nonce_invalidates_challenge(self):
tampered = _tamper(self.token, "nonce", lambda _: "tampered00000000")
self.assertFalse(self._check(tampered))
def test_tampered_host_invalidates_challenge(self):
tampered = _tamper(self.token, "host", lambda _: "attacker.com")
self.assertFalse(self._check(tampered))
def test_tampered_signature_rejected(self):
def flip_last_byte(b64: str) -> str:
raw = bytearray(_b64u_decode(b64))
raw[-1] ^= 0xFF
return _b64u_encode(bytes(raw))
tampered = _tamper(self.token, "sig", flip_last_byte)
self.assertFalse(self._check(tampered))
def test_wrong_public_key_rejected(self):
other = generate_private_key(SECP256R1(), default_backend())
pub = other.public_key().public_numbers()
x = pub.x.to_bytes(32, "big")
y = pub.y.to_bytes(32, "big")
new_cose = _cose_es256(x, y)
def swap_key(b64: str) -> str:
orig = _b64u_decode(b64)
cred_id_len = (orig[16] << 8) | orig[17]
return _b64u_encode(orig[:18 + cred_id_len] + new_cose)
tampered = _tamper(self.token, "cred", swap_key)
self.assertFalse(self._check(tampered))
def test_malformed_token_returns_false(self):
self.assertFalse(self._check(token="!!!not-base64!!!"))
def test_empty_token_returns_false(self):
self.assertFalse(self._check(token=""))
def test_missing_field_returns_false(self):
bundle = json.loads(_b64u_decode(self.token))
del bundle["sig"]
truncated = _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
self.assertFalse(self._check(truncated))
def test_cdj_wrong_type_rejected(self):
bundle = json.loads(_b64u_decode(self.token))
cdj = json.loads(_b64u_decode(bundle["cdj"]))
cdj["type"] = "webauthn.create" # wrong type
bundle["cdj"] = _b64u_encode(json.dumps(cdj, separators=(",", ":")).encode())
tampered = _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
self.assertFalse(self._check(tampered))
# ---------------------------------------------------------------------------
# Group 2 — end-to-end round-trip via CardEmulator (needs fido2)
# ---------------------------------------------------------------------------
@unittest.skipUnless(HAS_FIDO2, "fido2 not installed")
class TestVerifyAssertionTokenRoundTrip(unittest.TestCase):
"""Full round-trip: CardEmulator → assertion bundle → server verification.
Mirrors the actual k_phone flow:
make_credential (enrollment) get_assertion (per-request binding)
bundle as k_server_app.py expects _verify_assertion_token.
"""
def _register_and_assert(
self,
emulator: "CardEmulator",
host: str,
nonce: str,
) -> str:
"""Return a token string after one register + one assertion."""
# 1. Register — mirrors makeCredential in fido2_ops.dart
reg_cdh = hashlib.sha256(b"registration-placeholder").digest()
attest = emulator.make_credential(
client_data_hash=reg_cdh,
rp={"id": "localhost", "name": "ChromeCard"},
user={"id": b"testuid", "name": "alice"},
key_params=[{"type": "public-key", "alg": -7}],
)
# AttestedCredentialData = authData[37:]
auth_data_make = bytes(attest.auth_data)
cred_data = auth_data_make[37:]
cred_id_len = (cred_data[16] << 8) | cred_data[17]
cred_id = cred_data[18:18 + cred_id_len]
# 2. Assert with domain-level challenge — mirrors _handleAuthGetToken in proxy_service.dart
challenge_b64u = _b64u_encode(
hashlib.sha256(f"{host}|{nonce}".encode()).digest()
)
cdj = json.dumps(
{
"type": "webauthn.get",
"challenge": challenge_b64u,
"origin": "https://localhost",
"crossOrigin": False,
},
separators=(",", ":"),
)
cdj_bytes = cdj.encode()
assertion = emulator.get_assertion(
rp_id="localhost",
client_data_hash=hashlib.sha256(cdj_bytes).digest(),
allow_list=[{"type": "public-key", "id": cred_id}],
)
# 3. Encode bundle — same as proxy_service.dart _handleAuthGetToken
bundle = {
"v": 1,
"host": host,
"nonce": nonce,
"authData": _b64u_encode(bytes(assertion.auth_data)),
"sig": _b64u_encode(bytes(assertion.signature)),
"cdj": _b64u_encode(cdj_bytes),
"cred": _b64u_encode(cred_data),
"user": "alice",
}
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
def test_roundtrip_accepted(self):
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "cafebabe12345678")
self.assertTrue(
k_server_app._verify_assertion_token(token, "example.com"),
"valid round-trip token must be accepted",
)
def test_roundtrip_valid_for_any_path(self):
"""Domain-level token: accepted regardless of which path is requested."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "aabbccdd11223344")
self.assertTrue(
k_server_app._verify_assertion_token(token, "example.com"),
"domain-level token must be accepted for any path on the host",
)
def test_roundtrip_same_token_tampered_nonce_rejected(self):
"""Changing the nonce in a real assertion bundle breaks verification."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "original00000000")
tampered = _tamper(token, "nonce", lambda _: "tampered11111111")
self.assertFalse(
k_server_app._verify_assertion_token(tampered, "example.com"),
"tampered nonce must break challenge verification",
)
def test_roundtrip_tampered_host_rejected(self):
"""Changing the host in the bundle breaks challenge verification."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "deadbeef00112233")
tampered = _tamper(token, "host", lambda _: "attacker.com")
self.assertFalse(
k_server_app._verify_assertion_token(tampered, "example.com"),
"tampered host must break challenge verification",
)
def test_roundtrip_cross_server_replay_rejected(self):
"""Token issued for server-a must not validate on server-b."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "server-a.com", "1122334455667788")
self.assertFalse(
k_server_app._verify_assertion_token(token, "server-b.com"),
"cross-server replay: token for server-a must be rejected by server-b",
)
def test_roundtrip_cross_server_replay_accepted_on_correct_server(self):
"""Sanity: same token is accepted on the server it was issued for."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "server-a.com", "aabbccdd99887766")
self.assertTrue(
k_server_app._verify_assertion_token(token, "server-a.com"),
"token must still be valid on the correct server",
)
def test_roundtrip_replayed_for_different_user_rejected(self):
"""Two users register separate credentials; each token is only valid for its own key."""
em_a = CardEmulator()
em_b = CardEmulator()
host, nonce = "example.com", "00112233aabbccdd"
token_a = self._register_and_assert(em_a, host, nonce)
token_b = self._register_and_assert(em_b, host, nonce)
# token_a's signature was made with em_a's key — must not verify with em_b's public key.
# Tamper: swap cred (public key) from token_b into token_a's bundle.
bundle_a = json.loads(_b64u_decode(token_a))
bundle_b = json.loads(_b64u_decode(token_b))
bundle_a["cred"] = bundle_b["cred"]
cross = _b64u_encode(json.dumps(bundle_a, separators=(",", ":")).encode())
self.assertFalse(
k_server_app._verify_assertion_token(cross, host),
"cross-user key swap must fail verification",
)
if __name__ == "__main__":
unittest.main(verbosity=2)