Compare commits
No commits in common. "c6294a46c782579b2fde438df1e31203410d1b4e" and "ddeed9b71e5ea21ad2bd2f3011340b9eb0918fab" have entirely different histories.
c6294a46c7
...
ddeed9b71e
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -82,25 +82,18 @@ Files are deployed to VMs via `scp <file> <host>:~` and run via `ssh <host> <cmd
|
||||||
Four physical devices: optional client computer, phone, chromecard, server.
|
Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
|
|
||||||
**Devices:**
|
**Devices:**
|
||||||
- **Client (optional):** Computer with Component 3 installed. No browser proxy configuration needed.
|
- **Client (optional):** Computer with browser configured to use the phone as HTTP/HTTPS proxy. No knowledge of the auth system.
|
||||||
- **Phone:** Central hub. Runs Component 1 and Component 2, hosts registration page, connects to chromecard via USB or WiFi.
|
- **Phone:** Central hub. Runs two components, 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).
|
- **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.
|
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
|
||||||
|
|
||||||
**Components on the phone:**
|
**Components on the phone:**
|
||||||
- **Component 1 — Proxy + gating filter:** Listens on a local port. Receives requests from the phone's own browser and from external clients via Component 3. Binary decision per request: host is gated → forward to Component 2, receive WebAuthn token back, then call the endpoint with the token (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
|
- **Component 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 — 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).
|
- **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).
|
||||||
- **Registration page:** Local web app on phone. Requires admin fingerprint on the card for enrollment/deletion.
|
- **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:**
|
**Three flows:**
|
||||||
- **Flow A (authenticated access — phone browser):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → token returned to Component 1 → Component 1 calls endpoint (TLS) → resource returned.
|
- **Flow A (authenticated proxy):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → Server (WebAuthn validates token) → 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 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.
|
- **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.
|
||||||
|
|
||||||
|
|
@ -108,8 +101,6 @@ Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
- PIN on card (in addition to biometrics) — not yet decided
|
- PIN on card (in addition to biometrics) — not yet decided
|
||||||
- User database location: on-card only vs. external — not yet decided
|
- User database location: on-card only vs. external — not yet decided
|
||||||
- Network-level access control on registration page — 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)
|
### Development topology (Qubes 3-VM)
|
||||||
|
|
||||||
|
|
@ -139,13 +130,11 @@ 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 Flutter app (Phase 9 — replaces k_proxy)
|
||||||
|
|
||||||
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → fetches bearer token from Component 2 (`POST /auth/get-token`), then calls endpoint directly with `Authorization: Bearer`; non-gated → direct to target. HTTPS CONNECT to gated host: relays CONNECT through Component 2 (session-gate check). Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
|
**`k_phone/lib/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/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/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/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).
|
||||||
|
|
||||||
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests). `SessionManager.ttlSeconds` is the public TTL constant (300 s).
|
|
||||||
|
|
||||||
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.
|
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.
|
||||||
|
|
||||||
|
|
|
||||||
25
Setup.md
25
Setup.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
Last updated: 2026-05-08
|
Last updated: 2026-04-29
|
||||||
|
|
||||||
This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`.
|
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.
|
Update this file whenever environment status or verified behavior changes.
|
||||||
|
|
@ -668,29 +668,6 @@ Session note (2026-04-27, card emulator and bug fixes):
|
||||||
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
|
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
|
||||||
- total test count is now 122, all passing locally without card or VMs
|
- 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):
|
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.
|
- 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.
|
- Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor.
|
||||||
|
|
|
||||||
96
Workplan.md
96
Workplan.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Workplan
|
# Workplan
|
||||||
|
|
||||||
Last updated: 2026-05-08
|
Last updated: 2026-04-29
|
||||||
|
|
||||||
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
|
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
|
||||||
|
|
||||||
|
|
@ -529,47 +529,23 @@ Exit criteria:
|
||||||
|
|
||||||
## Phase 9: Migrate to Phone-Mediated Wireless Validation
|
## Phase 9: Migrate to Phone-Mediated Wireless Validation
|
||||||
|
|
||||||
Status (2026-05-04): **ACTIVE — Architecture v2 adopted; Component 1 + Component 2 CONNECT handler complete**
|
Status (2026-05-02): **ACTIVE — Component 1 + Component 2 CONNECT handler complete**
|
||||||
|
|
||||||
### Architecture v2 changes (2026-05-04)
|
### Target architecture
|
||||||
|
|
||||||
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.
|
Four physical devices: optional client computer, phone, chromecard, server.
|
||||||
|
|
||||||
**Phone components:**
|
**Phone components:**
|
||||||
- **Component 1 — Proxy + gating filter:** Receives requests from phone browser and from external clients via Component 3. Per-request: gated host → forward to Component 2, receive WebAuthn token back, call endpoint with token (TLS); non-gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
|
- **Component 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 — 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).
|
- **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).
|
||||||
- **Registration page:** Local web app on phone; admin fingerprint access control enforced by card.
|
- **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:**
|
**Three flows:**
|
||||||
- **Flow A (phone browser):** Browser → Comp 1 → Comp 2 → card → token → Comp 1 → endpoint → resource
|
- **Flow A:** Browser → phone (comp 1 + 2) → card (user biometric) → server WebAuthn → resource
|
||||||
- **Flow A (external client):** Browser → Comp 3 → Comp 1 → Comp 2 → card → token → Comp 1 → Comp 3 → endpoint → resource
|
- **Flow B:** Browser → phone (comp 1 + 2, registration URL) → card (admin biometric) → enroll/delete user
|
||||||
- **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)
|
||||||
- **Flow C:** Non-gated host → Comp 1 → internet port 80 (no TLS, no card)
|
|
||||||
|
|
||||||
**Open decisions:** PIN on card; user DB on-card vs. external; network-level access control on registration page; Component 3 rendezvous mechanism; iOS vs Android priority.
|
**Open decisions (from architecture doc):** PIN on card; user DB on-card vs. external; network-level access control on registration page.
|
||||||
|
|
||||||
Development chain (Qubes): `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
|
Development chain (Qubes): `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
|
||||||
|
|
||||||
|
|
@ -637,58 +613,12 @@ 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
|
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
|
### Next action
|
||||||
|
|
||||||
1. Deploy to a real Android phone with physical ChromeCard via USB
|
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. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
|
2. Deploy to a real Android phone with physical ChromeCard via USB
|
||||||
3. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
|
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
|
||||||
|
|
||||||
### k_phone API contract (must match k_proxy_app.py exactly)
|
### k_phone API contract (must match k_proxy_app.py exactly)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GatedHosts is the set of hostnames that require FIDO2 authentication.
|
|
||||||
// Format matches k_phone's gated_hosts.txt: one "host" or "host:port" per line,
|
|
||||||
// lines starting with "#" and blank lines are ignored.
|
|
||||||
type GatedHosts struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
entries map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads the gated hosts file. Missing file is not an error (empty list).
|
|
||||||
func (g *GatedHosts) Load(path string) error {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
entries := make(map[string]bool)
|
|
||||||
sc := bufio.NewScanner(f)
|
|
||||||
for sc.Scan() {
|
|
||||||
line := strings.TrimSpace(sc.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Normalise: lowercase, strip any trailing port-free colon.
|
|
||||||
entries[strings.ToLower(line)] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
g.entries = entries
|
|
||||||
g.mu.Unlock()
|
|
||||||
|
|
||||||
return sc.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len returns the number of entries in the gated list.
|
|
||||||
func (g *GatedHosts) Len() int {
|
|
||||||
g.mu.RLock()
|
|
||||||
defer g.mu.RUnlock()
|
|
||||||
return len(g.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsGated returns true if host:port matches a gated entry.
|
|
||||||
// An entry "example.com" matches any port; "example.com:8080" matches only port 8080.
|
|
||||||
func (g *GatedHosts) IsGated(host, port string) bool {
|
|
||||||
g.mu.RLock()
|
|
||||||
defer g.mu.RUnlock()
|
|
||||||
if len(g.entries) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
h := strings.ToLower(host)
|
|
||||||
return g.entries[h] || (port != "" && g.entries[h+":"+port])
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module github.com/chromecard/component3
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
listen := flag.String("listen", "127.0.0.1:9090", "local proxy address (configure browser to use this)")
|
|
||||||
phoneURL := flag.String("phone", "http://192.168.1.10:8771", "phone base URL (Component 1/2)")
|
|
||||||
gatedFile := flag.String("gated", "", "gated hosts file (default: ~/.config/component3/gated_hosts.txt)")
|
|
||||||
verbose := flag.Bool("v", false, "verbose logging")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfgDir := defaultConfigDir()
|
|
||||||
if err := os.MkdirAll(cfgDir, 0700); err != nil {
|
|
||||||
log.Fatalf("cannot create config dir: %v", err)
|
|
||||||
}
|
|
||||||
if *gatedFile == "" {
|
|
||||||
*gatedFile = filepath.Join(cfgDir, "gated_hosts.txt")
|
|
||||||
}
|
|
||||||
|
|
||||||
gated := &GatedHosts{}
|
|
||||||
if err := gated.Load(*gatedFile); err != nil {
|
|
||||||
log.Printf("warning: gated hosts: %v (using empty list)", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("loaded %d gated entries from %s", gated.Len(), *gatedFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
phone := NewPhoneClient(*phoneURL)
|
|
||||||
|
|
||||||
proxy := &Proxy{
|
|
||||||
phone: phone,
|
|
||||||
gated: gated,
|
|
||||||
verbose: *verbose,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("listening on %s — configure browser HTTP proxy to this address", *listen)
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: *listen,
|
|
||||||
Handler: proxy,
|
|
||||||
}
|
|
||||||
if err := server.ListenAndServe(); err != nil {
|
|
||||||
log.Fatalf("proxy: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultConfigDir() string {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return ".component3"
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".config", "component3")
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PhoneClient fetches a per-request FIDO2 assertion token from Component 2.
|
|
||||||
// There is no session caching — each call triggers a card interaction.
|
|
||||||
type PhoneClient struct {
|
|
||||||
baseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPhoneClient(baseURL string) *PhoneClient {
|
|
||||||
return &PhoneClient{baseURL: baseURL}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTokenForRequest calls /auth/get-token with url+method+nonce, triggering
|
|
||||||
// a fresh FIDO2 assertion on the card. Returns the self-contained assertion
|
|
||||||
// bundle that the endpoint can verify independently.
|
|
||||||
func (c *PhoneClient) GetTokenForRequest(rawURL, method string) (string, error) {
|
|
||||||
nonce, err := randomHex(16)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("nonce: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"url": rawURL,
|
|
||||||
"method": method,
|
|
||||||
"nonce": nonce,
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := http.Post(c.baseURL+"/auth/get-token", "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("phone unreachable (%s): %w", c.baseURL, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
|
||||||
var result struct {
|
|
||||||
Ok bool `json:"ok"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("parse token response: %w (body: %s)", err, raw)
|
|
||||||
}
|
|
||||||
if !result.Ok {
|
|
||||||
return "", fmt.Errorf("auth failed: %s", result.Error)
|
|
||||||
}
|
|
||||||
if result.Token == "" {
|
|
||||||
return "", fmt.Errorf("phone returned empty token")
|
|
||||||
}
|
|
||||||
return result.Token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomHex(n int) (string, error) {
|
|
||||||
b := make([]byte, n)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b), nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hopByHop headers that must not be forwarded by a proxy (RFC 7230 §6.1).
|
|
||||||
var hopByHopHeaders = map[string]bool{
|
|
||||||
"connection": true,
|
|
||||||
"keep-alive": true,
|
|
||||||
"proxy-authenticate": true,
|
|
||||||
"proxy-authorization": true,
|
|
||||||
"te": true,
|
|
||||||
"trailers": true,
|
|
||||||
"transfer-encoding": true,
|
|
||||||
"upgrade": true,
|
|
||||||
"proxy-connection": true, // non-standard but common
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy is the HTTP/HTTPS proxy handler.
|
|
||||||
//
|
|
||||||
// For plain HTTP requests to gated hosts:
|
|
||||||
// browser → Proxy → (session token from phone) → endpoint directly → browser
|
|
||||||
//
|
|
||||||
// For HTTPS CONNECT to gated hosts:
|
|
||||||
// 407 — HTTPS interception is not supported; use plain HTTP for gated endpoints.
|
|
||||||
//
|
|
||||||
// For non-gated hosts:
|
|
||||||
// browser → Proxy → internet (transparent, no auth)
|
|
||||||
type Proxy struct {
|
|
||||||
phone *PhoneClient
|
|
||||||
gated *GatedHosts
|
|
||||||
verbose bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodConnect {
|
|
||||||
p.handleConnect(w, r)
|
|
||||||
} else {
|
|
||||||
p.handleHTTP(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleHTTP handles plain HTTP proxy requests.
|
|
||||||
// For gated hosts: acquires a session token from the phone, adds it as
|
|
||||||
// Authorization: Bearer, then calls the endpoint directly.
|
|
||||||
func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Host == "" {
|
|
||||||
http.Error(w, "not a proxy request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host := r.URL.Hostname()
|
|
||||||
port := r.URL.Port()
|
|
||||||
if port == "" {
|
|
||||||
port = "80"
|
|
||||||
}
|
|
||||||
isGated := p.gated.IsGated(host, port)
|
|
||||||
p.logf("HTTP %s %s (gated=%v)", r.Method, r.URL, isGated)
|
|
||||||
|
|
||||||
// Build outgoing request. RequestURI must be empty for http.Client/RoundTrip.
|
|
||||||
out := r.Clone(r.Context())
|
|
||||||
out.RequestURI = ""
|
|
||||||
stripHopByHop(out.Header)
|
|
||||||
|
|
||||||
if isGated {
|
|
||||||
token, err := p.phone.GetTokenForRequest(r.URL.String(), r.Method)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "auth: "+err.Error(), http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultTransport.RoundTrip(out)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "upstream: "+err.Error(), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
copyHeaders(w.Header(), resp.Header)
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleConnect handles HTTPS CONNECT tunnels.
|
|
||||||
// For gated hosts: does TLS MITM so Authorization can be injected into each
|
|
||||||
// inner HTTP request before it is forwarded to the actual server.
|
|
||||||
// For non-gated hosts: transparent byte-level tunnel.
|
|
||||||
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
host, portStr, err := net.SplitHostPort(r.Host)
|
|
||||||
if err != nil {
|
|
||||||
// No port — default to 443.
|
|
||||||
host = r.Host
|
|
||||||
portStr = "443"
|
|
||||||
}
|
|
||||||
if portStr == "" {
|
|
||||||
portStr = "443"
|
|
||||||
}
|
|
||||||
target := net.JoinHostPort(host, portStr)
|
|
||||||
isGated := p.gated.IsGated(host, portStr)
|
|
||||||
p.logf("CONNECT %s (gated=%v)", target, isGated)
|
|
||||||
|
|
||||||
hijacker, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "hijack not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientConn, _, err := hijacker.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("hijack: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isGated {
|
|
||||||
fmt.Fprintf(clientConn, "HTTP/1.1 407 Proxy Authentication Required\r\nContent-Type: text/plain\r\nProxy-Authenticate: Bearer realm=\"chromecard\"\r\n\r\nHTTPS tunnels to gated hosts are not supported. Use plain HTTP.\r\n")
|
|
||||||
p.logf("CONNECT %s: gated HTTPS not supported, returned 407", target)
|
|
||||||
clientConn.Close()
|
|
||||||
} else {
|
|
||||||
p.handleDirectConnect(clientConn, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDirectConnect tunnels bytes transparently — no auth, no inspection.
|
|
||||||
func (p *Proxy) handleDirectConnect(clientConn net.Conn, target string) {
|
|
||||||
defer clientConn.Close()
|
|
||||||
|
|
||||||
upConn, err := net.DialTimeout("tcp", target, 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer upConn.Close()
|
|
||||||
|
|
||||||
fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
||||||
pipe(clientConn, upConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pipe copies bytes bidirectionally between two connections until either closes.
|
|
||||||
func pipe(a, b net.Conn) {
|
|
||||||
done := make(chan struct{}, 2)
|
|
||||||
go func() { io.Copy(a, b); done <- struct{}{} }()
|
|
||||||
go func() { io.Copy(b, a); done <- struct{}{} }()
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripHopByHop removes hop-by-hop headers and any headers named in Connection.
|
|
||||||
func stripHopByHop(h http.Header) {
|
|
||||||
if conn := h.Get("Connection"); conn != "" {
|
|
||||||
for _, name := range strings.Split(conn, ",") {
|
|
||||||
h.Del(strings.TrimSpace(name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name := range hopByHopHeaders {
|
|
||||||
h.Del(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyHeaders copies non-hop-by-hop headers from src to dst.
|
|
||||||
func copyHeaders(dst, src http.Header) {
|
|
||||||
for k, vs := range src {
|
|
||||||
if !hopByHopHeaders[strings.ToLower(k)] {
|
|
||||||
for _, v := range vs {
|
|
||||||
dst.Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) logf(format string, args ...any) {
|
|
||||||
if p.verbose {
|
|
||||||
log.Printf(format, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,13 +46,11 @@ class GetAssertionResult {
|
||||||
final Uint8List authData;
|
final Uint8List authData;
|
||||||
final Uint8List signature;
|
final Uint8List signature;
|
||||||
final Uint8List clientDataHash;
|
final Uint8List clientDataHash;
|
||||||
final String clientDataJson;
|
|
||||||
|
|
||||||
GetAssertionResult({
|
GetAssertionResult({
|
||||||
required this.authData,
|
required this.authData,
|
||||||
required this.signature,
|
required this.signature,
|
||||||
required this.clientDataHash,
|
required this.clientDataHash,
|
||||||
required this.clientDataJson,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,17 +116,15 @@ Future<MakeCredentialResult> makeCredential(
|
||||||
|
|
||||||
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
||||||
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
||||||
/// [challenge] overrides the random challenge — use for per-request token binding.
|
|
||||||
Future<GetAssertionResult> getAssertion(
|
Future<GetAssertionResult> getAssertion(
|
||||||
int cid,
|
int cid,
|
||||||
String credentialDataB64, {
|
String credentialDataB64,
|
||||||
Uint8List? challenge,
|
) async {
|
||||||
}) async {
|
|
||||||
final credData = _b64uDecode(credentialDataB64);
|
final credData = _b64uDecode(credentialDataB64);
|
||||||
final credId = _extractCredentialId(credData);
|
final credId = _extractCredentialId(credData);
|
||||||
|
|
||||||
final actualChallenge = challenge ?? _randomBytes(32);
|
final challenge = _randomBytes(32);
|
||||||
final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge);
|
final clientDataJson = _buildClientDataJson('webauthn.get', challenge);
|
||||||
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
||||||
|
|
||||||
final requestMap = CborMap({
|
final requestMap = CborMap({
|
||||||
|
|
@ -158,7 +154,6 @@ Future<GetAssertionResult> getAssertion(
|
||||||
authData: authData,
|
authData: authData,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
clientDataHash: clientDataHash,
|
clientDataHash: clientDataHash,
|
||||||
clientDataJson: clientDataJson,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,29 @@
|
||||||
// Component 1 — HTTP proxy with URL gating filter (v2 architecture).
|
// Component 1 — HTTP proxy with URL gating filter.
|
||||||
//
|
//
|
||||||
// Routing rule — binary decision per request:
|
// All browser traffic enters here. The routing rule is a single binary decision:
|
||||||
// gated host → ask Component 2 for a bearer token (POST /auth/get-token),
|
// gated host → relay through Component 2 on localhost:_component2Port
|
||||||
// then call the endpoint directly with Authorization: Bearer.
|
// other host → forward directly to the target host:port
|
||||||
// other host → forward directly to the target host:port (no auth, port 80)
|
|
||||||
//
|
//
|
||||||
// For HTTPS (CONNECT) to gated hosts the CONNECT is still relayed through
|
// "Gated hosts" are resources that require FIDO2 card authentication before
|
||||||
// Component 2 (session-gate check), with Component 2 opening the upstream TCP
|
// they can be accessed. Traffic to them is relayed through Component 2, which
|
||||||
// connection. TODO: replace with local MITM so Component 2 never contacts
|
// checks for an active session before forwarding.
|
||||||
// endpoints directly.
|
|
||||||
//
|
//
|
||||||
// Gated hosts file (gated_hosts.txt in the app documents directory): one entry
|
// 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
|
// per line, either "host" or "host:port". Lines starting with "#" and blank
|
||||||
// lines are ignored.
|
// 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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
|
@ -356,7 +362,9 @@ class FilterProxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plain HTTP request
|
// 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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Future<void> _handleHttp(
|
Future<void> _handleHttp(
|
||||||
|
|
@ -378,6 +386,7 @@ class FilterProxy {
|
||||||
|
|
||||||
final host = uri.host;
|
final host = uri.host;
|
||||||
final port = uri.hasPort ? uri.port : 80;
|
final port = uri.hasPort ? uri.port : 80;
|
||||||
|
final path = _relativePath(uri);
|
||||||
|
|
||||||
int contentLength = 0;
|
int contentLength = 0;
|
||||||
for (final h in headerLines) {
|
for (final h in headerLines) {
|
||||||
|
|
@ -387,144 +396,35 @@ class FilterProxy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isGated(host, port)) {
|
// For gated plain-HTTP hosts, route through Component 2; for others, direct.
|
||||||
await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength);
|
final Socket upstream;
|
||||||
} else {
|
|
||||||
await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gated plain HTTP (v2): get token from Component 2, then call endpoint directly.
|
|
||||||
Future<void> _handleGatedHttp(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String method,
|
|
||||||
Uri uri,
|
|
||||||
List<String> headerLines,
|
|
||||||
List<int> remainder,
|
|
||||||
int contentLength,
|
|
||||||
) async {
|
|
||||||
String token;
|
|
||||||
try {
|
try {
|
||||||
token = await _getAuthToken(uri, method);
|
if (_isGated(host, port)) {
|
||||||
} catch (_) {
|
upstream = await Socket.connect('127.0.0.1', _component2Port)
|
||||||
_deny(client, sub, 407, 'Proxy Authentication Required');
|
.timeout(const Duration(seconds: 5));
|
||||||
return;
|
} else {
|
||||||
}
|
upstream = await Socket.connect(host, port)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
Socket upstream;
|
|
||||||
try {
|
|
||||||
upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
} catch (_) {
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final out = StringBuffer();
|
|
||||||
_writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines, bearerToken: token);
|
|
||||||
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-gated plain HTTP: forward directly, no auth.
|
|
||||||
Future<void> _handleDirectHttp(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String method,
|
|
||||||
Uri uri,
|
|
||||||
List<String> headerLines,
|
|
||||||
List<int> remainder,
|
|
||||||
int contentLength,
|
|
||||||
) async {
|
|
||||||
Socket upstream;
|
|
||||||
try {
|
|
||||||
upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
} catch (_) {
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final out = StringBuffer();
|
|
||||||
_writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines);
|
|
||||||
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calls POST /auth/get-token on Component 2 with 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');
|
} catch (e) {
|
||||||
} finally {
|
_deny(client, sub, 502, 'Bad Gateway');
|
||||||
httpClient.close();
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
String _secureHex(int bytes) {
|
final out = StringBuffer()
|
||||||
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('$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) {
|
for (final h in headerLines) {
|
||||||
if (h.isEmpty) continue;
|
if (h.isEmpty) continue;
|
||||||
final lower = h.toLowerCase();
|
final lower = h.toLowerCase();
|
||||||
if (lower.startsWith('host:') ||
|
if (lower.startsWith('host:') ||
|
||||||
lower.startsWith('proxy-connection:') ||
|
lower.startsWith('proxy-connection:') ||
|
||||||
lower.startsWith('proxy-authorization:') ||
|
lower.startsWith('proxy-authorization:')) continue;
|
||||||
(bearerToken != null && lower.startsWith('authorization:'))) continue;
|
|
||||||
out.write('$h\r\n');
|
out.write('$h\r\n');
|
||||||
}
|
}
|
||||||
out.write('Connection: close\r\n\r\n');
|
out.write('Connection: close\r\n\r\n');
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _forwardHttpRequest(
|
upstream.add(utf8.encode(out.toString()));
|
||||||
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);
|
if (remainder.isNotEmpty) upstream.add(remainder);
|
||||||
|
|
||||||
final bodyLeft = contentLength - remainder.length;
|
final bodyLeft = contentLength - remainder.length;
|
||||||
|
|
@ -542,12 +442,20 @@ class FilterProxy {
|
||||||
client.add,
|
client.add,
|
||||||
// flush() drains the write buffer before closing; destroy() would drop it.
|
// flush() drains the write buffer before closing; destroy() would drop it.
|
||||||
onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); },
|
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,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
await done.future;
|
await done.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
|
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
|
||||||
sub.cancel();
|
sub.cancel();
|
||||||
client.add(utf8.encode(
|
client.add(utf8.encode(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
final List<int> kPortalHtmlBytes = utf8.encode(kPortalHtml);
|
|
||||||
final List<int> kEnrollHtmlBytes = utf8.encode(kEnrollHtml);
|
|
||||||
|
|
||||||
const String kPortalHtml = '''<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>ChromeCard k_phone Portal</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
|
|
||||||
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
|
|
||||||
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
|
|
||||||
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
|
|
||||||
}
|
|
||||||
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
|
|
||||||
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
|
|
||||||
.hero { padding: 24px; margin-bottom: 20px; }
|
|
||||||
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
|
|
||||||
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
|
|
||||||
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
|
||||||
.card { padding: 18px; }
|
|
||||||
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
|
|
||||||
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
|
|
||||||
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
|
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
|
|
||||||
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
|
|
||||||
button.secondary { background: var(--accent-2); }
|
|
||||||
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
|
|
||||||
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<section class="hero">
|
|
||||||
<h1>ChromeCard k_phone Portal</h1>
|
|
||||||
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
|
|
||||||
</section>
|
|
||||||
<section class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Enrollment</h2>
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input id="username" placeholder="alice" autocomplete="off">
|
|
||||||
<label for="displayName">Display Name</label>
|
|
||||||
<input id="displayName" placeholder="Alice Example" autocomplete="off">
|
|
||||||
<div class="actions">
|
|
||||||
<button id="enrollBtn">Enroll User</button>
|
|
||||||
<button id="updateBtn" class="secondary">Update User</button>
|
|
||||||
<button id="deleteBtn" class="secondary">Delete User</button>
|
|
||||||
<button id="checkBtn" class="secondary">Check Enrollment</button>
|
|
||||||
<button id="listBtn" class="secondary">List Users</button>
|
|
||||||
</div>
|
|
||||||
<div class="status">
|
|
||||||
<div>Stored username: <strong id="storedUser">none</strong></div>
|
|
||||||
<div>Session active: <strong id="sessionActive">no</strong></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>Session Flow</h2>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn">Login</button>
|
|
||||||
<button id="statusBtn" class="secondary">Status</button>
|
|
||||||
<button id="counterBtn">Get Auth Token</button>
|
|
||||||
<button id="logoutBtn" class="secondary">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<pre id="log"></pre>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
|
|
||||||
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
|
|
||||||
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
|
|
||||||
sessionActiveNode=document.getElementById("sessionActive");
|
|
||||||
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
|
|
||||||
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
|
|
||||||
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
|
|
||||||
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
|
|
||||||
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
|
|
||||||
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
|
|
||||||
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
|
|
||||||
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
|
|
||||||
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
|
|
||||||
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
|
|
||||||
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
|
|
||||||
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
|
|
||||||
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/auth/get-token",{});log("Auth token acquired — Component 1/3 uses this to call endpoint directly",data);}catch(err){log("Get token failed",{error:err.message});}});
|
|
||||||
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
|
|
||||||
syncState();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>''';
|
|
||||||
|
|
||||||
const String kEnrollHtml = '''<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>ChromeCard — Registration</title>
|
|
||||||
<style>
|
|
||||||
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
|
|
||||||
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
|
|
||||||
h1{font-size:1.25rem;font-weight:700}
|
|
||||||
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
|
|
||||||
/* user list */
|
|
||||||
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
||||||
#userList table{width:100%;border-collapse:collapse}
|
|
||||||
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
|
|
||||||
#userList tr:last-child td{border-bottom:none}
|
|
||||||
.uname{font-weight:600;font-size:.95rem}
|
|
||||||
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
|
|
||||||
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
|
|
||||||
.fido2{background:#d1fae5;color:#065f46}
|
|
||||||
.probe{background:#fef3c7;color:#92400e}
|
|
||||||
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
|
|
||||||
.btn-del:hover{background:var(--r);color:#fff}
|
|
||||||
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
|
|
||||||
/* form */
|
|
||||||
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
|
|
||||||
label{font-size:.8rem;color:var(--muted)}
|
|
||||||
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
|
|
||||||
input:focus{outline:2px solid var(--g);border-color:transparent}
|
|
||||||
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
|
|
||||||
#regBtn:disabled{opacity:.5;cursor:default}
|
|
||||||
/* status */
|
|
||||||
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
|
|
||||||
#msg.ok{color:#065f46}
|
|
||||||
#msg.err{color:var(--r)}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>ChromeCard — User Registration</h1>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Registered users</h2>
|
|
||||||
<div id="userList"><div class="empty">Loading…</div></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Register new user</h2>
|
|
||||||
<form id="regForm">
|
|
||||||
<label for="uname">Username</label>
|
|
||||||
<input id="uname" placeholder="alice" autocomplete="off" required>
|
|
||||||
<label for="dname">Display name (optional)</label>
|
|
||||||
<input id="dname" placeholder="Alice Example" autocomplete="off">
|
|
||||||
<button type="submit" id="regBtn">Register — touch card fingerprint</button>
|
|
||||||
</form>
|
|
||||||
<div id="msg"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
var listEl=document.getElementById("userList"),
|
|
||||||
regForm=document.getElementById("regForm"),
|
|
||||||
unameEl=document.getElementById("uname"),
|
|
||||||
dnameEl=document.getElementById("dname"),
|
|
||||||
regBtn=document.getElementById("regBtn"),
|
|
||||||
msgEl=document.getElementById("msg");
|
|
||||||
|
|
||||||
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
|
|
||||||
function clearMsg(){msgEl.textContent="";msgEl.className="";}
|
|
||||||
|
|
||||||
function renderUsers(users){
|
|
||||||
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
|
|
||||||
var rows=users.map(function(u){
|
|
||||||
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
|
|
||||||
var mode=u.has_credential?'fido2':'probe';
|
|
||||||
var label=u.has_credential?'FIDO2':'probe';
|
|
||||||
return '<tr>'
|
|
||||||
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
|
|
||||||
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
|
|
||||||
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
|
|
||||||
+'</tr>';
|
|
||||||
}).join("");
|
|
||||||
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
|
|
||||||
listEl.querySelectorAll(".btn-del").forEach(function(b){
|
|
||||||
b.addEventListener("click",function(){del(b.dataset.u);});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers(){
|
|
||||||
try{
|
|
||||||
var r=await fetch("/enroll/list"),d=await r.json();
|
|
||||||
renderUsers(d.users||[]);
|
|
||||||
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function del(username){
|
|
||||||
if(!confirm('Delete user "'+username+'"?'))return;
|
|
||||||
clearMsg();
|
|
||||||
try{
|
|
||||||
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
|
|
||||||
var d=await r.json();
|
|
||||||
if(!r.ok)throw new Error(d.error||"Delete failed");
|
|
||||||
renderUsers(d.users||[]);
|
|
||||||
setMsg('"'+username+'" deleted.',true);
|
|
||||||
}catch(e){setMsg(e.message,false);}
|
|
||||||
}
|
|
||||||
|
|
||||||
regForm.addEventListener("submit",async function(e){
|
|
||||||
e.preventDefault();clearMsg();
|
|
||||||
var username=unameEl.value.trim();
|
|
||||||
var display_name=dnameEl.value.trim()||undefined;
|
|
||||||
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
|
|
||||||
try{
|
|
||||||
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
|
|
||||||
var d=await r.json();
|
|
||||||
if(!r.ok)throw new Error(d.error||"Registration failed");
|
|
||||||
renderUsers(d.users||[]);
|
|
||||||
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
|
|
||||||
unameEl.value="";dnameEl.value="";
|
|
||||||
}catch(e){setMsg(e.message,false);}
|
|
||||||
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadUsers();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>''';
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
|
@ -11,10 +10,12 @@ import 'ctaphid_channel.dart';
|
||||||
import 'enrollment_db.dart';
|
import 'enrollment_db.dart';
|
||||||
import 'filter_proxy.dart';
|
import 'filter_proxy.dart';
|
||||||
import 'fido2_ops.dart';
|
import 'fido2_ops.dart';
|
||||||
import 'portal_html.dart';
|
import 'k_server_client.dart';
|
||||||
import 'session_manager.dart';
|
import 'session_manager.dart';
|
||||||
|
|
||||||
const int kProxyPort = 8771;
|
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 kNotificationChannelId = 'kphone_proxy';
|
||||||
const String kNotificationChannelName = 'k_phone proxy service';
|
const String kNotificationChannelName = 'k_phone proxy service';
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ class _ProxyServer {
|
||||||
final FilterProxy _filterProxy = FilterProxy();
|
final FilterProxy _filterProxy = FilterProxy();
|
||||||
final SessionManager _sessions = SessionManager();
|
final SessionManager _sessions = SessionManager();
|
||||||
final EnrollmentDb _db = EnrollmentDb();
|
final EnrollmentDb _db = EnrollmentDb();
|
||||||
|
final KServerClient _kserver = KServerClient();
|
||||||
int? _cardCid;
|
int? _cardCid;
|
||||||
bool _cardAttached = false;
|
bool _cardAttached = false;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
|
|
@ -114,9 +116,19 @@ class _ProxyServer {
|
||||||
// Card detection and DB loading are independent — run in parallel.
|
// Card detection and DB loading are independent — run in parallel.
|
||||||
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
|
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
|
||||||
|
|
||||||
_emit('No TLS certs found — running plain HTTP (dev mode)');
|
SecurityContext? tlsCtx;
|
||||||
try {
|
try {
|
||||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
|
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');
|
_emit('Listening on :$kProxyPort');
|
||||||
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
|
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -148,9 +160,9 @@ class _ProxyServer {
|
||||||
if (req.method == 'GET') {
|
if (req.method == 'GET') {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
case '/':
|
case '/':
|
||||||
await _serveHtmlBytes(req, kPortalHtmlBytes);
|
await _serveHtml(req);
|
||||||
case '/enroll':
|
case '/enroll':
|
||||||
await _serveHtmlBytes(req, kEnrollHtmlBytes);
|
await _serveEnrollHtml(req);
|
||||||
case '/health':
|
case '/health':
|
||||||
await _handleHealth(req);
|
await _handleHealth(req);
|
||||||
case '/enroll/list':
|
case '/enroll/list':
|
||||||
|
|
@ -176,8 +188,8 @@ class _ProxyServer {
|
||||||
await _handleSessionStatus(req);
|
await _handleSessionStatus(req);
|
||||||
case '/session/logout':
|
case '/session/logout':
|
||||||
await _handleSessionLogout(req);
|
await _handleSessionLogout(req);
|
||||||
case '/auth/get-token':
|
case '/resource/counter':
|
||||||
await _handleAuthGetToken(req);
|
await _handleResourceCounter(req);
|
||||||
default:
|
default:
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
||||||
}
|
}
|
||||||
|
|
@ -202,9 +214,18 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final r = await _parseUsernameAndDisplay(req, body);
|
final rawUsername = body['username'] as String? ?? '';
|
||||||
if (r == null) return;
|
final rawDisplay = body['display_name'] as String?;
|
||||||
final (canonical, pretty) = r;
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
MakeCredentialResult? credential;
|
MakeCredentialResult? credential;
|
||||||
if (_cardAttached && _cardCid != null) {
|
if (_cardAttached && _cardCid != null) {
|
||||||
|
|
@ -237,9 +258,18 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final r = await _parseUsernameAndDisplay(req, body);
|
final rawUsername = body['username'] as String? ?? '';
|
||||||
if (r == null) return;
|
final rawDisplay = body['display_name'] as String?;
|
||||||
final (canonical, pretty) = r;
|
|
||||||
|
String canonical;
|
||||||
|
String? pretty;
|
||||||
|
try {
|
||||||
|
canonical = normalizeUsername(rawUsername);
|
||||||
|
pretty = normalizeDisplayName(rawDisplay);
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final enrollment = await _db.update(username: canonical, displayName: pretty);
|
final enrollment = await _db.update(username: canonical, displayName: pretty);
|
||||||
|
|
@ -253,8 +283,15 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final canonical = await _parseUsername(req, body);
|
final rawUsername = body['username'] as String? ?? '';
|
||||||
if (canonical == null) return;
|
|
||||||
|
String canonical;
|
||||||
|
try {
|
||||||
|
canonical = normalizeUsername(rawUsername);
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final enrollment = await _db.delete(canonical);
|
final enrollment = await _db.delete(canonical);
|
||||||
|
|
@ -301,8 +338,14 @@ class _ProxyServer {
|
||||||
final body = await _readJson(req);
|
final body = await _readJson(req);
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
|
|
||||||
final canonical = await _parseUsername(req, body);
|
final rawUsername = body['username'] as String? ?? '';
|
||||||
if (canonical == null) return;
|
String canonical;
|
||||||
|
try {
|
||||||
|
canonical = normalizeUsername(rawUsername);
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final enrollment = await _db.get(canonical);
|
final enrollment = await _db.get(canonical);
|
||||||
if (enrollment == null) {
|
if (enrollment == null) {
|
||||||
|
|
@ -311,11 +354,7 @@ class _ProxyServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enrollment.hasCredential && _cardCid != null) {
|
if (enrollment.hasCredential && _cardCid != null) {
|
||||||
// FIDO2-direct: getAssertion + local verify.
|
// FIDO2-direct: getAssertion + 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;
|
GetAssertionResult assertionResult;
|
||||||
try {
|
try {
|
||||||
assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!);
|
assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!);
|
||||||
|
|
@ -350,7 +389,7 @@ class _ProxyServer {
|
||||||
'username': canonical,
|
'username': canonical,
|
||||||
'session_token': token,
|
'session_token': token,
|
||||||
'expires_at': expiresAt,
|
'expires_at': expiresAt,
|
||||||
'ttl_seconds': SessionManager.ttlSeconds,
|
'ttl_seconds': _kSessionTtlSeconds,
|
||||||
'auth_mode': authMode,
|
'auth_mode': authMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -446,72 +485,47 @@ class _ProxyServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Auth token endpoint (v2 architecture — per-request token binding)
|
// Resource forwarding
|
||||||
//
|
|
||||||
// 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> _handleAuthGetToken(HttpRequest req) async {
|
Future<void> _handleResourceCounter(HttpRequest req) async {
|
||||||
final body = await _readJson(req);
|
await _drainBody(req);
|
||||||
if (body == null) return;
|
final token = _extractBearerToken(req);
|
||||||
|
if (token == null) {
|
||||||
final url = body['url'] as String? ?? '';
|
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
|
||||||
final method = body['method'] as String? ?? '';
|
return;
|
||||||
final nonce = body['nonce'] as String? ?? '';
|
}
|
||||||
|
final session = _sessions.getSession(token);
|
||||||
if (url.isEmpty || method.isEmpty || nonce.isEmpty) {
|
if (session == null) {
|
||||||
await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'});
|
await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cardAttached || _cardCid == null) {
|
final result = await _kserver.forward(
|
||||||
await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
|
method: 'POST',
|
||||||
return;
|
path: '/resource/counter',
|
||||||
}
|
headers: req.headers,
|
||||||
|
body: Uint8List(0),
|
||||||
// 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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
GetAssertionResult assertionResult;
|
if (result.statusCode != 200) {
|
||||||
try {
|
await _send(req.response, result.statusCode, {'ok': false, 'error': 'upstream failed'});
|
||||||
assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge);
|
|
||||||
} catch (e) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-contained bundle the server can verify without calling back to the phone.
|
Map<String, dynamic> upstream;
|
||||||
final bundleJson = jsonEncode({
|
try {
|
||||||
'v': 1,
|
upstream = jsonDecode(utf8.decode(result.body)) as Map<String, dynamic>;
|
||||||
'url': url,
|
} catch (_) {
|
||||||
'method': method,
|
upstream = {};
|
||||||
'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});
|
await _send(req.response, 200, {
|
||||||
|
'ok': true,
|
||||||
|
'username': session.username,
|
||||||
|
'session_reused': true,
|
||||||
|
'upstream': upstream,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -528,11 +542,19 @@ class _ProxyServer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _serveHtmlBytes(HttpRequest req, List<int> bytes) async {
|
Future<void> _serveHtml(HttpRequest req) async {
|
||||||
req.response.statusCode = 200;
|
req.response.statusCode = 200;
|
||||||
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
||||||
req.response.headers.contentLength = bytes.length;
|
req.response.headers.contentLength = _kPortalHtmlBytes.length;
|
||||||
req.response.add(bytes);
|
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);
|
||||||
await req.response.close();
|
await req.response.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -619,25 +641,245 @@ class _ProxyServer {
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _parseUsername(HttpRequest req, Map<String, dynamic> body) async {
|
Future<SecurityContext> _loadTlsContext() async {
|
||||||
try {
|
throw UnimplementedError('TLS cert loading not yet wired up');
|
||||||
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>''';
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ class SessionEntry {
|
||||||
|
|
||||||
class SessionManager {
|
class SessionManager {
|
||||||
final Map<String, SessionEntry> _sessions = {};
|
final Map<String, SessionEntry> _sessions = {};
|
||||||
static const int ttlSeconds = 300;
|
static const Duration _ttl = Duration(seconds: 300);
|
||||||
static const Duration _ttl = Duration(seconds: ttlSeconds);
|
|
||||||
|
|
||||||
/// Issue a new session token for [username].
|
/// Issue a new session token for [username].
|
||||||
/// _purgeExpired is only called here, not on every lookup, so tokens accumulate
|
/// _purgeExpired is only called here, not on every lookup, so tokens accumulate
|
||||||
|
|
@ -52,15 +51,6 @@ class SessionManager {
|
||||||
return _sessions.isNotEmpty;
|
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].
|
/// Revoke all sessions for [username].
|
||||||
void revokeAll(String username) {
|
void revokeAll(String username) {
|
||||||
_sessions.removeWhere((_, s) => s.username == username);
|
_sessions.removeWhere((_, s) => s.username == username);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ import '../lib/filter_proxy.dart';
|
||||||
|
|
||||||
const _kTimeout = Duration(seconds: 5);
|
const _kTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
// Start an HttpServer that records the first request and replies 200 OK.
|
// 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).
|
||||||
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
|
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
|
||||||
final server = await HttpServer.bind('127.0.0.1', 0);
|
final server = await HttpServer.bind('127.0.0.1', 0);
|
||||||
final c = Completer<HttpRequest>();
|
final c = Completer<HttpRequest>();
|
||||||
|
|
@ -37,35 +38,6 @@ Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() asyn
|
||||||
return (server: server, completer: c);
|
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.
|
// Start a raw TCP server that hands back the accepted Socket.
|
||||||
Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
|
Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
|
||||||
final server = await ServerSocket.bind('127.0.0.1', 0);
|
final server = await ServerSocket.bind('127.0.0.1', 0);
|
||||||
|
|
@ -190,117 +162,56 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Group 2: HTTP routing (v2 semantics)
|
// Group 2: HTTP routing
|
||||||
//
|
|
||||||
// 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', () {
|
group('HTTP routing', () {
|
||||||
late FilterProxy proxy;
|
late FilterProxy proxy;
|
||||||
late HttpServer comp2;
|
late HttpServer comp2;
|
||||||
late Completer<({HttpRequest req, String rawBody})> comp2TokenReq;
|
late Completer<HttpRequest> comp2Req;
|
||||||
late HttpServer endpoint;
|
late HttpServer direct;
|
||||||
late Completer<HttpRequest> endpointReq;
|
|
||||||
late HttpServer directServer;
|
|
||||||
late Completer<HttpRequest> directReq;
|
late Completer<HttpRequest> directReq;
|
||||||
|
|
||||||
const testToken = 'test-bearer-token';
|
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
// Component 2 mock: handles POST /auth/get-token → returns token.
|
final c2 = await _mockHttp();
|
||||||
final c2 = await _mockTokenServer(token: testToken);
|
|
||||||
comp2 = c2.server;
|
comp2 = c2.server;
|
||||||
comp2TokenReq = c2.tokenReq;
|
comp2Req = c2.completer;
|
||||||
|
|
||||||
// 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();
|
final d = await _mockHttp();
|
||||||
directServer = d.server;
|
direct = d.server;
|
||||||
directReq = d.completer;
|
directReq = d.completer;
|
||||||
|
|
||||||
proxy = FilterProxy(
|
proxy = FilterProxy(
|
||||||
listenPort: 0,
|
listenPort: 0,
|
||||||
component2Port: comp2.port,
|
component2Port: comp2.port,
|
||||||
);
|
);
|
||||||
// Gate the endpoint address; 127.0.0.1 + endpoint port is resolvable in tests.
|
// 'auth.local' is gated; '127.0.0.1' is not.
|
||||||
proxy.setGatedEntries(['127.0.0.1:${endpoint.port}']);
|
proxy.setGatedEntries(['auth.local']);
|
||||||
await proxy.start();
|
await proxy.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await proxy.stop();
|
await proxy.stop();
|
||||||
await comp2.close(force: true);
|
await comp2.close(force: true);
|
||||||
await endpoint.close(force: true);
|
await direct.close(force: true);
|
||||||
await directServer.close(force: true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gated host: token is fetched from component2', () async {
|
test('gated host is forwarded to 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(
|
final response = await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
|
'GET http://auth.local/api HTTP/1.1\r\nHost: auth.local\r\n\r\n',
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
|
||||||
);
|
);
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
final req = await comp2Req.future.timeout(_kTimeout);
|
||||||
|
|
||||||
expect(req.method, 'GET');
|
expect(req.method, 'GET');
|
||||||
expect(req.uri.path, '/api');
|
expect(req.uri.path, '/api');
|
||||||
expect(req.headers.value('authorization'), 'Bearer $testToken');
|
|
||||||
expect(response, contains('200 OK'));
|
expect(response, contains('200 OK'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-gated host is forwarded directly', () async {
|
test('non-gated host is forwarded directly', () async {
|
||||||
final response = await _round(
|
final response = await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
|
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
|
||||||
);
|
);
|
||||||
final req = await directReq.future.timeout(_kTimeout);
|
final req = await directReq.future.timeout(_kTimeout);
|
||||||
|
|
||||||
|
|
@ -312,109 +223,78 @@ void main() {
|
||||||
test('non-gated request does NOT reach component2', () async {
|
test('non-gated request does NOT reach component2', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
|
'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
|
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
|
||||||
);
|
);
|
||||||
await directReq.future.timeout(_kTimeout);
|
await directReq.future.timeout(_kTimeout);
|
||||||
expect(comp2TokenReq.isCompleted, isFalse);
|
|
||||||
|
// comp2 should never have received anything
|
||||||
|
expect(comp2Req.isCompleted, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gated: request line is rewritten to relative path', () async {
|
test('request line is rewritten from absolute URL to relative path', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${endpoint.port}/session/login?foo=bar HTTP/1.1\r\n'
|
'GET http://auth.local/session/login?foo=bar HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
'Host: auth.local\r\n\r\n',
|
||||||
);
|
);
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
final req = await comp2Req.future.timeout(_kTimeout);
|
||||||
|
// The mock HttpServer parses the rewritten request.
|
||||||
expect(req.uri.path, '/session/login');
|
expect(req.uri.path, '/session/login');
|
||||||
expect(req.uri.query, 'foo=bar');
|
expect(req.uri.query, 'foo=bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gated: Proxy-Connection header is stripped', () async {
|
test('Proxy-Connection header is stripped', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
'GET http://auth.local/health HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
'Host: auth.local\r\n'
|
||||||
'Proxy-Connection: keep-alive\r\n\r\n',
|
'Proxy-Connection: keep-alive\r\n\r\n',
|
||||||
);
|
);
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
final req = await comp2Req.future.timeout(_kTimeout);
|
||||||
expect(req.headers.value('proxy-connection'), isNull);
|
expect(req.headers.value('proxy-connection'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gated: Proxy-Authorization header is stripped', () async {
|
test('Proxy-Authorization header is stripped', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
'GET http://auth.local/health HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
'Host: auth.local\r\n'
|
||||||
'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
|
'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
|
||||||
);
|
);
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
final req = await comp2Req.future.timeout(_kTimeout);
|
||||||
expect(req.headers.value('proxy-authorization'), isNull);
|
expect(req.headers.value('proxy-authorization'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gated: existing Authorization header is replaced with Bearer token', () async {
|
test('custom header is preserved', () async {
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
'GET http://auth.local/health HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
'Host: auth.local\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',
|
'X-Custom: hello\r\n\r\n',
|
||||||
);
|
);
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
final req = await comp2Req.future.timeout(_kTimeout);
|
||||||
expect(req.headers.value('x-custom'), 'hello');
|
expect(req.headers.value('x-custom'), 'hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gated: POST body is forwarded to endpoint', () async {
|
test('POST body is forwarded to component2', () async {
|
||||||
const body = '{"username":"alice"}';
|
const body = '{"username":"alice"}';
|
||||||
await _round(
|
await _round(
|
||||||
proxy.port,
|
proxy.port,
|
||||||
'POST http://127.0.0.1:${endpoint.port}/session/login HTTP/1.1\r\n'
|
'POST http://auth.local/session/login HTTP/1.1\r\n'
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
'Host: auth.local\r\n'
|
||||||
'Content-Type: application/json\r\n'
|
'Content-Type: application/json\r\n'
|
||||||
'Content-Length: ${body.length}\r\n\r\n'
|
'Content-Length: ${body.length}\r\n\r\n'
|
||||||
'$body',
|
'$body',
|
||||||
);
|
);
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
final req = await comp2Req.future.timeout(_kTimeout);
|
||||||
expect(req.method, 'POST');
|
expect(req.method, 'POST');
|
||||||
expect(req.uri.path, '/session/login');
|
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
|
// Group 3: CONNECT tunnel routing
|
||||||
// (Gated CONNECT is still relayed through Component 2 — unchanged in v2.)
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
group('CONNECT routing', () {
|
group('CONNECT routing', () {
|
||||||
late FilterProxy proxy;
|
late FilterProxy proxy;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ All state is process-local and resets on restart.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import ssl
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -21,86 +19,6 @@ from typing import Any
|
||||||
from urllib.parse import urlparse
|
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:
|
class ServerState:
|
||||||
# All state is process-local; a restart resets the counter to zero.
|
# All state is process-local; a restart resets the counter to zero.
|
||||||
def __init__(self, proxy_token: str):
|
def __init__(self, proxy_token: str):
|
||||||
|
|
@ -134,17 +52,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.rfile.read(length)
|
self.rfile.read(length)
|
||||||
|
|
||||||
def _is_proxy_authorized(self) -> bool:
|
def _is_proxy_authorized(self) -> bool:
|
||||||
# Accept legacy X-Proxy-Token (k_proxy_app.py) or FIDO2 assertion Bearer.
|
return self.headers.get("X-Proxy-Token") == self.state.proxy_token
|
||||||
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
|
def do_GET(self) -> None: # noqa: N802
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
|
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
/**
|
|
||||||
* Acceptance tests for k_phone proxy routing — Chrome inside the Android emulator.
|
|
||||||
*
|
|
||||||
* Same four serial tests as k_phone_proxy.spec.js, but the browser runs inside
|
|
||||||
* the emulator via Playwright's Android module. From inside the emulator
|
|
||||||
* 127.0.0.1:8888 IS Component 1 (filter_proxy.dart) — no adb port-forward needed.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* 1. Android emulator running with the k_phone app started.
|
|
||||||
* 2. ADB connected: adb devices shows the emulator.
|
|
||||||
* 3. card_emulator_bridge.py running on the Mac (auto-approves FIDO2 assertions):
|
|
||||||
* uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
|
|
||||||
* tests/card_emulator_bridge.py
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* npx playwright test tests/k_phone_android.spec.js
|
|
||||||
* npx playwright test tests/k_phone_android.spec.js --headed # shows emulator Chrome
|
|
||||||
*
|
|
||||||
* Tests skip automatically if no Android device/emulator is found via ADB.
|
|
||||||
*
|
|
||||||
* Env vars:
|
|
||||||
* GATED_URL URL of a gated resource (default: http://httpbin.org/get)
|
|
||||||
* GATED_METHOD HTTP method for gated request (default: GET)
|
|
||||||
* UNGATED_URL URL of a non-gated resource (default: http://example.com)
|
|
||||||
* CARD_REGISTRATION_TIMEOUT_MS (default: 90000)
|
|
||||||
* CARD_LOGIN_TIMEOUT_MS (default: 90000)
|
|
||||||
*
|
|
||||||
* Note on proxy bypass:
|
|
||||||
* Chrome bypasses --proxy-server for 127.0.0.1 / localhost by default.
|
|
||||||
* Portal API calls (127.0.0.1:8771) therefore reach Component 2 directly.
|
|
||||||
* External host requests (httpbin.org, example.com) go through Component 1.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, expect } = 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
/**
|
|
||||||
* Playwright acceptance test for the k_phone portal (Component 2, port 8771).
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* K_PHONE_BASE_URL=http://192.168.x.x:8771 npx playwright test tests/k_phone_portal.spec.js
|
|
||||||
*
|
|
||||||
* Env vars:
|
|
||||||
* K_PHONE_BASE_URL Base URL of the k_phone proxy service (default: http://127.0.0.1:8771)
|
|
||||||
* CARD_REGISTRATION_TIMEOUT_MS Timeout for makeCredential card step (default: 90000)
|
|
||||||
* CARD_LOGIN_TIMEOUT_MS Timeout for getAssertion card step (default: 90000)
|
|
||||||
* PW_HEADLESS Set to "1" for headless mode
|
|
||||||
*
|
|
||||||
* Constraint: the test does not read the Android log — all assertions are
|
|
||||||
* made against visible DOM state and the #log pre element.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, expect } = require("@playwright/test");
|
|
||||||
|
|
||||||
const BASE_URL = process.env.K_PHONE_BASE_URL || "http://127.0.0.1:8771";
|
|
||||||
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || "90000");
|
|
||||||
const loginTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || "90000");
|
|
||||||
|
|
||||||
function uniqueUsername() {
|
|
||||||
return `pw_${Date.now().toString(36)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForLog(page, expectedText, timeoutMs = 10_000) {
|
|
||||||
await expect(page.locator("#log")).toContainText(expectedText, { timeout: timeoutMs });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("k_phone portal regression", () => {
|
|
||||||
test(
|
|
||||||
"enrolls, logs in, checks session status, logs out, and deletes user",
|
|
||||||
async ({ page }) => {
|
|
||||||
const username = uniqueUsername();
|
|
||||||
|
|
||||||
test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 60_000);
|
|
||||||
|
|
||||||
await page.goto(BASE_URL + "/");
|
|
||||||
await expect(
|
|
||||||
page.getByRole("heading", { name: "ChromeCard k_phone Portal" })
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Clear any leftover localStorage from a previous session so the test
|
|
||||||
// starts from a clean slate regardless of browser profile state.
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await test.step("Initial state is unauthenticated", async () => {
|
|
||||||
await expect(page.locator("#storedUser")).toHaveText("none");
|
|
||||||
await expect(page.locator("#sessionActive")).toHaveText("no");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Enroll user", async () => {
|
|
||||||
await page.locator("#username").fill(username);
|
|
||||||
await page.locator("#displayName").fill("Playwright Test");
|
|
||||||
// Card step: makeCredential — touch user fingerprint on ChromeCard.
|
|
||||||
await page.locator("#enrollBtn").click();
|
|
||||||
await waitForLog(page, "Enrolled", registrationTimeoutMs);
|
|
||||||
await expect(page.locator("#storedUser")).toHaveText(username);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Login", async () => {
|
|
||||||
// Card step: getAssertion — touch user fingerprint on ChromeCard.
|
|
||||||
await page.locator("#loginBtn").click();
|
|
||||||
await waitForLog(page, "Login ok", loginTimeoutMs);
|
|
||||||
await expect(page.locator("#sessionActive")).toHaveText("yes");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Session status reflects active session", async () => {
|
|
||||||
await page.locator("#statusBtn").click();
|
|
||||||
await waitForLog(page, "Session status");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("List users includes enrolled user", async () => {
|
|
||||||
await page.locator("#listBtn").click();
|
|
||||||
await waitForLog(page, username);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Logout clears session", async () => {
|
|
||||||
await page.locator("#logoutBtn").click();
|
|
||||||
// "Logout" is a substring of "Logout failed", so assert the semantic
|
|
||||||
// outcome (sessionActive → no) rather than the log message text.
|
|
||||||
await expect(page.locator("#sessionActive")).toHaveText("no", {
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Delete user clears stored identity", async () => {
|
|
||||||
await page.locator("#deleteBtn").click();
|
|
||||||
// "Deleted" is not a substring of "Delete failed" — safe to match.
|
|
||||||
await waitForLog(page, "Deleted");
|
|
||||||
await expect(page.locator("#storedUser")).toHaveText("none");
|
|
||||||
await expect(page.locator("#sessionActive")).toHaveText("no");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
test("enrollment failure is surfaced in log", async ({ page }) => {
|
|
||||||
await page.goto(BASE_URL + "/");
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Submit enroll with an empty username — server must reject it.
|
|
||||||
await page.locator("#username").fill("");
|
|
||||||
await page.locator("#enrollBtn").click();
|
|
||||||
await waitForLog(page, "Enroll failed");
|
|
||||||
// No username must have been stored on failure.
|
|
||||||
await expect(page.locator("#storedUser")).toHaveText("none");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("login without enrollment fails gracefully", async ({ page }) => {
|
|
||||||
await page.goto(BASE_URL + "/");
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Attempt login with a username that is not enrolled.
|
|
||||||
await page.locator("#username").fill("no_such_user_pw");
|
|
||||||
await page.locator("#loginBtn").click();
|
|
||||||
await waitForLog(page, "Login failed");
|
|
||||||
await expect(page.locator("#sessionActive")).toHaveText("no");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
/**
|
|
||||||
* Acceptance tests for k_phone Component 1 (filter_proxy) routing behaviour.
|
|
||||||
*
|
|
||||||
* Four tests run serially, building shared state:
|
|
||||||
* 1. No users — non-gated request passes through directly.
|
|
||||||
* 2. No users — gated request is rejected (407 Proxy Authentication Required).
|
|
||||||
* 3. Register user — non-gated request still passes through.
|
|
||||||
* 4. (User enrolled) gated request succeeds after card assertion.
|
|
||||||
*
|
|
||||||
* HTTP proxy requests are made with Node's `http` module so the proxy protocol
|
|
||||||
* (absolute URI in the request line) is exact and Playwright's browser proxy
|
|
||||||
* handling is not involved. The portal page is used for enrollment (test 3)
|
|
||||||
* because that step requires the user to touch the card fingerprint.
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* K_PHONE_PROXY=http://phone-ip:8888 \
|
|
||||||
* K_PHONE_BASE_URL=http://phone-ip:8771 \
|
|
||||||
* GATED_URL=http://httpbin.org/get \
|
|
||||||
* npx playwright test tests/k_phone_proxy.spec.js
|
|
||||||
*
|
|
||||||
* Env vars:
|
|
||||||
* K_PHONE_PROXY Component 1 proxy URL (default: http://127.0.0.1:8888)
|
|
||||||
* K_PHONE_BASE_URL Component 2 portal URL (default: http://127.0.0.1:8771)
|
|
||||||
* GATED_URL URL of a gated resource (default: http://httpbin.org/get)
|
|
||||||
* GATED_METHOD HTTP method for gated request (default: GET)
|
|
||||||
* UNGATED_URL URL of a non-gated resource (default: http://example.com)
|
|
||||||
* CARD_REGISTRATION_TIMEOUT_MS makeCredential card step (default: 90000)
|
|
||||||
* CARD_LOGIN_TIMEOUT_MS getAssertion card step (default: 90000)
|
|
||||||
*
|
|
||||||
* Gated host configuration:
|
|
||||||
* gated_hosts.txt on the phone must contain the host from GATED_URL.
|
|
||||||
* The app seeds httpbin.org by default; no manual edit needed for the default case.
|
|
||||||
* For full chain validation against k_server (which verifies the FIDO2 token):
|
|
||||||
* GATED_URL=http://k-server-ip:8780/resource/counter GATED_METHOD=POST
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const PROXY_URL = process.env.K_PHONE_PROXY || 'http://127.0.0.1:8888';
|
|
||||||
const PORTAL_URL = process.env.K_PHONE_BASE_URL || 'http://127.0.0.1:8771';
|
|
||||||
const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get';
|
|
||||||
const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase();
|
|
||||||
const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com';
|
|
||||||
|
|
||||||
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000');
|
|
||||||
const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000');
|
|
||||||
|
|
||||||
function uniqueUsername() {
|
|
||||||
return `pw_${Date.now().toString(36)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTTP proxy helper — sends one request through Component 1.
|
|
||||||
//
|
|
||||||
// Sends `method targetUrl HTTP/1.1` (absolute URI — the proxy protocol) to
|
|
||||||
// the proxy host:port and returns { status, body }. The caller sets the
|
|
||||||
// timeout via the options object.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function proxyRequest(proxyUrl, method, targetUrl, timeoutMs = 15_000) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const proxy = new URL(proxyUrl);
|
|
||||||
const target = new URL(targetUrl);
|
|
||||||
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
hostname: proxy.hostname,
|
|
||||||
port: Number(proxy.port) || 80,
|
|
||||||
method,
|
|
||||||
path: targetUrl, // absolute URI → proxy protocol
|
|
||||||
headers: { Host: target.host },
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (c) => chunks.push(c));
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
req.setTimeout(timeoutMs, () => req.destroy(new Error(`proxy request to ${targetUrl} timed out after ${timeoutMs} ms`)));
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test.describe.serial('k_phone proxy routing', () => {
|
|
||||||
let enrolledUser = null;
|
|
||||||
|
|
||||||
// Ensure no users are enrolled before the suite runs so tests 1 and 2 start
|
|
||||||
// from a clean state — a gated request with no user must be rejected.
|
|
||||||
test.beforeAll(async ({ request }) => {
|
|
||||||
const resp = await request.get(`${PORTAL_URL}/enroll/list`);
|
|
||||||
const { users } = await resp.json();
|
|
||||||
for (const u of users ?? []) {
|
|
||||||
await request.post(`${PORTAL_URL}/enroll/delete`, {
|
|
||||||
data: { username: u.username },
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove the user enrolled in test 3 after the suite finishes.
|
|
||||||
test.afterAll(async ({ request }) => {
|
|
||||||
if (enrolledUser) {
|
|
||||||
await request.post(`${PORTAL_URL}/enroll/delete`, {
|
|
||||||
data: { username: enrolledUser },
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 1: no users — non-gated request passes through.
|
|
||||||
//
|
|
||||||
// Component 1 forwards non-gated traffic directly to the target host on
|
|
||||||
// port 80 without touching Component 2 or the card.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('1. no users: non-gated request passes through', async () => {
|
|
||||||
const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL);
|
|
||||||
expect(status).toBeLessThan(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 2: no users — gated request rejected with 407.
|
|
||||||
//
|
|
||||||
// Component 1 calls Component 2 for a Bearer token. Component 2 has no
|
|
||||||
// enrolled user and returns an error. Component 1 replies with
|
|
||||||
// 407 Proxy Authentication Required.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('2. no users: gated request rejected with 407', async () => {
|
|
||||||
const { status } = await proxyRequest(PROXY_URL, GATED_METHOD, GATED_URL);
|
|
||||||
expect(status).toBe(407);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 3: register user — non-gated request still passes through.
|
|
||||||
//
|
|
||||||
// Card step: makeCredential (touch user fingerprint on ChromeCard).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('3. enroll user: non-gated request still passes through', async ({ page }) => {
|
|
||||||
test.setTimeout(registrationTimeoutMs + 30_000);
|
|
||||||
|
|
||||||
enrolledUser = uniqueUsername();
|
|
||||||
|
|
||||||
// Enroll via portal — requires card fingerprint for makeCredential.
|
|
||||||
await page.goto(`${PORTAL_URL}/`);
|
|
||||||
await page.locator('#username').fill(enrolledUser);
|
|
||||||
await page.locator('#displayName').fill('Playwright Proxy Test');
|
|
||||||
await page.locator('#enrollBtn').click();
|
|
||||||
await expect(page.locator('#log')).toContainText('Enrolled', {
|
|
||||||
timeout: registrationTimeoutMs,
|
|
||||||
});
|
|
||||||
await expect(page.locator('#storedUser')).toHaveText(enrolledUser);
|
|
||||||
|
|
||||||
// Non-gated traffic must still be forwarded directly — enrollment must not
|
|
||||||
// break the direct-forward path.
|
|
||||||
const { status } = await proxyRequest(PROXY_URL, 'GET', UNGATED_URL);
|
|
||||||
expect(status).toBeLessThan(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 4: enrolled user — gated request succeeds after card assertion.
|
|
||||||
//
|
|
||||||
// Card step: getAssertion (touch user fingerprint on ChromeCard).
|
|
||||||
//
|
|
||||||
// The 200 response proves:
|
|
||||||
// - Component 1 fetched a token from Component 2.
|
|
||||||
// - Component 2 performed a FIDO2 assertion against the enrolled credential.
|
|
||||||
// - Component 1 forwarded the request to the gated endpoint with the token.
|
|
||||||
//
|
|
||||||
// Response body check (both targets):
|
|
||||||
// httpbin.org — echoes the Authorization: Bearer header in its JSON response.
|
|
||||||
// k_server — validates the assertion cryptographically and returns
|
|
||||||
// { ok: true, resource: "counter", value: N }.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('4. enrolled user: gated request succeeds — card asserted', async () => {
|
|
||||||
test.setTimeout(cardAssertionTimeoutMs + 30_000);
|
|
||||||
|
|
||||||
const { status, body } = await proxyRequest(
|
|
||||||
PROXY_URL, GATED_METHOD, GATED_URL, cardAssertionTimeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 200 proves the card assertion was performed and the token was accepted.
|
|
||||||
expect(status).toBe(200);
|
|
||||||
|
|
||||||
// Verify the token was actually forwarded to the target endpoint.
|
|
||||||
let parsed = null;
|
|
||||||
try { parsed = JSON.parse(body); } catch (_) {}
|
|
||||||
|
|
||||||
if (parsed?.headers?.Authorization !== undefined) {
|
|
||||||
// httpbin.org echoes request headers — the Bearer token must be present.
|
|
||||||
expect(parsed.headers.Authorization).toMatch(/^Bearer /i);
|
|
||||||
} else if (parsed?.resource !== undefined) {
|
|
||||||
// k_server validated the assertion and returned the counter value.
|
|
||||||
expect(parsed.ok).toBe(true);
|
|
||||||
expect(parsed.resource).toBe('counter');
|
|
||||||
expect(typeof parsed.value).toBe('number');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
"""
|
|
||||||
Unit + round-trip tests for k_server_app._verify_assertion_token.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
|
|
||||||
python3 -m unittest tests/test_k_server.py
|
|
||||||
|
|
||||||
The unit tests (TestVerifyAssertionToken) only need cbor2 + cryptography.
|
|
||||||
The round-trip tests (TestVerifyAssertionTokenRoundTrip) also need fido2
|
|
||||||
(through CardEmulator) — they are skipped automatically if fido2 is absent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
|
|
||||||
import k_server_app
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dependency guards
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
try:
|
|
||||||
import cbor2 # noqa: F401
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
||||||
ECDSA,
|
|
||||||
SECP256R1,
|
|
||||||
generate_private_key,
|
|
||||||
)
|
|
||||||
from cryptography.hazmat.primitives.hashes import SHA256
|
|
||||||
HAS_CRYPTO = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_CRYPTO = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from card_emulator import CardEmulator
|
|
||||||
HAS_FIDO2 = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_FIDO2 = False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _b64u_encode(b: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
||||||
|
|
||||||
|
|
||||||
def _b64u_decode(s: str) -> bytes:
|
|
||||||
padded = s + "=" * ((4 - len(s) % 4) % 4)
|
|
||||||
return base64.urlsafe_b64decode(padded)
|
|
||||||
|
|
||||||
|
|
||||||
# COSE ES256 key layout matching card_emulator._cose_es256 exactly.
|
|
||||||
def _cose_es256(x: bytes, y: bytes) -> bytes:
|
|
||||||
return (
|
|
||||||
bytes([0xA5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20])
|
|
||||||
+ x
|
|
||||||
+ bytes([0x22, 0x58, 0x20])
|
|
||||||
+ y
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_bundle(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)
|
|
||||||
Loading…
Reference in New Issue