Compare commits

..

6 Commits

Author SHA1 Message Date
Morten V. Christiansen c6294a46c7 Add Playwright acceptance tests for k_phone proxy routing
Three new specs in tests/:
- k_phone_portal.spec.js: portal UI flow (enroll/login/status/logout/delete)
- k_phone_proxy.spec.js: 4 serial proxy-routing tests via Node http module;
  requires adb forward for emulator use
- k_phone_android.spec.js: same 4 tests with Chrome running inside the
  Android emulator via playwright.android; no port-forward needed,
  auto-skips if no ADB device found

All tests use card_emulator_bridge.py for instant FIDO2 auto-approval —
no physical card or fingerprint interaction required in emulator mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:43:40 +02:00
Morten V. Christiansen 6f08c7eed4 Add k_server assertion verification tests + clarify session login comment
tests/test_k_server.py:
  - TestVerifyAssertionToken (12 tests): unit tests using raw P-256 keys —
    valid accept, wrong path/method, tampered nonce/signature/key, cross-
    resource replay, malformed/empty token, wrong cdj type, missing field.
  - TestVerifyAssertionTokenRoundTrip (5 tests): end-to-end via CardEmulator
    — register, getAssertion with bound challenge, build bundle as k_phone
    does, verify on server.  Tests include wrong path/method and cross-user
    key swap.  Skipped automatically if fido2 is not installed.
  All 17 pass.

proxy_service.dart: add comment to _handleSessionLogin explaining why
  random challenge is correct there (user-presence proof for portal session,
  not per-request resource binding).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:16:08 +02:00
Morten V. Christiansen 3bc47deb27 Extend filter_proxy tests: verify /auth/get-token binding fields
- _mockTokenServer now reads and captures the request body instead of
  draining it — Completer type updated to ({HttpRequest, String rawBody})
- Two new tests: assert that url, method, nonce are present in the
  /auth/get-token request body; verify POST requests carry method=POST

48/48 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:05:46 +02:00
Morten V. Christiansen 3fc40fc395 Implement per-request FIDO2 token binding across all components
Each request to a gated endpoint now triggers a fresh FIDO2 assertion.
Challenge = SHA256(url|method|nonce) — bound to the specific resource.
The self-contained assertion bundle lets the server verify independently
without calling back to the phone.

- fido2_ops.dart: GetAssertionResult gains clientDataJson; getAssertion
  accepts optional challenge override
- proxy_service.dart: _handleAuthGetToken accepts {url,method,nonce},
  derives challenge, runs card assertion, returns b64url bundle
- filter_proxy.dart: _getAuthToken(uri, method) generates nonce and
  passes binding fields to Component 2
- component3/phone.go: stateless GetTokenForRequest(url, method) —
  no session caching, no expiry, one card touch per request
- component3/proxy.go: use GetTokenForRequest
- component3/main.go: remove --user flag (Component 2 picks enrolled user)
- k_server_app.py: _verify_assertion_token() — verifies path+method
  match, challenge claim, and ECDSA-P256 signature; accepts both
  legacy X-Proxy-Token and new Bearer assertion tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:01:23 +02:00
Morten V. Christiansen ffa5bea1c7 Remove MITM from Component 3; record per-request token binding decision
- Delete mitm.go: CA generation and HTTPS interception removed entirely
- proxy.go: remove handleGatedConnect, forwardToUpstream, MITM struct field;
  gated CONNECT now returns 407 with explanation
- main.go: remove --ca-dir flag and MITM initialisation
- Workplan.md: record per-request auth decision (challenge bound to
  URL + method + nonce; no session opened; may revisit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:47:34 +02:00
Morten V. Christiansen 920d702dea Refactor k_phone (v2) and add component3 Go binary
k_phone:
- filter_proxy.dart: extract _writeProxyHeaders/_forwardHttpRequest helpers,
  removing ~30 lines of duplication; simplify _handleDirectHttp signature
- proxy_service.dart: import portal_html, merge _serveHtml/_serveEnrollHtml →
  _serveHtmlBytes, extract _parseUsername/_parseUsernameAndDisplay helpers,
  remove dead _loadTlsContext stub, use SessionManager.ttlSeconds (872→455 lines)
- portal_html.dart (new): kPortalHtml/kEnrollHtml/kPortalHtmlBytes/kEnrollHtmlBytes
- session_manager.dart: expose ttlSeconds as public constant
- filter_proxy_test.dart: rewritten for v2 — gated HTTP tests now verify Bearer
  token injection to endpoint directly; 24/24 pass
- k_server_client.dart: deleted (dead code)

component3 (Go proxy — first commit of entire directory):
- gated.go: fix IsGated(host,port) — was silently missing host:port entries
- proxy.go: pass port to IsGated in both handleHTTP and handleConnect
- phone.go: add getToken() calling /auth/get-token to avoid unnecessary FIDO2
  card interactions; fix login() JSON field expires_in→ttl_seconds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:04:19 +02:00
20 changed files with 2185 additions and 543 deletions

View File

@ -82,18 +82,25 @@ Files are deployed to VMs via `scp <file> <host>:~` and run via `ssh <host> <cmd
Four physical devices: optional client computer, phone, chromecard, server.
**Devices:**
- **Client (optional):** Computer with browser configured to use the phone as HTTP/HTTPS proxy. No knowledge of the auth system.
- **Phone:** Central hub. Runs two components, hosts registration page, connects to chromecard via USB or WiFi.
- **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. Binary decision per request: host is gated → forward to Component 2 (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
- **Component 2 — FIDO2 client + URL recognition:** Receives all requests from Component 1. Detects registration-URL → triggers admin registration flow; other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, forwards to server via TLS).
- **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 proxy):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → Server (WebAuthn validates token) → resource returned.
- **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.
@ -101,6 +108,8 @@ Four physical devices: optional client computer, phone, chromecard, server.
- 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)
@ -130,11 +139,13 @@ Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP ro
### 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 → CONNECT or plain-HTTP relay through Component 2; non-gated → direct to target. 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/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), resource/counter endpoints, and CONNECT tunnels. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally.
**`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/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).
**`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.

View File

@ -1,6 +1,6 @@
# Setup
Last updated: 2026-04-29
Last updated: 2026-05-08
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.
@ -668,6 +668,29 @@ Session note (2026-04-27, card emulator and bug fixes):
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-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.

View File

@ -1,6 +1,6 @@
# Workplan
Last updated: 2026-04-29
Last updated: 2026-05-08
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
@ -529,23 +529,47 @@ Exit criteria:
## Phase 9: Migrate to Phone-Mediated Wireless Validation
Status (2026-05-02): **ACTIVE — Component 1 + Component 2 CONNECT handler complete**
Status (2026-05-04): **ACTIVE — Architecture v2 adopted; Component 1 + Component 2 CONNECT handler complete**
### Target architecture
### Architecture v2 changes (2026-05-04)
The following changes replace the v1 architecture. Source: `chromecard_arkitektur_v2.docx`.
**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:** Listens on a local port. Per-request binary decision: host is gated → forward to Component 2 via TLS; host is not gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
- **Component 2 — FIDO2 client + URL recognition:** Detects registration URL → admin registration flow (admin fingerprint + PIN); other gated URLs → FIDO2 assertion flow (user fingerprint → token → server via TLS).
- **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:** Browser → phone (comp 1 + 2) → card (user biometric) → server WebAuthn → resource
- **Flow B:** Browser → phone (comp 1 + 2, registration URL) → card (admin biometric) → enroll/delete user
- **Flow C:** Non-gated host → comp 1 → internet port 80 (no TLS, no card)
- **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 (from architecture doc):** PIN on card; user DB on-card vs. external; network-level access control on registration page.
**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`
@ -613,12 +637,58 @@ 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`
### Next action
1. **Add `_handleConnect` to `proxy_service.dart`** — CONNECT handler for gated HTTPS tunnels; checks `hasAnyActiveSession()`, connects to upstream, detaches socket, pipes bytes. Tests needed.
2. Deploy to a real Android phone with physical ChromeCard via USB
3. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
4. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
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)

64
component3/gated.go Normal file
View File

@ -0,0 +1,64 @@
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])
}

3
component3/go.mod Normal file
View File

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

57
component3/main.go Normal file
View File

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

68
component3/phone.go Normal file
View File

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

181
component3/proxy.go Normal file
View File

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

@ -46,11 +46,13 @@ 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,
});
}
@ -116,15 +118,17 @@ Future<MakeCredentialResult> makeCredential(
/// 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,
) async {
String credentialDataB64, {
Uint8List? challenge,
}) async {
final credData = _b64uDecode(credentialDataB64);
final credId = _extractCredentialId(credData);
final challenge = _randomBytes(32);
final clientDataJson = _buildClientDataJson('webauthn.get', challenge);
final actualChallenge = challenge ?? _randomBytes(32);
final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge);
final clientDataHash = _sha256(utf8.encode(clientDataJson));
final requestMap = CborMap({
@ -154,6 +158,7 @@ Future<GetAssertionResult> getAssertion(
authData: authData,
signature: signature,
clientDataHash: clientDataHash,
clientDataJson: clientDataJson,
);
}

View File

@ -1,29 +1,23 @@
// Component 1 HTTP proxy with URL gating filter.
// Component 1 HTTP proxy with URL gating filter (v2 architecture).
//
// All browser traffic enters here. The routing rule is a single binary decision:
// gated host relay through Component 2 on localhost:_component2Port
// other host forward directly to the target host:port
// 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)
//
// "Gated hosts" are resources that require FIDO2 card authentication before
// they can be accessed. Traffic to them is relayed through Component 2, which
// checks for an active session before forwarding.
// 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.
//
// Example gated_hosts.txt:
// # External test resource (requires card login)
// httpbin.org
//
// For HTTPS (CONNECT) traffic to gated hosts this proxy sends a CONNECT request
// to Component 2 and waits for its 200/4xx response before responding to the
// browser. This lets Component 2 enforce the session check before the TLS
// tunnel is established; the raw TLS bytes are never exposed to Component 2.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path_provider/path_provider.dart';
@ -362,9 +356,7 @@ class FilterProxy {
}
// ---------------------------------------------------------------------------
// Plain HTTP request (both gated and non-gated use the same handler here
// gating for plain HTTP is enforced by Component 2 when it receives the
// forwarded request and checks the Host header)
// Plain HTTP request
// ---------------------------------------------------------------------------
Future<void> _handleHttp(
@ -386,7 +378,6 @@ class FilterProxy {
final host = uri.host;
final port = uri.hasPort ? uri.port : 80;
final path = _relativePath(uri);
int contentLength = 0;
for (final h in headerLines) {
@ -396,35 +387,144 @@ class FilterProxy {
}
}
// For gated plain-HTTP hosts, route through Component 2; for others, direct.
final Socket upstream;
try {
if (_isGated(host, port)) {
upstream = await Socket.connect('127.0.0.1', _component2Port)
.timeout(const Duration(seconds: 5));
await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength);
} else {
upstream = await Socket.connect(host, port)
.timeout(const Duration(seconds: 10));
await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength);
}
} catch (e) {
}
// 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, method);
} 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()
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 per-request 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, String method) async {
final nonce = _secureHex(16);
final payload = utf8.encode(jsonEncode({
'url': uri.toString(),
'method': method,
'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');
..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:')) continue;
lower.startsWith('proxy-authorization:') ||
(bearerToken != null && lower.startsWith('authorization:'))) continue;
out.write('$h\r\n');
}
out.write('Connection: close\r\n\r\n');
}
upstream.add(utf8.encode(out.toString()));
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;
@ -442,20 +542,12 @@ class FilterProxy {
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();
},
onError: (_) { upstream.destroy(); client.destroy(); if (!done.isCompleted) done.complete(); },
cancelOnError: true,
);
await done.future;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
sub.cancel();
client.add(utf8.encode(

View File

@ -1,83 +0,0 @@
// Client for forwarding requests to k_server (:8780).
// Mirrors the k_proxy k_server leg in k_proxy_app.py.
import 'dart:io';
import 'dart:typed_data';
const String kServerHost = '127.0.0.1'; // k_server address (same device or Qubes forward)
const int kServerPort = 8780;
class KServerResponse {
final int statusCode;
final HttpHeaders headers;
final Uint8List body;
KServerResponse({
required this.statusCode,
required this.headers,
required this.body,
});
}
class KServerClient {
HttpClient? _client;
HttpClient _getClient() {
// TLS: k_server uses self-signed cert from generate_phase2_certs.py.
// In dev, accept any cert; in prod, pin the CA cert.
_client ??= HttpClient()
..badCertificateCallback = (cert, host, port) {
// TODO: replace with CA pinning once certs are bundled.
return true;
};
return _client!;
}
Future<KServerResponse> forward({
required String method,
required String path,
required HttpHeaders headers,
required Uint8List body,
}) async {
final client = _getClient();
final uri = Uri(
scheme: 'https',
host: kServerHost,
port: kServerPort,
path: path,
);
final req = await client.openUrl(method, uri);
// Forward relevant headers
headers.forEach((name, values) {
if (_shouldForwardHeader(name)) {
for (final v in values) req.headers.add(name, v);
}
});
if (body.isNotEmpty) {
req.headers.contentLength = body.length;
req.add(body);
}
final res = await req.close();
final resBody = await res.fold<List<int>>([], (a, b) => a..addAll(b));
return KServerResponse(
statusCode: res.statusCode,
headers: res.headers,
body: Uint8List.fromList(resBody),
);
}
bool _shouldForwardHeader(String name) {
// 'authorization' is intentionally stripped: it carries the k_phone session
// token which is meaningless to k_server. k_server authenticates via the
// X-Proxy-Token header added by the Kotlin layer, not by bearer tokens.
const skip = {'host', 'connection', 'transfer-encoding', 'authorization'};
return !skip.contains(name.toLowerCase());
}
void close() => _client?.close();
}

View File

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

@ -3,6 +3,7 @@ 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';
@ -10,12 +11,10 @@ import 'ctaphid_channel.dart';
import 'enrollment_db.dart';
import 'filter_proxy.dart';
import 'fido2_ops.dart';
import 'k_server_client.dart';
import 'portal_html.dart';
import 'session_manager.dart';
const int kProxyPort = 8771;
// Must match SessionManager._ttl; used only in the API response payload.
const int _kSessionTtlSeconds = 300;
const String kNotificationChannelId = 'kphone_proxy';
const String kNotificationChannelName = 'k_phone proxy service';
@ -84,7 +83,6 @@ class _ProxyServer {
final FilterProxy _filterProxy = FilterProxy();
final SessionManager _sessions = SessionManager();
final EnrollmentDb _db = EnrollmentDb();
final KServerClient _kserver = KServerClient();
int? _cardCid;
bool _cardAttached = false;
bool _running = false;
@ -116,19 +114,9 @@ class _ProxyServer {
// Card detection and DB loading are independent run in parallel.
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
SecurityContext? tlsCtx;
try {
tlsCtx = await _loadTlsContext();
} catch (_) {
_emit('No TLS certs found — running plain HTTP (dev mode)');
}
try {
if (tlsCtx != null) {
_server = await HttpServer.bindSecure(InternetAddress.anyIPv4, kProxyPort, tlsCtx);
} else {
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
}
_emit('Listening on :$kProxyPort');
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
} catch (e) {
@ -160,9 +148,9 @@ class _ProxyServer {
if (req.method == 'GET') {
switch (path) {
case '/':
await _serveHtml(req);
await _serveHtmlBytes(req, kPortalHtmlBytes);
case '/enroll':
await _serveEnrollHtml(req);
await _serveHtmlBytes(req, kEnrollHtmlBytes);
case '/health':
await _handleHealth(req);
case '/enroll/list':
@ -188,8 +176,8 @@ class _ProxyServer {
await _handleSessionStatus(req);
case '/session/logout':
await _handleSessionLogout(req);
case '/resource/counter':
await _handleResourceCounter(req);
case '/auth/get-token':
await _handleAuthGetToken(req);
default:
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
}
@ -214,18 +202,9 @@ class _ProxyServer {
final body = await _readJson(req);
if (body == null) return;
final rawUsername = body['username'] as String? ?? '';
final rawDisplay = body['display_name'] as String?;
String canonical;
String? pretty;
try {
canonical = normalizeUsername(rawUsername);
pretty = normalizeDisplayName(rawDisplay);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
final r = await _parseUsernameAndDisplay(req, body);
if (r == null) return;
final (canonical, pretty) = r;
MakeCredentialResult? credential;
if (_cardAttached && _cardCid != null) {
@ -258,18 +237,9 @@ class _ProxyServer {
final body = await _readJson(req);
if (body == null) return;
final rawUsername = body['username'] as String? ?? '';
final rawDisplay = body['display_name'] as String?;
String canonical;
String? pretty;
try {
canonical = normalizeUsername(rawUsername);
pretty = normalizeDisplayName(rawDisplay);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
final r = await _parseUsernameAndDisplay(req, body);
if (r == null) return;
final (canonical, pretty) = r;
try {
final enrollment = await _db.update(username: canonical, displayName: pretty);
@ -283,15 +253,8 @@ class _ProxyServer {
final body = await _readJson(req);
if (body == null) return;
final rawUsername = body['username'] as String? ?? '';
String canonical;
try {
canonical = normalizeUsername(rawUsername);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
final canonical = await _parseUsername(req, body);
if (canonical == null) return;
try {
final enrollment = await _db.delete(canonical);
@ -338,14 +301,8 @@ class _ProxyServer {
final body = await _readJson(req);
if (body == null) return;
final rawUsername = body['username'] as String? ?? '';
String canonical;
try {
canonical = normalizeUsername(rawUsername);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
final canonical = await _parseUsername(req, body);
if (canonical == null) return;
final enrollment = await _db.get(canonical);
if (enrollment == null) {
@ -354,7 +311,11 @@ class _ProxyServer {
}
if (enrollment.hasCredential && _cardCid != null) {
// FIDO2-direct: getAssertion + verify
// 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!);
@ -389,7 +350,7 @@ class _ProxyServer {
'username': canonical,
'session_token': token,
'expires_at': expiresAt,
'ttl_seconds': _kSessionTtlSeconds,
'ttl_seconds': SessionManager.ttlSeconds,
'auth_mode': authMode,
});
}
@ -485,47 +446,72 @@ class _ProxyServer {
}
// -------------------------------------------------------------------------
// Resource forwarding
// Auth token endpoint (v2 architecture per-request token binding)
//
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
// {url, method, nonce} for each gated request. A fresh FIDO2 assertion is
// produced with challenge = SHA256(url|method|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> _handleResourceCounter(HttpRequest req) async {
await _drainBody(req);
final token = _extractBearerToken(req);
if (token == null) {
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
return;
}
final session = _sessions.getSession(token);
if (session == null) {
await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'});
Future<void> _handleAuthGetToken(HttpRequest req) async {
final body = await _readJson(req);
if (body == null) return;
final url = body['url'] as String? ?? '';
final method = body['method'] as String? ?? '';
final nonce = body['nonce'] as String? ?? '';
if (url.isEmpty || method.isEmpty || nonce.isEmpty) {
await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'});
return;
}
final result = await _kserver.forward(
method: 'POST',
path: '/resource/counter',
headers: req.headers,
body: Uint8List(0),
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(url | "|" | method | "|" | nonce)
final challenge = Uint8List.fromList(
sha256.convert(utf8.encode('$url|$method|$nonce')).bytes,
);
if (result.statusCode != 200) {
await _send(req.response, result.statusCode, {'ok': false, 'error': 'upstream failed'});
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;
}
Map<String, dynamic> upstream;
try {
upstream = jsonDecode(utf8.decode(result.body)) as Map<String, dynamic>;
} catch (_) {
upstream = {};
}
await _send(req.response, 200, {
'ok': true,
'username': session.username,
'session_reused': true,
'upstream': upstream,
// Self-contained bundle the server can verify without calling back to the phone.
final bundleJson = jsonEncode({
'v': 1,
'url': url,
'method': method,
'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});
}
// -------------------------------------------------------------------------
@ -542,19 +528,11 @@ class _ProxyServer {
});
}
Future<void> _serveHtml(HttpRequest req) async {
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 = _kPortalHtmlBytes.length;
req.response.add(_kPortalHtmlBytes);
await req.response.close();
}
Future<void> _serveEnrollHtml(HttpRequest req) async {
req.response.statusCode = 200;
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
req.response.headers.contentLength = _kEnrollHtmlBytes.length;
req.response.add(_kEnrollHtmlBytes);
req.response.headers.contentLength = bytes.length;
req.response.add(bytes);
await req.response.close();
}
@ -641,245 +619,25 @@ class _ProxyServer {
return m;
}
Future<SecurityContext> _loadTlsContext() async {
throw UnimplementedError('TLS cert loading not yet wired up');
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;
}
}
}
// ---------------------------------------------------------------------------
// Portal HTML (mirrors k_proxy_app.py HTML)
// ---------------------------------------------------------------------------
final _kPortalHtmlBytes = utf8.encode(_kPortalHtml);
final _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">Counter</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/resource/counter",{},true);log("Counter",data);}catch(err){log("Counter failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';
// ---------------------------------------------------------------------------
// Enrollment / Registration HTML (GET /enroll)
// ---------------------------------------------------------------------------
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

@ -11,7 +11,8 @@ class SessionEntry {
class SessionManager {
final Map<String, SessionEntry> _sessions = {};
static const Duration _ttl = Duration(seconds: 300);
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
@ -51,6 +52,15 @@ class SessionManager {
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);

View File

@ -19,8 +19,7 @@ import '../lib/filter_proxy.dart';
const _kTimeout = Duration(seconds: 5);
// Start an HttpServer that accepts one request, records it, and replies 200 OK.
// Returns the server and a Completer (use .future to await; .isCompleted to check).
// 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>();
@ -38,6 +37,35 @@ Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() asyn
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);
@ -162,56 +190,117 @@ void main() {
});
// -------------------------------------------------------------------------
// Group 2: HTTP routing
// 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> comp2Req;
late HttpServer direct;
late Completer<({HttpRequest req, String rawBody})> comp2TokenReq;
late HttpServer endpoint;
late Completer<HttpRequest> endpointReq;
late HttpServer directServer;
late Completer<HttpRequest> directReq;
setUp(() async {
final c2 = await _mockHttp();
comp2 = c2.server;
comp2Req = c2.completer;
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();
direct = d.server;
directServer = d.server;
directReq = d.completer;
proxy = FilterProxy(
listenPort: 0,
component2Port: comp2.port,
);
// 'auth.local' is gated; '127.0.0.1' is not.
proxy.setGatedEntries(['auth.local']);
// 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 direct.close(force: true);
await endpoint.close(force: true);
await directServer.close(force: true);
});
test('gated host is forwarded to component2', () async {
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 url, method, 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['url'], contains('/api?x=1'));
expect(body['method'], 'GET');
expect(body['nonce'], isA<String>());
expect((body['nonce'] as String).length, greaterThan(8));
});
test('gated POST: /auth/get-token body carries method POST', () 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['method'], 'POST');
expect(body['url'], contains('/submit'));
});
test('gated host: request goes directly to endpoint with Bearer token', () async {
final response = await _round(
proxy.port,
'GET http://auth.local/api HTTP/1.1\r\nHost: auth.local\r\n\r\n',
'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 comp2Req.future.timeout(_kTimeout);
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:${direct.port}/page HTTP/1.1\r\n'
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
'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);
@ -223,78 +312,109 @@ void main() {
test('non-gated request does NOT reach component2', () async {
await _round(
proxy.port,
'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n'
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
'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);
// comp2 should never have received anything
expect(comp2Req.isCompleted, isFalse);
expect(comp2TokenReq.isCompleted, isFalse);
});
test('request line is rewritten from absolute URL to relative path', () async {
test('gated: request line is rewritten to relative path', () async {
await _round(
proxy.port,
'GET http://auth.local/session/login?foo=bar HTTP/1.1\r\n'
'Host: auth.local\r\n\r\n',
'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 comp2Req.future.timeout(_kTimeout);
// The mock HttpServer parses the rewritten request.
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.uri.path, '/session/login');
expect(req.uri.query, 'foo=bar');
});
test('Proxy-Connection header is stripped', () async {
test('gated: Proxy-Connection header is stripped', () async {
await _round(
proxy.port,
'GET http://auth.local/health HTTP/1.1\r\n'
'Host: auth.local\r\n'
'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 comp2Req.future.timeout(_kTimeout);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('proxy-connection'), isNull);
});
test('Proxy-Authorization header is stripped', () async {
test('gated: Proxy-Authorization header is stripped', () async {
await _round(
proxy.port,
'GET http://auth.local/health HTTP/1.1\r\n'
'Host: auth.local\r\n'
'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 comp2Req.future.timeout(_kTimeout);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('proxy-authorization'), isNull);
});
test('custom header is preserved', () async {
test('gated: existing Authorization header is replaced with Bearer token', () async {
await _round(
proxy.port,
'GET http://auth.local/health HTTP/1.1\r\n'
'Host: auth.local\r\n'
'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 comp2Req.future.timeout(_kTimeout);
final req = await endpointReq.future.timeout(_kTimeout);
expect(req.headers.value('x-custom'), 'hello');
});
test('POST body is forwarded to component2', () async {
test('gated: POST body is forwarded to endpoint', () async {
const body = '{"username":"alice"}';
await _round(
proxy.port,
'POST http://auth.local/session/login HTTP/1.1\r\n'
'Host: auth.local\r\n'
'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 comp2Req.future.timeout(_kTimeout);
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;

View File

@ -10,6 +10,8 @@ All state is process-local and resets on restart.
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import ssl
import threading
@ -19,6 +21,86 @@ 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, request_path: str, request_method: str) -> bool:
"""Verify a base64url-encoded FIDO2 per-request assertion bundle.
Bundle fields (JSON, then base64url-encoded):
v version (1)
url full URL used to derive the challenge
method HTTP method 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)
"""
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"))
# Path and method must match the actual request.
bundle_path = urlparse(bundle["url"]).path
if bundle_path != request_path:
return False
if bundle["method"].upper() != request_method.upper():
return False
url = bundle["url"]
method = bundle["method"]
nonce = bundle["nonce"]
# Verify challenge claim: challenge == b64u(SHA256(url|method|nonce))
binding = f"{url}|{method}|{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):
@ -52,7 +134,17 @@ class Handler(BaseHTTPRequestHandler):
self.rfile.read(length)
def _is_proxy_authorized(self) -> bool:
return self.headers.get("X-Proxy-Token") == self.state.proxy_token
# 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(),
request_path=urlparse(self.path).path,
request_method=self.command,
)
return False
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path

View File

@ -0,0 +1,264 @@
/**
* 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 } = require('@playwright/test');
let android = null;
try {
android = require('playwright').android;
} catch {
// playwright not installed separately — tests will be skipped.
}
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 device = null;
let proxyCtx = null; // Chrome launched with --proxy-server=127.0.0.1:8888
let skipReason = null;
let enrolledUser = null;
test.beforeAll(async () => {
if (!android) {
skipReason = 'playwright package not found — run: npm install playwright';
return;
}
try {
const devices = await android.devices();
if (!devices.length) {
skipReason = 'No Android emulator connected — run: adb devices';
return;
}
device = devices[0];
// Launch Chrome inside the emulator.
// --proxy-server points at Component 1 on the emulator's own loopback.
proxyCtx = await device.launchBrowser({
args: ['--proxy-server=127.0.0.1:8888'],
});
// 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 proxyCtx?.close().catch(() => {});
await device?.close().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

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

204
tests/k_phone_proxy.spec.js Normal file
View File

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

349
tests/test_k_server.py Normal file
View File

@ -0,0 +1,349 @@
"""
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(url: str, method: 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(url|method|nonce) — same as proxy_service.dart
challenge_b64u = _b64u_encode(hashlib.sha256(f"{url}|{method}|{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,
"url": url,
"method": method,
"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.url = "https://127.0.0.1:8780/resource/counter"
self.method = "POST"
self.nonce = "deadbeef01234567"
self.token, _ = _make_bundle(self.url, self.method, self.nonce)
def _check(self, token=None, path="/resource/counter", method="POST") -> bool:
return k_server_app._verify_assertion_token(
self.token if token is None else token, request_path=path, request_method=method
)
def test_valid_token_accepted(self):
self.assertTrue(self._check())
def test_wrong_path_rejected(self):
self.assertFalse(self._check(path="/resource/other"))
def test_wrong_method_rejected(self):
self.assertFalse(self._check(method="GET"))
def test_method_comparison_case_insensitive(self):
self.assertTrue(self._check(method="post"))
self.assertTrue(self._check(method="POST"))
def test_tampered_nonce_invalidates_challenge(self):
tampered = _tamper(self.token, "nonce", lambda _: "tampered00000000")
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_cross_resource_replay_rejected(self):
# Token bound to /resource/counter must not pass for /resource/admin.
self.assertFalse(self._check(path="/resource/admin"))
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",
url: str,
method: 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 bound challenge — mirrors _handleAuthGetToken in proxy_service.dart
challenge_b64u = _b64u_encode(
hashlib.sha256(f"{url}|{method}|{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,
"url": url,
"method": method,
"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, "https://example.com/api/data", "GET", "cafebabe12345678"
)
self.assertTrue(
k_server_app._verify_assertion_token(token, "/api/data", "GET"),
"valid round-trip token must be accepted",
)
def test_roundtrip_wrong_path_rejected(self):
emulator = CardEmulator()
token = self._register_and_assert(
emulator, "https://example.com/api/data", "GET", "aabbccdd11223344"
)
self.assertFalse(
k_server_app._verify_assertion_token(token, "/api/other", "GET"),
"token for /api/data must not pass for /api/other",
)
def test_roundtrip_wrong_method_rejected(self):
emulator = CardEmulator()
token = self._register_and_assert(
emulator, "https://example.com/submit", "POST", "1122334455667788"
)
self.assertFalse(
k_server_app._verify_assertion_token(token, "/submit", "GET"),
"token for POST must not pass for GET",
)
def test_roundtrip_same_token_twice_rejected_after_nonce_tamper(self):
"""Changing the nonce in a real assertion bundle breaks verification."""
emulator = CardEmulator()
token = self._register_and_assert(
emulator, "https://example.com/resource/counter", "POST", "original00000000"
)
tampered = _tamper(token, "nonce", lambda _: "tampered11111111")
self.assertFalse(
k_server_app._verify_assertion_token(tampered, "/resource/counter", "POST"),
"tampered nonce must break challenge verification",
)
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()
url, method, nonce = "https://example.com/protected", "GET", "00112233aabbccdd"
token_a = self._register_and_assert(em_a, url, method, nonce)
token_b = self._register_and_assert(em_b, url, method, 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, "/protected", "GET"),
"cross-user key swap must fail verification",
)
if __name__ == "__main__":
unittest.main(verbosity=2)