Compare commits
No commits in common. "main" and "docs-maintenance" have entirely different histories.
main
...
docs-maint
|
|
@ -10,19 +10,3 @@ test-results/
|
||||||
|
|
||||||
# Keep firmware SDK tree out of this workspace-tracking repo
|
# Keep firmware SDK tree out of this workspace-tracking repo
|
||||||
CR_SDK_CK-main/
|
CR_SDK_CK-main/
|
||||||
|
|
||||||
# Flutter/Dart build artifacts
|
|
||||||
k_phone/.dart_tool/
|
|
||||||
k_phone/build/
|
|
||||||
k_phone/.flutter-plugins
|
|
||||||
k_phone/.flutter-plugins-dependencies
|
|
||||||
k_phone/android/.gradle/
|
|
||||||
k_phone/android/local.properties
|
|
||||||
k_phone/android/app/build/
|
|
||||||
k_phone/ios/.symlinks/
|
|
||||||
k_phone/ios/Pods/
|
|
||||||
k_phone/ios/Flutter/App.framework
|
|
||||||
k_phone/ios/Flutter/Flutter.framework
|
|
||||||
k_phone/ios/Flutter/Generated.xcconfig
|
|
||||||
*.g.dart
|
|
||||||
*.freezed.dart
|
|
||||||
|
|
|
||||||
164
CLAUDE.md
164
CLAUDE.md
|
|
@ -1,164 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Repository policy
|
|
||||||
|
|
||||||
- `CR_SDK_CK-main/` is the firmware SDK source tree. Treat it as **read-only**. Do not add or edit files there.
|
|
||||||
- All host-side scripts live at the workspace root (`/home/user/chromecard`).
|
|
||||||
- `Setup.md` and `Workplan.md` are the canonical living docs. After each meaningful execution step, update `Setup.md` for environment/runtime state and `Workplan.md` for phase progress and next blocking action.
|
|
||||||
|
|
||||||
## Running tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Python unit tests (no VMs or card required, 122 tests)
|
|
||||||
python3 -m unittest tests/test_k_proxy.py
|
|
||||||
|
|
||||||
# Playwright browser regression (requires services running and forwarded portal)
|
|
||||||
PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client
|
|
||||||
|
|
||||||
# Split-VM end-to-end regression helper (runs from host via SSH into k_client)
|
|
||||||
./phase5_chain_regression.sh
|
|
||||||
./phase5_chain_regression.sh --interactive-card --expect-auth-mode fido2_assertion
|
|
||||||
```
|
|
||||||
|
|
||||||
## Probing the card (on k_proxy)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 /home/user/chromecard/fido2_probe.py --list
|
|
||||||
python3 /home/user/chromecard/fido2_probe.py --json
|
|
||||||
python3 /home/user/chromecard/raw_ctap_probe.py info
|
|
||||||
python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost
|
|
||||||
python3 /home/user/chromecard/webauthn_local_demo.py # opens http://localhost:8765
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generating TLS certificates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 generate_phase2_certs.py
|
|
||||||
# Writes tls/phase2/ca.crt, k_proxy.crt/.key, k_server.crt/.key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Starting services (split-VM shape)
|
|
||||||
|
|
||||||
**k_server VM:**
|
|
||||||
```bash
|
|
||||||
python3 /home/user/chromecard/k_server_app.py \
|
|
||||||
--host 127.0.0.1 --port 8780 \
|
|
||||||
--proxy-token dev-proxy-token \
|
|
||||||
--tls-certfile /home/user/chromecard/tls/phase2/k_server.crt \
|
|
||||||
--tls-keyfile /home/user/chromecard/tls/phase2/k_server.key
|
|
||||||
```
|
|
||||||
|
|
||||||
**k_proxy VM:**
|
|
||||||
```bash
|
|
||||||
qvm-connect-tcp 9780:k_server:8780
|
|
||||||
|
|
||||||
python3 /home/user/chromecard/k_proxy_app.py \
|
|
||||||
--host 127.0.0.1 --port 8771 --session-ttl 300 \
|
|
||||||
--server-base-url https://127.0.0.1:9780 \
|
|
||||||
--server-ca-file /home/user/chromecard/tls/phase2/ca.crt \
|
|
||||||
--proxy-token dev-proxy-token \
|
|
||||||
--tls-certfile /home/user/chromecard/tls/phase2/k_proxy.crt \
|
|
||||||
--tls-keyfile /home/user/chromecard/tls/phase2/k_proxy.key
|
|
||||||
# Add --auth-mode fido2-direct for real CTAP2 (default is probe mode)
|
|
||||||
```
|
|
||||||
|
|
||||||
**k_client VM:**
|
|
||||||
```bash
|
|
||||||
qvm-connect-tcp 9771:k_proxy:8771
|
|
||||||
|
|
||||||
python3 /home/user/chromecard/k_client_portal.py \
|
|
||||||
--proxy-base-url https://127.0.0.1:9771
|
|
||||||
# Browser demo page at http://127.0.0.1:8766
|
|
||||||
```
|
|
||||||
|
|
||||||
Files are deployed to VMs via `scp <file> <host>:~` and run via `ssh <host> <cmd>`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Target production architecture (4-device system)
|
|
||||||
|
|
||||||
Four physical devices: optional client computer, phone, chromecard, server.
|
|
||||||
|
|
||||||
**Devices:**
|
|
||||||
- **Client (optional):** Computer with Component 3 installed. No browser proxy configuration needed.
|
|
||||||
- **Phone:** Central hub. Runs Component 1 and Component 2, hosts registration page, connects to chromecard via USB or WiFi.
|
|
||||||
- **Chromecard:** FIDO2 hardware security module. All crypto happens on-card; private keys never leave. Two fingerprint types: *user* (login) and *admin* (registration/deletion).
|
|
||||||
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
|
|
||||||
|
|
||||||
**Components on the phone:**
|
|
||||||
- **Component 1 — Proxy + gating filter:** Listens on a local port. Receives requests from the phone's own browser and from external clients via Component 3. Binary decision per request: host is gated → forward to Component 2, receive WebAuthn token back, then call the endpoint with the token (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
|
|
||||||
- **Component 2 — WebAuthn client + URL recognition:** Receives requests from Component 1. Always returns a WebAuthn token to the caller — never calls endpoints itself. Detects registration-URL → triggers admin registration flow (admin fingerprint); other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, returns token to Component 1).
|
|
||||||
- **Registration page:** Local web app on phone. Requires admin fingerprint on the card for enrollment/deletion.
|
|
||||||
|
|
||||||
**Component 3 (on external client):**
|
|
||||||
- Installed on external client computers; replaces the old browser-proxy-configuration approach.
|
|
||||||
- Finds the phone on the network (currently via hardcoded IP+port — TODO: rendezvous mechanism).
|
|
||||||
- Forwards validation requests to Component 1, receives WebAuthn token back, calls the protected endpoint directly, and returns the response to the browser.
|
|
||||||
- Must be a compiled binary that runs without a specific runtime. Recommended: **Go** (single static binary, cross-platform). Alternative: Rust (stronger memory guarantees, higher implementation complexity).
|
|
||||||
|
|
||||||
**Three flows:**
|
|
||||||
- **Flow A (authenticated access — phone browser):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → token returned to Component 1 → Component 1 calls endpoint (TLS) → resource returned.
|
|
||||||
- **Flow A (authenticated access — external client):** Browser → Component 3 → Component 1 → Component 2 → Card (user fingerprint) → token returned to Component 1 → token returned to Component 3 → Component 3 calls endpoint (TLS) → resource returned to browser.
|
|
||||||
- **Flow B (registration):** Browser → Component 1 → Component 2 (detects registration URL) → Card (admin fingerprint) → user created/deleted on card.
|
|
||||||
- **Flow C (unauthenticated):** Host not gated → Component 1 forwards directly to internet via port 80 (unencrypted, bypasses Component 2 and card). By design for normal web traffic.
|
|
||||||
|
|
||||||
**Open architectural decisions:**
|
|
||||||
- PIN on card (in addition to biometrics) — not yet decided
|
|
||||||
- User database location: on-card only vs. external — not yet decided
|
|
||||||
- Network-level access control on registration page — not yet decided
|
|
||||||
- Rendezvous mechanism for Component 3 to discover the phone — not yet decided
|
|
||||||
- iOS requires a push-relay component (APNs) for background operation; Android does not — platform priority not yet decided
|
|
||||||
|
|
||||||
### Development topology (Qubes 3-VM)
|
|
||||||
|
|
||||||
**Qubes 3-VM topology:** `k_client` → `k_proxy` → `k_server`, each a Debian 13 AppVM.
|
|
||||||
|
|
||||||
Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP routing). Validated chain:
|
|
||||||
- `k_client localhost:9771` → `k_proxy:8771`
|
|
||||||
- `k_proxy localhost:9780` → `k_server:8780`
|
|
||||||
|
|
||||||
**k_proxy_app.py** — session gateway and FIDO2 auth bridge. Two auth modes:
|
|
||||||
- `probe` (default): validates card presence by subprocess-calling `fido2_probe.py --json`
|
|
||||||
- `fido2-direct`: performs real CTAP2 `makeCredential`/`getAssertion` against the physical card via `python-fido2`; auto-detects the FIDO hidraw device
|
|
||||||
|
|
||||||
`ProxyState` holds all server-side state: in-memory session store (guarded by one lock), enrollment DB (JSON file), and an `UpstreamPool` of persistent TLS connections to k_server. Sessions are lost on restart.
|
|
||||||
|
|
||||||
**k_server_app.py** — protected resource backend. Exposes a monotonic counter behind `X-Proxy-Token` auth. Counter state is in-memory only; resets on restart. Lock guards counter increments.
|
|
||||||
|
|
||||||
**k_client_portal.py** — thin browser-facing portal in k_client. Delegates all auth and resource calls to k_proxy. Holds only a local preferred username; enrollment and session state live in k_proxy.
|
|
||||||
|
|
||||||
**FIDO2 transport:** Card communicates over USB HID (CTAPHID) on `/dev/hidraw0` (FIDO interface, usage page `0xF1D0`). `/dev/hidraw1` is a separate vendor HID interface. If the card re-enumerates, k_proxy auto-detects the correct node. If CTAPHID stops responding, a full USB power cycle is the recovery path.
|
|
||||||
|
|
||||||
**CardEmulator** (`tests/card_emulator.py`) — software emulator of the card for unit tests. Implements `make_credential`/`get_assertion` with real P-256 crypto; `user_confirms=False` simulates card rejection. Wire it into tests by patching `_with_direct_ctap2` and `_drop_direct_device` on `ProxyState`. See the module docstring for the exact patch pattern.
|
|
||||||
|
|
||||||
**Key enrollment endpoints on k_proxy:** `POST /enroll/register`, `GET /enroll/status`, `POST /enroll/update`, `POST /enroll/delete`, `GET /enroll/list`. Usernames are normalized to lowercase, 3–32 chars `[a-z0-9._-]`.
|
|
||||||
|
|
||||||
**Key session endpoints on k_proxy:** `POST /session/login`, `POST /session/status`, `POST /session/logout`, `POST /resource/counter`.
|
|
||||||
|
|
||||||
### k_phone Flutter app (Phase 9 — replaces k_proxy)
|
|
||||||
|
|
||||||
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → fetches bearer token from Component 2 (`POST /auth/get-token`), then calls endpoint directly with `Authorization: Bearer`; non-gated → direct to target. HTTPS CONNECT to gated host: relays CONNECT through Component 2 (session-gate check). Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
|
|
||||||
|
|
||||||
**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), `/auth/get-token`, and CONNECT tunnels. Returns bearer token to caller via `/auth/get-token`; never calls endpoints itself. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally.
|
|
||||||
|
|
||||||
**`k_phone/lib/portal_html.dart`** — HTML string constants (`kPortalHtml`, `kEnrollHtml`) and pre-encoded byte lists (`kPortalHtmlBytes`, `kEnrollHtmlBytes`) for the portal and enrollment pages served by Component 2.
|
|
||||||
|
|
||||||
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests). `SessionManager.ttlSeconds` is the public TTL constant (300 s).
|
|
||||||
|
|
||||||
**`k_phone/lib/fido2_ops.dart`** — `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification against the card via CTAPHID.
|
|
||||||
|
|
||||||
**`k_phone/lib/ctaphid_channel.dart`** — CTAPHID framing over USB (Kotlin platform channel) or emulator bridge TCP socket (`card_emulator_bridge.py` on `10.0.2.2:8772`).
|
|
||||||
|
|
||||||
**`k_phone/lib/enrollment_db.dart`** — enrollment model + JSON persistence via path_provider.
|
|
||||||
|
|
||||||
**Tests:** `flutter test test/filter_proxy_test.dart` and `flutter test test/enrollment_test.dart` (no device needed).
|
|
||||||
|
|
||||||
## Known limits and blockers
|
|
||||||
|
|
||||||
- Concurrency ceiling on the browser-facing forwarded path is ~10 in-flight requests; higher fan-out triggers Qubes vchan failures (`xs_transaction_start: No space left on device`).
|
|
||||||
- If CTAPHID `INIT` packets get no reply after a card reattach, a full USB power cycle recovers the transport.
|
|
||||||
- `CR_SDK_CK-main` is missing role directories (`mvp`, `setup`, `components`, `samples`) required for the firmware build/flash flow (`./scripts/build_flash_mvp.sh`). `west` and `nrfjprog` must also be installed.
|
|
||||||
- Phase 7 (firmware build): blocked on Chrome Roads (card vendor).
|
|
||||||
- Phase 9 (phone): Component 2 CONNECT handler implemented. HTTPS tunnels to gated hosts are gated by `hasAnyActiveSession()`; the tunnel connects to the actual upstream target (not k_server).
|
|
||||||
113
Setup.md
113
Setup.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
Last updated: 2026-05-08
|
Last updated: 2026-04-26
|
||||||
|
|
||||||
This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`.
|
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.
|
||||||
|
|
@ -611,23 +611,6 @@ Session note (2026-04-25, direct FIDO2 auth attempt):
|
||||||
- the deployed `k_proxy` service was restored to default `probe` mode
|
- the deployed `k_proxy` service was restored to default `probe` mode
|
||||||
- verified `alice` login still works afterward, so the validated Phase 5 baseline remains intact
|
- verified `alice` login still works afterward, so the validated Phase 5 baseline remains intact
|
||||||
|
|
||||||
Session note (2026-04-27, fido2-direct end-to-end browser validation):
|
|
||||||
- Deployed all three services (k_server, k_proxy, k_client_portal) in split-VM chain via SSH/SCP.
|
|
||||||
- k_proxy restarted with --auth-mode fido2-direct.
|
|
||||||
- Full browser flow verified from k_client at http://127.0.0.1:8766 with real card:
|
|
||||||
- Register: makeCredential triggered on card, button press confirmed.
|
|
||||||
- Login: getAssertion triggered on card, button press confirmed.
|
|
||||||
- Counter: k_server returned incremented value.
|
|
||||||
- Logout: session correctly invalidated.
|
|
||||||
- Confirmed: probe mode showed stale directtest enrollment (no credential_data_b64) from earlier session; that is expected.
|
|
||||||
- Bug found and fixed: clicking Register after Login cleared the client-side session token but left the server-side session alive; fix adds a best-effort /session/logout call to k_proxy before re-enrolling.
|
|
||||||
- Current deployed service state:
|
|
||||||
- k_server: https://127.0.0.1:8780, TLS, proxy-token dev-proxy-token
|
|
||||||
- k_proxy: https://127.0.0.1:8771, TLS, --auth-mode fido2-direct, upstream https://127.0.0.1:9780
|
|
||||||
- k_client: http://127.0.0.1:8766, proxy-base-url https://127.0.0.1:9771
|
|
||||||
- Forwards: k_proxy 9780->k_server:8780, k_client 9771->k_proxy:8771
|
|
||||||
- Unit test suite added: tests/test_k_proxy.py (100 tests, all passing, run locally with python3 -m unittest tests/test_k_proxy.py).
|
|
||||||
|
|
||||||
Session note (2026-04-26, markdown maintenance re-scan):
|
Session note (2026-04-26, markdown maintenance re-scan):
|
||||||
- Re-read the maintained workspace markdown set:
|
- Re-read the maintained workspace markdown set:
|
||||||
- `/home/user/chromecard/Setup.md`
|
- `/home/user/chromecard/Setup.md`
|
||||||
|
|
@ -648,100 +631,6 @@ Session note (2026-04-26, markdown maintenance re-scan):
|
||||||
- direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime
|
- direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime
|
||||||
- the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up
|
- the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up
|
||||||
|
|
||||||
Session note (2026-04-27, card emulator and bug fixes):
|
|
||||||
- Added software emulator of the ChromeCard FIDO2 authenticator:
|
|
||||||
- `/home/user/chromecard/tests/card_emulator.py`
|
|
||||||
- implements `make_credential` and `get_assertion` with real P-256 cryptography
|
|
||||||
- in-memory credential store keyed by credential ID (matching firmware layout)
|
|
||||||
- auth_data byte layout and COSE key encoding mirror `fido_make_cred.c` / `fido_get_assertion.c` exactly
|
|
||||||
- `user_confirms=True/False` parameter simulates the card's Yes/No confirmation dialog
|
|
||||||
- `refusing()` method returns a wrapper that forces `user_confirms=False` for integration test paths
|
|
||||||
- `forget_user(username)` simulates card-side credential removal
|
|
||||||
- module docstring is the usage guide
|
|
||||||
- Fixed two bugs in `k_proxy_app.py` that were silently breaking fido2-direct mode:
|
|
||||||
- `RegistrationResponse(id=..., ...)` → `RegistrationResponse(raw_id=..., ...)` (fido2 2.2.0 API)
|
|
||||||
- `AuthenticationResponse(id=..., ...)` → `AuthenticationResponse(raw_id=..., ...)` (same)
|
|
||||||
- both calls raised `TypeError` at runtime, caught by the surrounding `except`, so register and
|
|
||||||
authenticate in fido2-direct mode always returned failure without any visible error
|
|
||||||
- Extended test suite: 22 new tests across `TestCardEmulatorUnit` and `TestCardEmulatorIntegration`
|
|
||||||
- covers: register, authenticate, user-says-no (register and auth), forget, two-user isolation,
|
|
||||||
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
|
|
||||||
- total test count is now 122, all passing locally without card or VMs
|
|
||||||
|
|
||||||
Session note (2026-05-09, Android Playwright tests passing):
|
|
||||||
- All 4 tests in `tests/k_phone_android.spec.js` now pass (16 s total on emulator).
|
|
||||||
- Root cause 1: `playwright.android.devices()[0].launchBrowser()` hangs indefinitely on Chrome 145 in the emulator. Replaced with: write `--proxy-server=127.0.0.1:8888` to `/data/local/tmp/chrome-command-line` via adb, force-stop + restart Chrome, forward `tcp:9222 localabstract:chrome_devtools_remote`, and connect via `chromium.connectOverCDP()`. CDP becomes ready within 2–3 s; a polling retry loop (max 15 s) handles variance.
|
|
||||||
- Root cause 2: `card_emulator_bridge.py` TCP socket in the Flutter app becomes stale when the bridge process is restarted. `_cardAttached` and `_cardCid` remained set in `proxy_service.dart` even after the Dart socket `onDone` fired. Added `_ensureCardOpen()` in `proxy_service.dart`, called before `makeCredential` (enrollment) and `getAssertion` (login and `/auth/get-token`). The method calls `isCardAttached()` and, if the socket is closed, re-runs `_tryOpenCard()` to reconnect.
|
|
||||||
- Global Playwright test timeout reduced from 180 s to 60 s in `playwright.config.js`. No test should need more than 60 s (FIDO2 assertion via CardEmulator bridge is instant).
|
|
||||||
- `adb` path: discovered at `~/Library/Android/sdk/platform-tools/adb` (not in system PATH). The spec file now auto-detects it without requiring a modified PATH.
|
|
||||||
- `card_emulator_bridge.py` must be running before the first card operation. The bridge does not need restarting between test runs — `_ensureCardOpen()` in the Flutter app reconnects automatically after a bridge restart.
|
|
||||||
|
|
||||||
Session note (2026-05-08, per-request token binding + Playwright acceptance tests):
|
|
||||||
- Per-request FIDO2 token binding implemented across the full stack:
|
|
||||||
- `k_phone/lib/fido2_ops.dart`: `GetAssertionResult` carries `clientDataJson`; `getAssertion()` accepts optional bound challenge.
|
|
||||||
- `k_phone/lib/proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, returns a self-contained assertion bundle as base64url Bearer token. No session created.
|
|
||||||
- `k_phone/lib/filter_proxy.dart`: `_getAuthToken(uri, method)` generates a 16-byte secure nonce, POSTs `{url, method, nonce}` to Component 2.
|
|
||||||
- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session cache, no mutex.
|
|
||||||
- `k_server_app.py`: `_verify_assertion_token()` added — verifies path+method, challenge, and ECDSA-P256 signature from the self-contained bundle. `_is_proxy_authorized()` accepts legacy `X-Proxy-Token` or `Authorization: Bearer <bundle>`.
|
|
||||||
- Test coverage added:
|
|
||||||
- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit + 5 CardEmulator round-trips. All pass.
|
|
||||||
Run: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography python3 -m unittest tests/test_k_server.py`
|
|
||||||
- `k_phone/test/filter_proxy_test.dart`: 2 new tests verify `/auth/get-token` body fields. 48/48 pass.
|
|
||||||
- Playwright acceptance tests added (three specs, all in `tests/`):
|
|
||||||
- `k_phone_portal.spec.js`: portal UI flow — enroll → login → status → list → logout → delete. DOM assertions only; no phone screen needed.
|
|
||||||
Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js`
|
|
||||||
- `k_phone_proxy.spec.js`: 4 serial proxy-routing tests using Node `http` module.
|
|
||||||
1. No users → non-gated passes. 2. No users → gated rejected (407). 3. Enroll (card) → non-gated still passes. 4. Gated succeeds with card assertion (200 + Bearer token in response).
|
|
||||||
Run: `K_PHONE_PROXY=http://127.0.0.1:8888 K_PHONE_BASE_URL=http://127.0.0.1:8771 npx playwright test tests/k_phone_proxy.spec.js` (requires `adb forward tcp:8888 tcp:8888 && adb forward tcp:8771 tcp:8771`)
|
|
||||||
- `k_phone_android.spec.js`: same 4 tests but Chrome runs inside the Android emulator via Playwright Android (`playwright.android.devices()`). No adb port-forward needed — `127.0.0.1:8888` is Component 1 from inside the emulator. Auto-skips if no ADB device found.
|
|
||||||
Prerequisite: `npm install playwright` + card_emulator_bridge.py running.
|
|
||||||
Run: `npx playwright test tests/k_phone_android.spec.js [--headed]`
|
|
||||||
- card_emulator_bridge.py auto-approves all FIDO2 operations instantly — no physical fingerprint or card needed for emulator tests. The `CARD_REGISTRATION_TIMEOUT_MS` / `CARD_LOGIN_TIMEOUT_MS` timeouts exist only for physical ChromeCard use.
|
|
||||||
- Flutter analyze: no issues. `go build ./...`: clean. 48/48 Flutter tests pass.
|
|
||||||
|
|
||||||
Session note (2026-04-29, Phase 9 k_phone bring-up):
|
|
||||||
- Phase 9 approved and started: Flutter Android app (`k_phone`) replaces `k_proxy` in the auth chain.
|
|
||||||
- Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor.
|
|
||||||
- Mac environment:
|
|
||||||
- Flutter SDK installed (stable channel)
|
|
||||||
- Android Studio installed with API 37 emulator (`Pixel_7_Pro_API_37`)
|
|
||||||
- Python package manager: `brew install uv` used as workaround — macOS 26 beta broke `pip` on both Python 3.14 (Homebrew default) and Python 3.12 due to libexpat ABI mismatch
|
|
||||||
- `k_phone` Flutter project scaffolded at `/Users/mortenv.christiansen/Desktop/chromecard/k_phone/`
|
|
||||||
- Kotlin `MainActivity.kt` registers USB HID platform channel (`com.chromecard.kphone/usb_hid`)
|
|
||||||
- `lib/ctaphid_channel.dart`: CTAPHID framing/fragmentation + two transports (USB MethodChannel and emulator TCP socket)
|
|
||||||
- `lib/proxy_service.dart`: background service HTTP proxy (flutter_background_service v5)
|
|
||||||
- `lib/session_manager.dart`: in-memory bearer token sessions with TTL
|
|
||||||
- `lib/k_server_client.dart`: HTTP forwarder to k_server (:8780)
|
|
||||||
- `android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt`: USB HID platform channel implementation
|
|
||||||
- Build issues resolved (10+ iterations):
|
|
||||||
- AGP bumped to 8.7.3, Gradle wrapper to 8.10.2, Kotlin to 2.1.0
|
|
||||||
- Foreground service type changed from `connectedDevice` to `dataSync` for emulator compatibility
|
|
||||||
- Notification channel created natively in `MainActivity.onCreate()` before service starts
|
|
||||||
- `MissingPluginException` caught in all USB channel calls (USB plugin not registered in background isolate)
|
|
||||||
- Core library desugaring enabled with `desugar_jdk_libs:2.1.4`
|
|
||||||
- Network security config added to allow cleartext to `10.0.2.2` (Mac host alias in Android emulator)
|
|
||||||
- Card emulator bridge added: `tests/card_emulator_bridge.py`
|
|
||||||
- asyncio TCP server on `127.0.0.1:8772`
|
|
||||||
- bridges CTAPHID packets from Android emulator to Python `CardEmulator`
|
|
||||||
- handles CTAPHID INIT (CID allocation), multi-packet reassembly, CBOR dispatch to `CardEmulator`
|
|
||||||
- run with: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography tests/card_emulator_bridge.py`
|
|
||||||
- End-to-end bridge verified: app reports `Card open, CID=0x1` — CTAPHID handshake with CardEmulator confirmed
|
|
||||||
- Current status (2026-04-29, emulator FIDO2 verified):
|
|
||||||
- App builds and runs on Android emulator
|
|
||||||
- Service auto-starts (`autoStart: true` for testing; revert to `false` for production)
|
|
||||||
- USB transport falls back to emulator TCP bridge on `10.0.2.2:8772`
|
|
||||||
- FIDO2 endpoints fully implemented (enrollment_db.dart, fido2_ops.dart, proxy_service.dart)
|
|
||||||
- Three bugs fixed during emulator integration:
|
|
||||||
1. CTAP2 command prefix bytes missing from CTAPHID CBOR payload (fido2_ops.dart)
|
|
||||||
2. Socket single-subscription stream bug — `await for ... break` cannot be reused (ctaphid_channel.dart)
|
|
||||||
3. `on StateError` catch masked socket write errors as "user already enrolled" (proxy_service.dart)
|
|
||||||
- Verified end-to-end on emulator with CardEmulator bridge:
|
|
||||||
- `/enroll/register` → makeCredential → `has_credential: true`
|
|
||||||
- `/session/login` → getAssertion + ECDSA verify → `auth_mode: fido2_assertion`
|
|
||||||
- `/session/status`, `/session/logout`, post-logout 401 — all correct
|
|
||||||
- `/resource/counter` fails (k_server not running in Mac test env — expected)
|
|
||||||
- Next step: deploy to real Android phone, test USB HID path with physical ChromeCard
|
|
||||||
|
|
||||||
## Known FIDO2 Transport Boundary
|
## Known FIDO2 Transport Boundary
|
||||||
|
|
||||||
- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.
|
- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.
|
||||||
|
|
|
||||||
243
Workplan.md
243
Workplan.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Workplan
|
# Workplan
|
||||||
|
|
||||||
Last updated: 2026-05-08
|
Last updated: 2026-04-26
|
||||||
|
|
||||||
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
|
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
|
||||||
|
|
||||||
|
|
@ -527,230 +527,43 @@ Status (2026-04-25, dom0 policy fix validated):
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
- New team member can follow docs end-to-end without path or tooling ambiguity.
|
- New team member can follow docs end-to-end without path or tooling ambiguity.
|
||||||
|
|
||||||
## Phase 9: Migrate to Phone-Mediated Wireless Validation
|
## Phase 9: Migrate to Phone-Mediated Wireless Validation (Future)
|
||||||
|
|
||||||
Status (2026-05-04): **ACTIVE — Architecture v2 adopted; Component 1 + Component 2 CONNECT handler complete**
|
1. Auth transport abstraction in `k_proxy`.
|
||||||
|
- Introduce/keep a transport interface for authenticator operations.
|
||||||
|
- Implement at least two backends:
|
||||||
|
- USB-direct backend (current).
|
||||||
|
- Phone-wireless backend (future).
|
||||||
|
|
||||||
### Architecture v2 changes (2026-05-04)
|
2. Wireless phone integration.
|
||||||
|
- Define protocol between `k_proxy` and phone service.
|
||||||
|
- Define secure pairing/authentication and message integrity for wireless link.
|
||||||
|
- Add timeout/retry behavior and offline handling.
|
||||||
|
|
||||||
The following changes replace the v1 architecture. Source: `chromecard_arkitektur_v2.docx`.
|
3. Functional equivalence tests.
|
||||||
|
- Verify login/enrollment behavior is unchanged at API level for `k_client`.
|
||||||
|
- Verify session reuse still works and card prompts are not increased unexpectedly.
|
||||||
|
|
||||||
**Component 2 no longer calls endpoints:** Component 2 returns the WebAuthn token to whoever asked (Component 1). It is Component 1 that calls the endpoint with the token. This is the most important behavioral change.
|
Exit criteria:
|
||||||
|
- `k_proxy` can validate via wireless phone path with no client-facing API changes.
|
||||||
**New Component 3 (external client):** A compiled binary (Go recommended, Rust alternative) installed on external client computers. Replaces the old browser-proxy-configuration approach. Tasks: find the phone (currently hardcoded IP+port — rendezvous TBD), forward validation requests to Component 1, receive token back, call the protected endpoint directly, return response to browser.
|
|
||||||
|
|
||||||
**Flow A splits into two paths:**
|
|
||||||
- Phone browser: Browser → Component 1 → Component 2 (returns token) → Component 1 calls endpoint → resource
|
|
||||||
- External client: Browser → Component 3 → Component 1 → Component 2 (returns token) → Component 1 → Component 3 calls endpoint → resource
|
|
||||||
|
|
||||||
**Platform note:** Android needs no extra infrastructure. iOS requires a push-relay (APNs) for background operation — platform priority is an open decision.
|
|
||||||
|
|
||||||
**New open decisions:** Rendezvous mechanism for Component 3; iOS vs Android priority.
|
|
||||||
|
|
||||||
**Architectural decision (2026-05-08) — token binding model:**
|
|
||||||
Current choice: per-request authentication. No session is opened. Each request to a gated resource requires a fresh FIDO2 assertion from the card, with the challenge bound to the specific request (URL + method + nonce). The server verifies that the assertion's challenge matches the resource being requested. A token cannot be replayed for a different resource.
|
|
||||||
Consequence: one card interaction per request. This is intentional for now.
|
|
||||||
May change to: session model (one card interaction opens a time-limited session for all gated resources). If changed, token must at minimum be bound to a specific server (audience) to prevent cross-server replay.
|
|
||||||
Trigger for revisiting: user experience — if per-request card interaction proves too slow or disruptive.
|
|
||||||
|
|
||||||
### Target architecture (v2)
|
|
||||||
|
|
||||||
Four physical devices: optional client computer, phone, chromecard, server.
|
|
||||||
|
|
||||||
**Phone components:**
|
|
||||||
- **Component 1 — Proxy + gating filter:** Receives requests from phone browser and from external clients via Component 3. Per-request: gated host → forward to Component 2, receive WebAuthn token back, call endpoint with token (TLS); non-gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
|
|
||||||
- **Component 2 — WebAuthn client + URL recognition:** Always returns token to caller, never calls endpoints itself. Detects registration URL → admin registration flow (admin fingerprint); other gated URLs → FIDO2 assertion flow (user fingerprint → token returned to Component 1).
|
|
||||||
- **Registration page:** Local web app on phone; admin fingerprint access control enforced by card.
|
|
||||||
- **Component 3 (external client):** Compiled binary, finds phone, relays auth through Component 1, calls endpoint with received token.
|
|
||||||
|
|
||||||
**Three flows:**
|
|
||||||
- **Flow A (phone browser):** Browser → Comp 1 → Comp 2 → card → token → Comp 1 → endpoint → resource
|
|
||||||
- **Flow A (external client):** Browser → Comp 3 → Comp 1 → Comp 2 → card → token → Comp 1 → Comp 3 → endpoint → resource
|
|
||||||
- **Flow B:** Browser → Comp 1 → Comp 2 (registration URL) → card (admin biometric) → enroll/delete user
|
|
||||||
- **Flow C:** Non-gated host → Comp 1 → internet port 80 (no TLS, no card)
|
|
||||||
|
|
||||||
**Open decisions:** PIN on card; user DB on-card vs. external; network-level access control on registration page; Component 3 rendezvous mechanism; iOS vs Android priority.
|
|
||||||
|
|
||||||
Development chain (Qubes): `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
|
|
||||||
|
|
||||||
The `k_phone` Flutter app replaces `k_proxy` entirely. It presents the same HTTP API as `k_proxy_app.py`
|
|
||||||
so `k_client_portal.py` and the browser portal work without changes.
|
|
||||||
|
|
||||||
**Development environment:** Mac (not Qubes). Android emulator is incompatible with Xen/Qubes. All
|
|
||||||
k_phone development and testing runs on the Mac with the Android emulator and `card_emulator_bridge.py`.
|
|
||||||
|
|
||||||
### Work completed (2026-04-29)
|
|
||||||
|
|
||||||
- Flutter project scaffolded at `k_phone/` (no `flutter create` — fully hand-written)
|
|
||||||
- 10+ Android build issues resolved (AGP, Gradle, Kotlin, desugaring, notification channel, foreground service type)
|
|
||||||
- `k_phone/lib/ctaphid_channel.dart`: full CTAPHID framing + USB/emulator dual-transport
|
|
||||||
- Fixed: persistent socket subscription (single-subscription stream cannot use `await for ... break` per packet)
|
|
||||||
- Fixed: `_emulatorSocketOpen` flag prevents dead-socket writes from raising `StateError`
|
|
||||||
- Fixed: emulator round-trip sends all request packets before reading (no per-packet blocking)
|
|
||||||
- `k_phone/lib/proxy_service.dart`: full HTTP proxy — all endpoints implemented, error handling hardened
|
|
||||||
- Fixed: card-error try-catch separated from DB StateError catch (was masking socket errors as "user already enrolled")
|
|
||||||
- `autoStart: true` for emulator testing; revert to `false` for production builds
|
|
||||||
- `k_phone/lib/enrollment_db.dart`: enrollment model + JSON persistence via path_provider
|
|
||||||
- `k_phone/lib/fido2_ops.dart`: CTAP2 `makeCredential`, `getAssertion`, ECDSA-P256 assertion verification
|
|
||||||
- Fixed: CTAP2 command prefix bytes (0x01/0x02) prepended to CBOR payload per CTAP2-over-CTAPHID spec
|
|
||||||
- `k_phone/lib/session_manager.dart`: in-memory bearer token sessions; `hasAnyActiveSession()` added for gated-proxy forwarding (personal-device model: any live session authorises gated traffic)
|
|
||||||
- `k_phone/lib/k_server_client.dart`: HTTP forwarder to k_server
|
|
||||||
- `k_phone/android/app/src/main/kotlin/.../MainActivity.kt`: USB HID Kotlin platform channel
|
|
||||||
- `tests/card_emulator_bridge.py`: asyncio CTAPHID TCP bridge wrapping `CardEmulator` for emulator dev
|
|
||||||
|
|
||||||
### Work completed (2026-05-02)
|
|
||||||
|
|
||||||
- `k_phone/lib/filter_proxy.dart`: Component 1 implemented — HTTP proxy with gating filter
|
|
||||||
- Plain HTTP to gated host: rewritten to relative path and forwarded to Component 2
|
|
||||||
- HTTPS CONNECT to gated host: CONNECT request relayed to Component 2; tunnel opened on 200, denied on 4xx
|
|
||||||
- All other traffic forwarded directly to target host
|
|
||||||
- Gated hosts file: `gated_hosts.txt` in app documents directory (one `host` or `host:port` per line)
|
|
||||||
- Default seeded with `httpbin.org` on first run
|
|
||||||
- `k_phone/test/filter_proxy_test.dart`: full test suite for Component 1 (gated matching, HTTP routing, CONNECT routing, edge cases)
|
|
||||||
- `k_phone/test/enrollment_test.dart`: full test suite for `EnrollmentDb` (register, list, delete, persistence, update)
|
|
||||||
|
|
||||||
### Work completed (2026-05-02, session 2)
|
|
||||||
|
|
||||||
- `k_phone/lib/proxy_service.dart`: `_handleConnect` added to `_ProxyServer`
|
|
||||||
- Dispatched from `_handleRequest` for `CONNECT` method
|
|
||||||
- Checks `_sessions.hasAnyActiveSession()` — returns 407 if no active session
|
|
||||||
- Extracts upstream host:port from `Host` header
|
|
||||||
- Opens TCP socket to upstream target (the real external server — httpbin.org, etc.)
|
|
||||||
- Detaches the HTTP socket (`detachSocket(writeHeaders: false)`) and writes `200 Connection Established` manually
|
|
||||||
- Pipes bytes bidirectionally: client ↔ upstream
|
|
||||||
- k_server is not involved in CONNECT tunnels; Component 2 connects directly to the real target
|
|
||||||
|
|
||||||
### Verified on emulator (2026-04-29)
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /enroll/register → makeCredential via bridge → has_credential: true ✓
|
|
||||||
POST /session/login → getAssertion + ECDSA verify → auth_mode: fido2_assertion ✓
|
|
||||||
POST /session/status → 299 s remaining ✓
|
|
||||||
POST /session/logout → invalidated: true ✓
|
|
||||||
POST /resource/counter → internal error (k_server not running locally — expected)
|
|
||||||
POST /resource/counter (after logout) → 401 invalid or expired session ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
Bridge log confirmed:
|
|
||||||
```
|
|
||||||
CTAP2 cmd=0x01 body=180 bytes → makeCredential OK auth_data=164 bytes
|
|
||||||
CTAP2 cmd=0x02 body=113 bytes → getAssertion OK auth_data=37 bytes sig=71 bytes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Work completed (2026-05-05, v2 architecture refactor)
|
|
||||||
|
|
||||||
**k_phone (Dart):**
|
|
||||||
- `filter_proxy_test.dart`: rewritten for v2 semantics — gated HTTP now hits a mock endpoint with Bearer token, not Component 2 directly. 24/24 tests pass.
|
|
||||||
- `filter_proxy.dart`: extracted `_writeProxyHeaders` and `_forwardHttpRequest` helpers to eliminate ~30 lines of duplication between `_handleGatedHttp` and `_handleDirectHttp`; simplified `_handleDirectHttp` signature (redundant `host`/`port` params removed).
|
|
||||||
- `session_manager.dart`: added `static const int ttlSeconds = 300` (public); `_ttl` now references it.
|
|
||||||
- `portal_html.dart` (new): extracted 400-line HTML blobs (`kPortalHtml`, `kEnrollHtml`, `kPortalHtmlBytes`, `kEnrollHtmlBytes`) from `proxy_service.dart`.
|
|
||||||
- `proxy_service.dart`: imports `portal_html.dart`; removed `_kSessionTtlSeconds` constant (replaced with `SessionManager.ttlSeconds`); merged `_serveHtml`/`_serveEnrollHtml` into `_serveHtmlBytes(req, bytes)`; extracted `_parseUsername` and `_parseUsernameAndDisplay` helpers eliminating repeated validation boilerplate; removed dead `_loadTlsContext` stub; simplified `start()` TLS branch. File: 872 → 455 lines.
|
|
||||||
- `k_server_client.dart`: deleted (dead code — no longer imported anywhere).
|
|
||||||
|
|
||||||
**component3 (Go):**
|
|
||||||
- `gated.go`: `IsGated(host, port string)` — was `IsGated(host string)`. Was silently missing `host:port` entries in gated_hosts.txt. Now checks both bare hostname and `host:port`.
|
|
||||||
- `proxy.go`: `handleHTTP` extracts `port` from URL (defaults `"80"`), passes to `IsGated`; `handleConnect` passes `portStr` to `IsGated`.
|
|
||||||
- `phone.go`: added `getToken()` calling `/auth/get-token` — avoids FIDO2 card interaction if the phone already has an active session. `EnsureSession()` tries `getToken()` first, falls back to `login()`. Fixed `login()` JSON field: `expires_in` → `ttl_seconds` (actual server field name). `go build ./...` passes.
|
|
||||||
|
|
||||||
### Parallel-change note: Component 1 and Component 3 share the same proxy logic
|
|
||||||
|
|
||||||
Component 3 (`component3/`) and Component 1 (`k_phone/lib/filter_proxy.dart`) implement the same core behaviour: intercept HTTP/HTTPS traffic, decide per-request whether the target is gated, fetch a WebAuthn token if so, and call the endpoint directly with the token. Any structural change to one (new gating logic, token-binding changes, CONNECT handling, error semantics) will almost certainly need a corresponding change in the other. Treat them as a pair: when modifying Component 3, check Component 1 for the same fix, and vice versa.
|
|
||||||
|
|
||||||
### Work completed (2026-05-08, per-request token binding)
|
|
||||||
|
|
||||||
- `fido2_ops.dart`: `GetAssertionResult` now includes `clientDataJson`; `getAssertion()` accepts optional `challenge` param for binding.
|
|
||||||
- `proxy_service.dart`: `_handleAuthGetToken` rewritten — accepts `{url, method, nonce}`, derives `challenge = SHA256(url|method|nonce)`, calls card (getAssertion), returns self-contained assertion bundle as base64url Bearer token. No session involved.
|
|
||||||
- `filter_proxy.dart`: `_getAuthToken(uri, method)` generates a secure 16-byte nonce, posts `{url, method, nonce}` to Component 2, uses returned assertion token directly.
|
|
||||||
- `component3/phone.go`: rewritten as stateless `GetTokenForRequest(url, method)` — no session caching, no mutex, no expiry tracking.
|
|
||||||
- `component3/proxy.go`: `handleHTTP` uses `GetTokenForRequest(r.URL.String(), r.Method)`.
|
|
||||||
- `component3/main.go`: `--user` flag removed (Component 2 picks the enrolled user).
|
|
||||||
- `k_server_app.py`: `_verify_assertion_token()` added — decodes bundle, verifies path+method match, verifies challenge claim, verifies ECDSA-P256 signature over authData||clientDataHash using public key extracted from bundle's credentialData. `_is_proxy_authorized()` accepts either X-Proxy-Token (legacy k_proxy path) or Bearer assertion token.
|
|
||||||
- `filter_proxy_test.dart`: 2 new tests for `/auth/get-token` body fields (url, method, nonce). 48/48 tests pass.
|
|
||||||
- `tests/test_k_server.py`: 17 Python tests for `_verify_assertion_token` — 12 unit tests with synthetic P-256 keys, 5 round-trip tests via `CardEmulator`. All pass.
|
|
||||||
- 48/48 Flutter tests pass; `go build ./...` clean; `flutter analyze` no issues.
|
|
||||||
|
|
||||||
### Work completed (2026-05-08, Playwright acceptance tests for k_phone)
|
|
||||||
|
|
||||||
- `tests/k_phone_portal.spec.js` (new): Portal UI acceptance tests (enroll → login → status → list → logout → delete). DOM assertions against `#storedUser`, `#sessionActive`, `#log`. Also tests empty-username and unknown-user error paths.
|
|
||||||
- Run: `K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_portal.spec.js`
|
|
||||||
|
|
||||||
- `tests/k_phone_proxy.spec.js` (new): Proxy routing acceptance tests. Four serial tests that prove Component 1's routing decisions:
|
|
||||||
1. No users → non-gated request passes through (< 500).
|
|
||||||
2. No users → gated request rejected with 407 (Component 2 has no enrolled user).
|
|
||||||
3. Register user (card fingerprint) → non-gated still passes through.
|
|
||||||
4. With enrolled user → gated request succeeds after card assertion (200); response body proves Bearer token was forwarded to target.
|
|
||||||
- Uses Node `http` module for proxy requests (absolute URI / proxy protocol).
|
|
||||||
- Uses Playwright `page` fixture for enrollment in test 3 (card interaction).
|
|
||||||
- `GATED_URL` defaults to `http://httpbin.org/get`; point at `http://k-server-ip:8780/resource/counter` (GATED_METHOD=POST) for full chain validation including token signature verification.
|
|
||||||
- Run: `K_PHONE_PROXY=http://phone-ip:8888 K_PHONE_BASE_URL=http://phone-ip:8771 npx playwright test tests/k_phone_proxy.spec.js`
|
|
||||||
|
|
||||||
### Work completed (2026-05-09, Android Playwright tests passing)
|
|
||||||
|
|
||||||
- `tests/k_phone_android.spec.js`: all 4 tests pass (16 s total). Two root causes fixed:
|
|
||||||
|
|
||||||
1. **`launchBrowser()` hangs on Chrome 145.** Replaced with: write proxy flag to `/data/local/tmp/chrome-command-line`, force-stop + restart Chrome, `adb forward tcp:9222 localabstract:chrome_devtools_remote`, `chromium.connectOverCDP()`. CDP polling loop handles startup variance (≤ 15 s).
|
|
||||||
|
|
||||||
2. **Stale emulator socket after bridge restart.** `proxy_service.dart`: added `_ensureCardOpen()` — checks `isCardAttached()` and re-runs `_tryOpenCard()` if the socket is closed. Called before `makeCredential` and `getAssertion` in all three handler paths (enroll, session login, `/auth/get-token`).
|
|
||||||
|
|
||||||
- `playwright.config.js`: global timeout reduced from 180 s → 60 s.
|
|
||||||
- `adb` auto-detected at `~/Library/Android/sdk/platform-tools/adb` without PATH changes.
|
|
||||||
- `card_emulator_bridge.py` is long-running; no restart needed between test runs.
|
|
||||||
|
|
||||||
### Next action
|
|
||||||
|
|
||||||
1. Deploy to a real Android phone with physical ChromeCard via USB
|
|
||||||
2. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
|
|
||||||
3. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
|
|
||||||
|
|
||||||
### k_phone API contract (must match k_proxy_app.py exactly)
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
- `POST /enroll/register` `{"username","display_name"}`
|
|
||||||
- `GET /enroll/status?username=`
|
|
||||||
- `POST /enroll/update` `{"username","display_name"}`
|
|
||||||
- `POST /enroll/delete` `{"username"}`
|
|
||||||
- `GET /enroll/list`
|
|
||||||
- `POST /session/login` `{"username"}`
|
|
||||||
- `POST /session/status`
|
|
||||||
- `POST /session/logout`
|
|
||||||
- `POST /resource/counter` (forwarded to k_server with X-Proxy-Token)
|
|
||||||
|
|
||||||
### Key design decisions
|
|
||||||
|
|
||||||
- rp_id: `"localhost"`, origin: `"https://localhost"` (matches k_proxy_app.py defaults)
|
|
||||||
- clientDataHash = SHA256(clientDataJSON), where clientDataJSON = `{"type":"webauthn.create","challenge":"<b64>","origin":"https://localhost","crossOrigin":false}`
|
|
||||||
- credential_data_b64 stores `AttestedCredentialData` bytes = `aaguid(16) + credIdLen(2) + credId(n) + coseKey`
|
|
||||||
- Signature verification: ECDSA-SHA256(authData || clientDataHash, P-256 pubKey extracted from COSE key)
|
|
||||||
- No begin/complete HTTP round-trip — registration and auth are each a single HTTP call (same as Python)
|
|
||||||
- Sessions: server-side in-memory, TTL 300 s (matching Python default), token = 32-byte hex
|
|
||||||
|
|
||||||
### start bridge for emulator testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography tests/card_emulator_bridge.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 9 exit criteria
|
|
||||||
|
|
||||||
- `k_phone` presents identical HTTP API to `k_proxy_app.py` (so k_client works unchanged)
|
|
||||||
- Registration and login both complete via `card_emulator_bridge.py` in emulator testing
|
|
||||||
- With physical ChromeCard plugged into Android phone: full register → login → counter → logout works
|
|
||||||
- `phase5_chain_regression.sh` passes against `k_phone` on Android
|
|
||||||
|
|
||||||
## Current Next Step
|
## Current Next Step
|
||||||
|
|
||||||
Status (2026-04-29):
|
- Treat the default HTTPS split-VM chain as the stable baseline and keep validating it with `/home/user/chromecard/phase5_chain_regression.sh`.
|
||||||
- Phase 9 emulator milestone complete: makeCredential + getAssertion verified via CardEmulator bridge.
|
- Push the next engineering cycle toward Phase 6.5 limits:
|
||||||
- Next blocking step: deploy to real Android phone with ChromeCard over USB.
|
- reproduce and narrow the `~10` in-flight request ceiling on the browser-facing `k_client -> k_proxy` Qubes forward
|
||||||
- k_server is not running in the Mac test environment; counter endpoint will work once running in Qubes.
|
- separate Qubes forwarding churn from app-level issues with targeted concurrency probes and log capture
|
||||||
|
- In parallel, decide whether `--auth-mode fido2-direct` is ready to become the default deployed path or should remain an optional/operator mode.
|
||||||
Phase status (2026-04-29):
|
- Keep the regression helpers as the fast check that transport, auth, session reuse, and counter semantics still hold after each change.
|
||||||
- Phase 6.5 (concurrency): deferred. ~10 in-flight ceiling is acceptable.
|
|
||||||
- Phase 7 (firmware build/flash): blocked on Chrome Roads (card vendor).
|
|
||||||
- Phase 9 (phone integration): **emulator FIDO2 verified; physical phone + USB HID path is next.**
|
|
||||||
|
|
||||||
Status (2026-04-26, markdown maintenance):
|
Status (2026-04-26, markdown maintenance):
|
||||||
- Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files.
|
- Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files.
|
||||||
|
- Updated the plan to match the verified state:
|
||||||
|
- direct FIDO2 auth is no longer the primary blocker because register/login/logout already work in the experimental path
|
||||||
|
- the main open system limit is concurrency/fan-out on the Qubes-forwarded browser path
|
||||||
|
- the current planning split is now:
|
||||||
|
- baseline path: keep `probe` mode stable and reproducible
|
||||||
|
- follow-up path: decide whether to promote `fido2-direct`
|
||||||
|
|
||||||
## Inputs Expected During This Session
|
## Inputs Expected During This Session
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
k_client_portal — browser-facing portal running in k_client.
|
Minimal browser-facing client portal for Phase 6 bring-up.
|
||||||
|
|
||||||
Serves the single-page UI and thin API shim that delegates every auth and
|
This runs in k_client, keeps a local preferred username, and talks to k_proxy
|
||||||
resource operation to k_proxy over the localhost-forwarded TLS endpoint.
|
over the localhost-forwarded TLS endpoint.
|
||||||
Persists one preferred username locally; all session and enrollment state
|
|
||||||
lives in k_proxy.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -563,25 +561,18 @@ class ClientState:
|
||||||
self.proxy_base_url = proxy_base_url.rstrip("/")
|
self.proxy_base_url = proxy_base_url.rstrip("/")
|
||||||
self.proxy_ca_file = proxy_ca_file
|
self.proxy_ca_file = proxy_ca_file
|
||||||
self.enroll_db = enroll_db
|
self.enroll_db = enroll_db
|
||||||
# Registration and login both require a physical card touch, which can
|
|
||||||
# take up to ~60 s in practice; 90 s gives a generous margin.
|
|
||||||
self.interactive_timeout_s = interactive_timeout_s
|
self.interactive_timeout_s = interactive_timeout_s
|
||||||
self.default_timeout_s = default_timeout_s
|
self.default_timeout_s = default_timeout_s
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.preferred_enrollment: EnrollmentRecord | None = None
|
self.preferred_enrollment: EnrollmentRecord | None = None
|
||||||
self.session_token: str | None = None
|
self.session_token: str | None = None
|
||||||
self.session_expires_at: int | None = None
|
self.session_expires_at: int | None = None
|
||||||
# Build the TLS context once; creating it on every request is expensive
|
|
||||||
# and the CA file doesn't change at runtime.
|
|
||||||
self._ssl_ctx: ssl.SSLContext | None = (
|
|
||||||
ssl.create_default_context(cafile=self.proxy_ca_file)
|
|
||||||
if proxy_base_url.startswith("https://")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self._load_preferred_enrollment()
|
self._load_preferred_enrollment()
|
||||||
|
|
||||||
def _ssl_context(self) -> ssl.SSLContext | None:
|
def _ssl_context(self):
|
||||||
return self._ssl_ctx
|
if self.proxy_base_url.startswith("https://"):
|
||||||
|
return ssl.create_default_context(cafile=self.proxy_ca_file)
|
||||||
|
return None
|
||||||
|
|
||||||
def _proxy_json(
|
def _proxy_json(
|
||||||
self,
|
self,
|
||||||
|
|
@ -635,12 +626,6 @@ class ClientState:
|
||||||
username = username.strip()
|
username = username.strip()
|
||||||
if not username:
|
if not username:
|
||||||
return {"ok": False, "error": "username required"}
|
return {"ok": False, "error": "username required"}
|
||||||
# Best-effort: invalidate any active session on k_proxy before re-enrolling.
|
|
||||||
# The new credential will differ from what the old session was issued for.
|
|
||||||
with self.lock:
|
|
||||||
old_token = self.session_token
|
|
||||||
if old_token:
|
|
||||||
self._proxy_json("POST", "/session/logout")
|
|
||||||
status, data = self._proxy_json(
|
status, data = self._proxy_json(
|
||||||
"POST",
|
"POST",
|
||||||
"/enroll/register",
|
"/enroll/register",
|
||||||
|
|
@ -756,15 +741,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return {}
|
return {}
|
||||||
return json.loads(raw.decode("utf-8"))
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
def _require_json(self) -> dict[str, Any] | None:
|
|
||||||
# Returns None and sends 400 when the body is unparseable; the caller
|
|
||||||
# should return immediately without sending a second response.
|
|
||||||
try:
|
|
||||||
return self._read_json()
|
|
||||||
except Exception:
|
|
||||||
self._json(400, {"ok": False, "error": "invalid json"})
|
|
||||||
return None
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
def do_GET(self) -> None: # noqa: N802
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
if path == "/":
|
if path == "/":
|
||||||
|
|
@ -785,22 +761,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
if path == "/api/enroll":
|
if path == "/api/enroll":
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
result = self.state.enroll(str(data.get("username", "")))
|
result = self.state.enroll(str(data.get("username", "")))
|
||||||
self._json(200 if result.get("ok") else 400, result)
|
self._json(200 if result.get("ok") else 400, result)
|
||||||
return
|
return
|
||||||
if path == "/api/login":
|
if path == "/api/login":
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
status, data = self.state.login(str(data.get("username", "")))
|
status, data = self.state.login(str(data.get("username", "")))
|
||||||
self._json(status, data)
|
self._json(status, data)
|
||||||
return
|
return
|
||||||
if path == "/api/enroll/delete":
|
if path == "/api/enroll/delete":
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
status, data = self.state.delete_enrollment(str(data.get("username", "")))
|
status, data = self.state.delete_enrollment(str(data.get("username", "")))
|
||||||
self._json(status, data)
|
self._json(status, data)
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
plugins {
|
|
||||||
id "com.android.application"
|
|
||||||
id "kotlin-android"
|
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file("local.properties")
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.withReader("UTF-8") { reader ->
|
|
||||||
localProperties.load(reader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "com.chromecard.kphone"
|
|
||||||
compileSdk = 36
|
|
||||||
ndkVersion = flutter.ndkVersion
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
coreLibraryDesugaringEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += "src/main/kotlin"
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.chromecard.kphone"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = flutter.versionCode
|
|
||||||
versionName = flutter.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig = signingConfigs.debug
|
|
||||||
minifyEnabled false
|
|
||||||
shrinkResources false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source = "../.."
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.4"
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
|
|
||||||
<!-- USB host mode: required to open UsbDevice handles -->
|
|
||||||
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
|
|
||||||
|
|
||||||
<!-- Foreground service for running proxy while screen is off -->
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<!-- connectedDevice for real hardware; dataSync for emulator dev -->
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
||||||
|
|
||||||
<!-- Network: TLS server on :8771 + outbound to k_server -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
|
|
||||||
<!-- Required for foreground service notification on Android 13+ -->
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:label="k_phone"
|
|
||||||
android:allowBackup="false"
|
|
||||||
android:usesCleartextTraffic="false"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
|
||||||
|
|
||||||
<!-- Flutter v2 embedding -->
|
|
||||||
<meta-data
|
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:theme="@style/LaunchTheme"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
|
||||||
android:resource="@style/NormalTheme" />
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<!-- USB device attach intent: auto-launch when ChromeCard plugged in -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
|
||||||
android:resource="@xml/usb_device_filter" />
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- flutter_background_service foreground service type override -->
|
|
||||||
<!-- dataSync used for emulator dev; swap to connectedDevice on real hardware -->
|
|
||||||
<service
|
|
||||||
android:name="id.flutter.flutter_background_service.BackgroundService"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
tools:replace="android:foregroundServiceType" />
|
|
||||||
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
package io.flutter.plugins;
|
|
||||||
|
|
||||||
import androidx.annotation.Keep;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import io.flutter.Log;
|
|
||||||
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generated file. Do not edit.
|
|
||||||
* This file is generated by the Flutter tool based on the
|
|
||||||
* plugins that support the Android platform.
|
|
||||||
*/
|
|
||||||
@Keep
|
|
||||||
public final class GeneratedPluginRegistrant {
|
|
||||||
private static final String TAG = "GeneratedPluginRegistrant";
|
|
||||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new id.flutter.flutter_background_service.FlutterBackgroundServicePlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_background_service_android, id.flutter.flutter_background_service.FlutterBackgroundServicePlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
package com.chromecard.kphone
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.hardware.usb.UsbConstants
|
|
||||||
import android.hardware.usb.UsbDevice
|
|
||||||
import android.hardware.usb.UsbDeviceConnection
|
|
||||||
import android.hardware.usb.UsbEndpoint
|
|
||||||
import android.hardware.usb.UsbInterface
|
|
||||||
import android.hardware.usb.UsbManager
|
|
||||||
import android.os.Build
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
|
|
||||||
// ChromeCard USB identifiers (must match udev rule and ctaphid_channel.dart)
|
|
||||||
private const val VENDOR_ID = 0x1209
|
|
||||||
private const val PRODUCT_ID = 0x0005
|
|
||||||
|
|
||||||
private const val CHANNEL = "com.chromecard.kphone/usb_hid"
|
|
||||||
private const val ACTION_USB_PERMISSION = "com.chromecard.kphone.USB_PERMISSION"
|
|
||||||
private const val HID_PACKET_SIZE = 64
|
|
||||||
private const val TRANSFER_TIMEOUT_MS = 3000
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
|
||||||
|
|
||||||
private val usbManager: UsbManager by lazy {
|
|
||||||
getSystemService(Context.USB_SERVICE) as UsbManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private var usbDevice: UsbDevice? = null
|
|
||||||
private var usbConnection: UsbDeviceConnection? = null
|
|
||||||
private var usbInterface: UsbInterface? = null
|
|
||||||
private var endpointIn: UsbEndpoint? = null
|
|
||||||
private var endpointOut: UsbEndpoint? = null
|
|
||||||
|
|
||||||
// Pending permission result callback
|
|
||||||
private var permissionCallback: ((Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
private val permissionReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == ACTION_USB_PERMISSION) {
|
|
||||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
|
||||||
permissionCallback?.invoke(granted)
|
|
||||||
permissionCallback = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: android.os.Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
"kphone_proxy",
|
|
||||||
"k_phone proxy service",
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Shows when the ChromeCard proxy is running"
|
|
||||||
}
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
|
||||||
|
|
||||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
registerReceiver(permissionReceiver, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
|
||||||
.setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"openCard" -> handleOpenCard(result)
|
|
||||||
"closeCard" -> { closeCard(); result.success(null) }
|
|
||||||
"isCardAttached" -> result.success(usbConnection != null)
|
|
||||||
"sendCtaphid" -> {
|
|
||||||
val packet = call.arguments as? ByteArray
|
|
||||||
if (packet == null || packet.size != HID_PACKET_SIZE) {
|
|
||||||
result.error("INVALID_PACKET", "Expected $HID_PACKET_SIZE bytes", null)
|
|
||||||
} else {
|
|
||||||
handleSendCtaphid(packet, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
closeCard()
|
|
||||||
try { unregisterReceiver(permissionReceiver) } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// openCard: find ChromeCard, request permission, claim HID interface
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun handleOpenCard(result: MethodChannel.Result) {
|
|
||||||
// Already open?
|
|
||||||
if (usbConnection != null) { result.success(true); return }
|
|
||||||
|
|
||||||
val device = findChromeCard()
|
|
||||||
if (device == null) {
|
|
||||||
result.success(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usbManager.hasPermission(device)) {
|
|
||||||
result.success(claimDevice(device))
|
|
||||||
} else {
|
|
||||||
requestPermission(device) { granted ->
|
|
||||||
runOnUiThread {
|
|
||||||
result.success(if (granted) claimDevice(device) else false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findChromeCard(): UsbDevice? {
|
|
||||||
return usbManager.deviceList.values.firstOrNull { dev ->
|
|
||||||
dev.vendorId == VENDOR_ID && dev.productId == PRODUCT_ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestPermission(device: UsbDevice, callback: (Boolean) -> Unit) {
|
|
||||||
permissionCallback = callback
|
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
||||||
PendingIntent.FLAG_MUTABLE else 0
|
|
||||||
val permIntent = PendingIntent.getBroadcast(this, 0,
|
|
||||||
Intent(ACTION_USB_PERMISSION), flags)
|
|
||||||
usbManager.requestPermission(device, permIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun claimDevice(device: UsbDevice): Boolean {
|
|
||||||
// Find the HID interface (class 3)
|
|
||||||
val hidIface = (0 until device.interfaceCount)
|
|
||||||
.map { device.getInterface(it) }
|
|
||||||
.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_HID }
|
|
||||||
?: return false
|
|
||||||
|
|
||||||
// Find IN and OUT interrupt endpoints
|
|
||||||
var inEp: UsbEndpoint? = null
|
|
||||||
var outEp: UsbEndpoint? = null
|
|
||||||
for (i in 0 until hidIface.endpointCount) {
|
|
||||||
val ep = hidIface.getEndpoint(i)
|
|
||||||
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT) {
|
|
||||||
if (ep.direction == UsbConstants.USB_DIR_IN) inEp = ep
|
|
||||||
if (ep.direction == UsbConstants.USB_DIR_OUT) outEp = ep
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inEp == null || outEp == null) return false
|
|
||||||
|
|
||||||
val conn = usbManager.openDevice(device) ?: return false
|
|
||||||
if (!conn.claimInterface(hidIface, true)) {
|
|
||||||
conn.close(); return false
|
|
||||||
}
|
|
||||||
|
|
||||||
usbDevice = device
|
|
||||||
usbConnection = conn
|
|
||||||
usbInterface = hidIface
|
|
||||||
endpointIn = inEp
|
|
||||||
endpointOut = outEp
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// closeCard: release interface and close connection
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun closeCard() {
|
|
||||||
usbInterface?.let { usbConnection?.releaseInterface(it) }
|
|
||||||
usbConnection?.close()
|
|
||||||
usbDevice = null
|
|
||||||
usbConnection = null
|
|
||||||
usbInterface = null
|
|
||||||
endpointIn = null
|
|
||||||
endpointOut = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// sendCtaphid: write one HID packet, read one HID packet
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun handleSendCtaphid(packet: ByteArray, result: MethodChannel.Result) {
|
|
||||||
val conn = usbConnection
|
|
||||||
val outEp = endpointOut
|
|
||||||
val inEp = endpointIn
|
|
||||||
|
|
||||||
if (conn == null || outEp == null || inEp == null) {
|
|
||||||
result.error("NOT_OPEN", "Card not open", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send
|
|
||||||
val sent = conn.bulkTransfer(outEp, packet, packet.size, TRANSFER_TIMEOUT_MS)
|
|
||||||
if (sent < 0) {
|
|
||||||
result.error("SEND_FAILED", "bulkTransfer OUT returned $sent", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive
|
|
||||||
val buf = ByteArray(HID_PACKET_SIZE)
|
|
||||||
val received = conn.bulkTransfer(inEp, buf, buf.size, TRANSFER_TIMEOUT_MS)
|
|
||||||
if (received < 0) {
|
|
||||||
result.error("RECV_FAILED", "bulkTransfer IN returned $received", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success(buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#FFFFFF">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5L12,1zM12,11.99h7c-0.53,4.12 -3.28,7.79 -7,8.94V12H5V6.3l7,-3.11v8.8z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">@android:color/white</item>
|
|
||||||
</style>
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<network-security-config>
|
|
||||||
<!-- Allow cleartext to the Mac host (emulator bridge) in debug builds -->
|
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
|
||||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- USB device filter: matches ChromeCard (VID=0x1209, PID=0x0005) -->
|
|
||||||
<resources>
|
|
||||||
<usb-device vendor-id="4617" product-id="5" />
|
|
||||||
<!-- vendor-id and product-id are decimal: 0x1209=4617, 0x0005=5 -->
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.buildDir = "../build"
|
|
||||||
subprojects {
|
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
|
||||||
}
|
|
||||||
subprojects {
|
|
||||||
project.evaluationDependsOn(":app")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
|
|
||||||
android.useAndroidX=true
|
|
||||||
android.enableJetifier=true
|
|
||||||
Binary file not shown.
|
|
@ -1,5 +0,0 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
##
|
|
||||||
## Gradle start up script for UN*X
|
|
||||||
##
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS=""
|
|
||||||
|
|
||||||
APP_NAME="Gradle"
|
|
||||||
APP_BASE_NAME=`basename "$0"`
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD="maximum"
|
|
||||||
|
|
||||||
warn ( ) {
|
|
||||||
echo "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
die ( ) {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
case "`uname`" in
|
|
||||||
CYGWIN* )
|
|
||||||
cygwin=true
|
|
||||||
;;
|
|
||||||
Darwin* )
|
|
||||||
darwin=true
|
|
||||||
;;
|
|
||||||
MINGW* )
|
|
||||||
msys=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
PRG="$0"
|
|
||||||
# Need this for relative symlinks.
|
|
||||||
while [ -h "$PRG" ] ; do
|
|
||||||
ls=`ls -ld "$PRG"`
|
|
||||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
|
||||||
if expr "$link" : '/.*' > /dev/null; then
|
|
||||||
PRG="$link"
|
|
||||||
else
|
|
||||||
PRG=`dirname "$PRG"`"/$link"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
SAVED="`pwd`"
|
|
||||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
|
||||||
APP_HOME="`pwd -P`"
|
|
||||||
cd "$SAVED" >/dev/null
|
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
|
||||||
else
|
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD="java"
|
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
|
||||||
MAX_FD_LIMIT=`ulimit -H -n`
|
|
||||||
if [ $? -eq 0 ] ; then
|
|
||||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
|
||||||
MAX_FD="$MAX_FD_LIMIT"
|
|
||||||
fi
|
|
||||||
ulimit -n $MAX_FD
|
|
||||||
if [ $? -ne 0 ] ; then
|
|
||||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Darwin, add options to specify how the application appears in the dock
|
|
||||||
if $darwin; then
|
|
||||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Cygwin, switch paths to Windows format before running java
|
|
||||||
if $cygwin ; then
|
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
|
||||||
|
|
||||||
# We build the pattern for arguments to be converted via cygpath
|
|
||||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
|
||||||
SEP=""
|
|
||||||
for dir in $ROOTDIRSRAW ; do
|
|
||||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
|
||||||
SEP="|"
|
|
||||||
done
|
|
||||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
|
||||||
# Add a user-defined pattern to the cygpath arguments
|
|
||||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
|
||||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
|
||||||
fi
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
i=0
|
|
||||||
for arg in "$@" ; do
|
|
||||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
|
||||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
|
||||||
|
|
||||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
|
||||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
|
||||||
else
|
|
||||||
eval `echo args$i`="\"$arg\""
|
|
||||||
fi
|
|
||||||
i=$((i+1))
|
|
||||||
done
|
|
||||||
case $i in
|
|
||||||
(0) set -- ;;
|
|
||||||
(1) set -- "$args0" ;;
|
|
||||||
(2) set -- "$args0" "$args1" ;;
|
|
||||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
|
||||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
|
||||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
|
||||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
|
||||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
|
||||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
|
||||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
|
||||||
function splitJvmOpts() {
|
|
||||||
JVM_OPTS=("$@")
|
|
||||||
}
|
|
||||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
|
||||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
|
||||||
|
|
||||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
@if "%DEBUG%" == "" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS=
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%" == "" set DIRNAME=.
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if "%ERRORLEVEL%" == "0" goto init
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
echo location of your Java installation.
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto init
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
echo location of your Java installation.
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:init
|
|
||||||
@rem Get command-line arguments, handling Windowz variants
|
|
||||||
|
|
||||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
|
||||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
|
||||||
|
|
||||||
:win9xME_args
|
|
||||||
@rem Slurp the command line arguments.
|
|
||||||
set CMD_LINE_ARGS=
|
|
||||||
set _SKIP=2
|
|
||||||
|
|
||||||
:win9xME_args_slurp
|
|
||||||
if "x%~1" == "x" goto execute
|
|
||||||
|
|
||||||
set CMD_LINE_ARGS=%*
|
|
||||||
goto execute
|
|
||||||
|
|
||||||
:4NT_args
|
|
||||||
@rem Get arguments from the 4NT Shell from JP Software
|
|
||||||
set CMD_LINE_ARGS=%$
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
|
||||||
exit /b 1
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
pluginManagement {
|
|
||||||
def flutterSdkPath = {
|
|
||||||
def properties = new Properties()
|
|
||||||
file("local.properties").withInputStream { properties.load(it) }
|
|
||||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
|
||||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
|
||||||
return flutterSdkPath
|
|
||||||
}()
|
|
||||||
|
|
||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
|
||||||
id "com.android.application" version "8.7.3" apply false
|
|
||||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
include ":app"
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
// Integration test: registration and login flows on device.
|
|
||||||
// Runs EnrollmentDb and SessionManager directly; no card required.
|
|
||||||
//
|
|
||||||
// Run with: flutter test integration_test/ -d emulator-5554
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:integration_test/integration_test.dart';
|
|
||||||
|
|
||||||
import 'package:k_phone/enrollment_db.dart';
|
|
||||||
import 'package:k_phone/session_manager.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
group('Registration flow', () {
|
|
||||||
late EnrollmentDb db;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
db = EnrollmentDb();
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('register new user produces log entries', (tester) async {
|
|
||||||
debugPrint('[REGISTRATION] Starting: enrolling user "alice"');
|
|
||||||
|
|
||||||
final enrollment = await db.register(
|
|
||||||
username: 'alice',
|
|
||||||
displayName: 'Alice Testuser',
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('[REGISTRATION] SUCCESS: user="${enrollment.username}" '
|
|
||||||
'displayName="${enrollment.displayName}" '
|
|
||||||
'createdAt=${enrollment.createdAt} '
|
|
||||||
'hasCredential=${enrollment.hasCredential}');
|
|
||||||
|
|
||||||
expect(enrollment.username, equals('alice'));
|
|
||||||
expect(enrollment.displayName, equals('Alice Testuser'));
|
|
||||||
expect(enrollment.hasCredential, isFalse);
|
|
||||||
|
|
||||||
debugPrint('[REGISTRATION] Verifying user appears in list...');
|
|
||||||
final list = await db.list();
|
|
||||||
expect(list.any((e) => e.username == 'alice'), isTrue);
|
|
||||||
debugPrint('[REGISTRATION] OK: "alice" found in enrollment list (${list.length} total)');
|
|
||||||
|
|
||||||
debugPrint('[REGISTRATION] Verifying duplicate registration is rejected...');
|
|
||||||
try {
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
fail('Expected StateError for duplicate username');
|
|
||||||
} on StateError catch (e) {
|
|
||||||
debugPrint('[REGISTRATION] OK: duplicate rejected — ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('[REGISTRATION] COMPLETE');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('delete user removes from enrollment list', (tester) async {
|
|
||||||
await db.register(username: 'bob', displayName: 'Bob Testuser');
|
|
||||||
debugPrint('[REGISTRATION] Enrolled "bob"');
|
|
||||||
|
|
||||||
final deleted = await db.delete('bob');
|
|
||||||
debugPrint('[REGISTRATION] DELETE: removed user="${deleted.username}"');
|
|
||||||
|
|
||||||
final found = await db.get('bob');
|
|
||||||
expect(found, isNull);
|
|
||||||
debugPrint('[REGISTRATION] OK: "bob" no longer in database');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('Login flow', () {
|
|
||||||
final session = SessionManager();
|
|
||||||
|
|
||||||
testWidgets('issue and validate session token', (tester) async {
|
|
||||||
debugPrint('[LOGIN] Starting: issuing session for "alice"');
|
|
||||||
|
|
||||||
final token = session.issue('alice');
|
|
||||||
debugPrint('[LOGIN] Token issued: ${token.substring(0, 8)}... (${token.length} chars)');
|
|
||||||
|
|
||||||
final valid = session.isValid(token);
|
|
||||||
debugPrint('[LOGIN] Session valid: $valid');
|
|
||||||
expect(valid, isTrue);
|
|
||||||
|
|
||||||
final entry = session.getSession(token);
|
|
||||||
debugPrint('[LOGIN] Session entry: username="${entry?.username}" '
|
|
||||||
'expires=${entry?.expires.toIso8601String()}');
|
|
||||||
|
|
||||||
debugPrint('[LOGIN] COMPLETE');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('revoke session invalidates token', (tester) async {
|
|
||||||
final token = session.issue('alice');
|
|
||||||
debugPrint('[LOGIN] Token issued: ${token.substring(0, 8)}...');
|
|
||||||
|
|
||||||
session.revoke(token);
|
|
||||||
debugPrint('[LOGIN] Token revoked');
|
|
||||||
|
|
||||||
final valid = session.isValid(token);
|
|
||||||
debugPrint('[LOGIN] Session valid after revoke: $valid');
|
|
||||||
expect(valid, isFalse);
|
|
||||||
debugPrint('[LOGIN] OK: revoked token correctly rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('revokeAll removes all sessions for user', (tester) async {
|
|
||||||
final t1 = session.issue('charlie');
|
|
||||||
final t2 = session.issue('charlie');
|
|
||||||
debugPrint('[LOGIN] Issued 2 sessions for "charlie"');
|
|
||||||
|
|
||||||
session.revokeAll('charlie');
|
|
||||||
debugPrint('[LOGIN] revokeAll("charlie") called');
|
|
||||||
|
|
||||||
expect(session.isValid(t1), isFalse);
|
|
||||||
expect(session.isValid(t2), isFalse);
|
|
||||||
debugPrint('[LOGIN] OK: both sessions for "charlie" invalidated');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('unknown token is rejected', (tester) async {
|
|
||||||
debugPrint('[LOGIN] Testing unknown token...');
|
|
||||||
final valid = session.isValid('0000000000000000000000000000000000000000000000000000000000000000');
|
|
||||||
debugPrint('[LOGIN] Unknown token valid: $valid');
|
|
||||||
expect(valid, isFalse);
|
|
||||||
debugPrint('[LOGIN] OK: unknown token correctly rejected');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,362 +0,0 @@
|
||||||
// Dart side of the USB HID platform channel + TCP emulator transport.
|
|
||||||
//
|
|
||||||
// Two transport modes:
|
|
||||||
// USB mode (default): calls into Kotlin MainActivity via MethodChannel.
|
|
||||||
// Emulator mode: TCP socket to card_emulator_bridge.py on port 8772.
|
|
||||||
//
|
|
||||||
// Call useEmulator() before openCard() to switch to emulator mode.
|
|
||||||
// All CTAPHID framing, fragmentation, and reassembly lives here in Dart.
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
const _channel = MethodChannel('com.chromecard.kphone/usb_hid');
|
|
||||||
|
|
||||||
// ChromeCard USB IDs (matches udev rule 70-chromecard-fido.rules)
|
|
||||||
const int kVendorId = 0x1209;
|
|
||||||
const int kProductId = 0x0005;
|
|
||||||
|
|
||||||
// CTAPHID constants
|
|
||||||
const int kCtaphidBroadcastChannel = 0xFFFFFFFF;
|
|
||||||
const int kCtaphidInit = 0x06;
|
|
||||||
const int kCtaphidMsg = 0x03;
|
|
||||||
const int kCtaphidCbor = 0x10;
|
|
||||||
const int kCtaphidCancel = 0x11;
|
|
||||||
const int kCtaphidError = 0x3F;
|
|
||||||
const int kCtaphidKeepalive = 0x3B;
|
|
||||||
|
|
||||||
const int kHidPacketSize = 64;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Transport selection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
bool _emulatorMode = false;
|
|
||||||
String _emulatorHost = '127.0.0.1';
|
|
||||||
int _emulatorPort = 8772;
|
|
||||||
Socket? _emulatorSocket;
|
|
||||||
|
|
||||||
// Persistent read state for the emulator TCP socket.
|
|
||||||
// Socket is a single-subscription stream — we must subscribe exactly once
|
|
||||||
// and accumulate all incoming bytes into a buffer.
|
|
||||||
// _emulatorRxWaiter is replaced on each call to _receivePacket so that
|
|
||||||
// concurrent waiters don't share a Completer and accidentally wake each other.
|
|
||||||
StreamSubscription<List<int>>? _emulatorSub;
|
|
||||||
final _emulatorRxBuf = <int>[];
|
|
||||||
Completer<void>? _emulatorRxWaiter;
|
|
||||||
bool _emulatorSocketOpen = false;
|
|
||||||
|
|
||||||
void _emulatorStartReading(Socket sock) {
|
|
||||||
_emulatorRxBuf.clear();
|
|
||||||
_emulatorRxWaiter = null;
|
|
||||||
_emulatorSocketOpen = true;
|
|
||||||
_emulatorSub?.cancel();
|
|
||||||
_emulatorSub = sock.listen(
|
|
||||||
(chunk) {
|
|
||||||
_emulatorRxBuf.addAll(chunk);
|
|
||||||
final w = _emulatorRxWaiter;
|
|
||||||
if (w != null && !w.isCompleted) w.complete();
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
_emulatorSocketOpen = false;
|
|
||||||
final w = _emulatorRxWaiter;
|
|
||||||
if (w != null && !w.isCompleted) w.completeError(const SocketException('Emulator socket closed'));
|
|
||||||
},
|
|
||||||
onError: (Object e) {
|
|
||||||
_emulatorSocketOpen = false;
|
|
||||||
final w = _emulatorRxWaiter;
|
|
||||||
if (w != null && !w.isCompleted) w.completeError(e);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switch to emulator mode — connects to card_emulator_bridge.py.
|
|
||||||
/// Must be called before openCard().
|
|
||||||
void useEmulator({String host = '127.0.0.1', int port = 8772}) {
|
|
||||||
_emulatorMode = true;
|
|
||||||
_emulatorHost = host;
|
|
||||||
_emulatorPort = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switch back to USB mode.
|
|
||||||
void useUsb() {
|
|
||||||
_emulatorMode = false;
|
|
||||||
_emulatorSocketOpen = false;
|
|
||||||
_emulatorSub?.cancel();
|
|
||||||
_emulatorSub = null;
|
|
||||||
_emulatorRxBuf.clear();
|
|
||||||
_emulatorSocket?.destroy();
|
|
||||||
_emulatorSocket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Opens the ChromeCard USB device (or emulator TCP connection).
|
|
||||||
Future<bool> openCard() async {
|
|
||||||
if (_emulatorMode) {
|
|
||||||
try {
|
|
||||||
_emulatorSub?.cancel();
|
|
||||||
_emulatorSocket?.destroy();
|
|
||||||
_emulatorSocket = await Socket.connect(_emulatorHost, _emulatorPort);
|
|
||||||
_emulatorStartReading(_emulatorSocket!);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod<bool>('openCard') ?? false;
|
|
||||||
} on MissingPluginException {
|
|
||||||
return false;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw CtapHidException('openCard failed: ${e.message}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Closes the card handle / TCP connection.
|
|
||||||
Future<void> closeCard() async {
|
|
||||||
if (_emulatorMode) {
|
|
||||||
_emulatorSub?.cancel();
|
|
||||||
_emulatorSub = null;
|
|
||||||
_emulatorRxBuf.clear();
|
|
||||||
_emulatorSocket?.destroy();
|
|
||||||
_emulatorSocket = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await _channel.invokeMethod<void>('closeCard');
|
|
||||||
} on MissingPluginException {
|
|
||||||
return;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw CtapHidException('closeCard failed: ${e.message}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if a card (or emulator) is currently open.
|
|
||||||
Future<bool> isCardAttached() async {
|
|
||||||
if (_emulatorMode) return _emulatorSub != null;
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod<bool>('isCardAttached') ?? false;
|
|
||||||
} on MissingPluginException {
|
|
||||||
return false;
|
|
||||||
} on PlatformException {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends a CTAPHID INIT to allocate a channel, returns the allocated CID.
|
|
||||||
Future<int> ctaphidInit() async {
|
|
||||||
final nonce = Uint8List(8);
|
|
||||||
final rng = Random.secure();
|
|
||||||
for (var i = 0; i < 8; i++) nonce[i] = rng.nextInt(256);
|
|
||||||
|
|
||||||
final responsePayload = await _ctaphidRoundtrip(
|
|
||||||
kCtaphidBroadcastChannel,
|
|
||||||
kCtaphidInit,
|
|
||||||
nonce,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (responsePayload.length < 12) {
|
|
||||||
throw CtapHidException('INIT response too short: ${responsePayload.length}');
|
|
||||||
}
|
|
||||||
// Response payload: nonce(8) + CID(4) + ...
|
|
||||||
final cid = (responsePayload[8] << 24)
|
|
||||||
| (responsePayload[9] << 16)
|
|
||||||
| (responsePayload[10] << 8)
|
|
||||||
| responsePayload[11];
|
|
||||||
return cid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends a CTAP2 CBOR command and returns the response payload.
|
|
||||||
Future<Uint8List> ctap2Cbor(int cid, Uint8List cbor) async {
|
|
||||||
return _ctaphidRoundtrip(cid, kCtaphidCbor, cbor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends a CTAP1/U2F message and returns the response payload.
|
|
||||||
Future<Uint8List> ctap1Msg(int cid, Uint8List apdu) async {
|
|
||||||
return _ctaphidRoundtrip(cid, kCtaphidMsg, apdu);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal: request/response
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Full CTAPHID round-trip: fragment request, send, receive, reassemble.
|
|
||||||
Future<Uint8List> _ctaphidRoundtrip(int cid, int cmd, Uint8List data) async {
|
|
||||||
final requestPackets = _buildPackets(cid: cid, cmd: cmd, data: data);
|
|
||||||
|
|
||||||
if (_emulatorMode) {
|
|
||||||
// Emulator: send all request packets at once, then read response.
|
|
||||||
// The bridge buffers all request packets and sends keepalives as needed,
|
|
||||||
// but since we write everything before reading, we just send and drain.
|
|
||||||
for (final pkt in requestPackets) {
|
|
||||||
await _sendPacketOnly(pkt);
|
|
||||||
}
|
|
||||||
// Read the response init packet (bridge may have sent keepalives first).
|
|
||||||
var first = await _receivePacket();
|
|
||||||
while (_isKeepalive(first)) {
|
|
||||||
first = await _receivePacket();
|
|
||||||
}
|
|
||||||
return await _reassembleResponse(first, cid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// USB: platform channel returns one response per send.
|
|
||||||
// Limitation: keepalives and continuation packets after the last request
|
|
||||||
// packet call _receivePacket(), which only works in emulator mode.
|
|
||||||
// In practice this is safe because CTAP2 responses for typical credential
|
|
||||||
// sizes fit in a single init packet and the card does not send keepalives
|
|
||||||
// synchronously before the response to the last request packet.
|
|
||||||
Uint8List lastReceived = Uint8List(kHidPacketSize);
|
|
||||||
for (final pkt in requestPackets) {
|
|
||||||
lastReceived = await _sendPacket(pkt);
|
|
||||||
}
|
|
||||||
while (_isKeepalive(lastReceived)) {
|
|
||||||
lastReceived = await _receivePacket();
|
|
||||||
}
|
|
||||||
return await _reassembleResponse(lastReceived, cid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send one 64-byte packet (emulator mode writes to socket; USB invokes platform channel).
|
|
||||||
Future<void> _sendPacketOnly(Uint8List packet) async {
|
|
||||||
assert(packet.length == kHidPacketSize);
|
|
||||||
if (_emulatorMode) {
|
|
||||||
if (!_emulatorSocketOpen) throw CtapHidException('Emulator socket closed');
|
|
||||||
final sock = _emulatorSocket;
|
|
||||||
if (sock == null) throw CtapHidException('Emulator socket not open');
|
|
||||||
sock.add(packet);
|
|
||||||
await sock.flush();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// USB: sendCtaphid returns the response; handled by the USB round-trip path.
|
|
||||||
throw CtapHidException('_sendPacketOnly not used for USB');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send one 64-byte packet and receive one response (USB mode).
|
|
||||||
Future<Uint8List> _sendPacket(Uint8List packet) async {
|
|
||||||
assert(packet.length == kHidPacketSize);
|
|
||||||
try {
|
|
||||||
final r = await _channel.invokeMethod<Uint8List>('sendCtaphid', packet);
|
|
||||||
return r ?? Uint8List(kHidPacketSize);
|
|
||||||
} on MissingPluginException {
|
|
||||||
throw CtapHidException('USB plugin not available');
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw CtapHidException('USB transfer failed: ${e.message}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Receive one 64-byte packet from the emulator buffer.
|
|
||||||
/// Waits until the persistent socket listener has buffered enough bytes.
|
|
||||||
Future<Uint8List> _receivePacket() async {
|
|
||||||
if (_emulatorSub == null) throw CtapHidException('Emulator socket not open');
|
|
||||||
while (_emulatorRxBuf.length < kHidPacketSize) {
|
|
||||||
_emulatorRxWaiter = Completer<void>();
|
|
||||||
await _emulatorRxWaiter!.future;
|
|
||||||
}
|
|
||||||
final pkt = Uint8List.fromList(_emulatorRxBuf.take(kHidPacketSize).toList());
|
|
||||||
_emulatorRxBuf.removeRange(0, kHidPacketSize);
|
|
||||||
return pkt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reassemble a full CTAPHID response from an init packet + any continuations.
|
|
||||||
Future<Uint8List> _reassembleResponse(Uint8List initPacket, int expectedCid) async {
|
|
||||||
_checkCid(initPacket, expectedCid);
|
|
||||||
|
|
||||||
final cmd = initPacket[4] & 0x7F;
|
|
||||||
final payloadLen = (initPacket[5] << 8) | initPacket[6];
|
|
||||||
final firstChunk = min(payloadLen, kHidPacketSize - 7);
|
|
||||||
|
|
||||||
final result = BytesBuilder();
|
|
||||||
result.add(initPacket.sublist(7, 7 + firstChunk));
|
|
||||||
|
|
||||||
var received = firstChunk;
|
|
||||||
while (received < payloadLen) {
|
|
||||||
final contPacket = await _receivePacket(); // USB continuation unimplemented — see _ctaphidRoundtrip
|
|
||||||
if (_isKeepalive(contPacket)) continue;
|
|
||||||
_checkCid(contPacket, expectedCid);
|
|
||||||
final chunk = min(payloadLen - received, kHidPacketSize - 5);
|
|
||||||
result.add(contPacket.sublist(5, 5 + chunk));
|
|
||||||
received += chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
final payload = result.toBytes();
|
|
||||||
|
|
||||||
if (cmd == kCtaphidError) {
|
|
||||||
throw CtapHidException(
|
|
||||||
'CTAPHID error: 0x${payload.isNotEmpty ? payload[0].toRadixString(16) : "??"}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isKeepalive(Uint8List pkt) =>
|
|
||||||
pkt.length >= 5 && (pkt[4] & 0x7F) == kCtaphidKeepalive;
|
|
||||||
|
|
||||||
void _checkCid(Uint8List pkt, int expected) {
|
|
||||||
if (pkt.length < 4) return;
|
|
||||||
final got = (pkt[0] << 24) | (pkt[1] << 16) | (pkt[2] << 8) | pkt[3];
|
|
||||||
if (got != expected && expected != kCtaphidBroadcastChannel) {
|
|
||||||
throw CtapHidException(
|
|
||||||
'CID mismatch: got 0x${got.toRadixString(16)}, '
|
|
||||||
'expected 0x${expected.toRadixString(16)}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Packet building
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
List<Uint8List> _buildPackets({
|
|
||||||
required int cid,
|
|
||||||
required int cmd,
|
|
||||||
required Uint8List data,
|
|
||||||
}) {
|
|
||||||
final packets = <Uint8List>[];
|
|
||||||
const initPayload = kHidPacketSize - 7;
|
|
||||||
const contPayload = kHidPacketSize - 5;
|
|
||||||
|
|
||||||
final init = Uint8List(kHidPacketSize);
|
|
||||||
init[0] = (cid >> 24) & 0xFF;
|
|
||||||
init[1] = (cid >> 16) & 0xFF;
|
|
||||||
init[2] = (cid >> 8) & 0xFF;
|
|
||||||
init[3] = cid & 0xFF;
|
|
||||||
init[4] = (cmd & 0x7F) | 0x80;
|
|
||||||
init[5] = (data.length >> 8) & 0xFF;
|
|
||||||
init[6] = data.length & 0xFF;
|
|
||||||
final firstChunk = min(data.length, initPayload);
|
|
||||||
init.setRange(7, 7 + firstChunk, data);
|
|
||||||
packets.add(init);
|
|
||||||
|
|
||||||
var offset = firstChunk;
|
|
||||||
var seq = 0;
|
|
||||||
while (offset < data.length) {
|
|
||||||
final cont = Uint8List(kHidPacketSize);
|
|
||||||
cont[0] = (cid >> 24) & 0xFF;
|
|
||||||
cont[1] = (cid >> 16) & 0xFF;
|
|
||||||
cont[2] = (cid >> 8) & 0xFF;
|
|
||||||
cont[3] = cid & 0xFF;
|
|
||||||
cont[4] = seq & 0x7F;
|
|
||||||
final chunk = min(data.length - offset, contPayload);
|
|
||||||
cont.setRange(5, 5 + chunk, data, offset);
|
|
||||||
packets.add(cont);
|
|
||||||
offset += chunk;
|
|
||||||
seq++;
|
|
||||||
}
|
|
||||||
return packets;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Exception
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CtapHidException implements Exception {
|
|
||||||
final String message;
|
|
||||||
CtapHidException(this.message);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'CtapHidException: $message';
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
// Enrollment storage — mirrors k_proxy_app.py ProxyState enrollment logic.
|
|
||||||
// Persists to a JSON file with the same schema so snapshots are portable.
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Username validation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
final _usernamePattern = RegExp(r'^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$');
|
|
||||||
|
|
||||||
String normalizeUsername(String raw) {
|
|
||||||
final s = raw.trim().toLowerCase();
|
|
||||||
if (!_usernamePattern.hasMatch(s)) {
|
|
||||||
throw ArgumentError(
|
|
||||||
'username must be 3–32 chars of lowercase letters, digits, dot, underscore, or dash');
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? normalizeDisplayName(String? raw) {
|
|
||||||
final s = (raw ?? '').trim();
|
|
||||||
if (s.isEmpty) return null;
|
|
||||||
if (s.length > 64) throw ArgumentError('display_name must be 64 characters or fewer');
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Model
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class Enrollment {
|
|
||||||
final String username;
|
|
||||||
final String? displayName;
|
|
||||||
final int createdAt;
|
|
||||||
final int updatedAt;
|
|
||||||
final String? userIdB64;
|
|
||||||
final String? credentialDataB64;
|
|
||||||
|
|
||||||
const Enrollment({
|
|
||||||
required this.username,
|
|
||||||
this.displayName,
|
|
||||||
required this.createdAt,
|
|
||||||
required this.updatedAt,
|
|
||||||
this.userIdB64,
|
|
||||||
this.credentialDataB64,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool get hasCredential => credentialDataB64 != null;
|
|
||||||
|
|
||||||
Enrollment copyWith({
|
|
||||||
String? displayName,
|
|
||||||
int? updatedAt,
|
|
||||||
String? userIdB64,
|
|
||||||
String? credentialDataB64,
|
|
||||||
}) =>
|
|
||||||
Enrollment(
|
|
||||||
username: username,
|
|
||||||
displayName: displayName ?? this.displayName,
|
|
||||||
createdAt: createdAt,
|
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
|
||||||
userIdB64: userIdB64 ?? this.userIdB64,
|
|
||||||
credentialDataB64: credentialDataB64 ?? this.credentialDataB64,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'username': username,
|
|
||||||
'display_name': displayName,
|
|
||||||
'created_at': createdAt,
|
|
||||||
'updated_at': updatedAt,
|
|
||||||
'user_id_b64': userIdB64,
|
|
||||||
'credential_data_b64': credentialDataB64,
|
|
||||||
};
|
|
||||||
|
|
||||||
factory Enrollment.fromJson(Map<String, dynamic> m) {
|
|
||||||
final username = (m['username'] as String? ?? '').trim();
|
|
||||||
// 'enrolled_at' was the field name in the Python k_proxy JSON schema; accept both for portability.
|
|
||||||
final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs();
|
|
||||||
return Enrollment(
|
|
||||||
username: username,
|
|
||||||
displayName: normalizeDisplayName(m['display_name'] as String?),
|
|
||||||
createdAt: createdAt,
|
|
||||||
updatedAt: m['updated_at'] as int? ?? createdAt,
|
|
||||||
userIdB64: m['user_id_b64'] as String?,
|
|
||||||
credentialDataB64: m['credential_data_b64'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Database
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class EnrollmentDb {
|
|
||||||
// [baseDir] can be injected in tests to bypass path_provider.
|
|
||||||
EnrollmentDb({Directory? baseDir}) : _baseDir = baseDir;
|
|
||||||
|
|
||||||
final Directory? _baseDir;
|
|
||||||
final Map<String, Enrollment> _entries = {};
|
|
||||||
bool _loaded = false;
|
|
||||||
|
|
||||||
// Dart isolates are single-threaded so there is no data race on _entries.
|
|
||||||
// We still serialize async disk I/O: each _serialize call chains its op
|
|
||||||
// onto _pending so concurrent callers queue up rather than interleave.
|
|
||||||
Future<void>? _pending;
|
|
||||||
|
|
||||||
Future<void> _serialize(Future<void> Function() op) async {
|
|
||||||
final prev = _pending;
|
|
||||||
final next = _doAfter(prev, op);
|
|
||||||
_pending = next;
|
|
||||||
await next;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> _doAfter(Future<void>? prev, Future<void> Function() op) async {
|
|
||||||
if (prev != null) {
|
|
||||||
try {
|
|
||||||
await prev;
|
|
||||||
} catch (_) {} // previous op error must not block the queue
|
|
||||||
}
|
|
||||||
await op();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Persistence
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<File> _dbFile() async {
|
|
||||||
final dir = _baseDir ?? await getApplicationSupportDirectory();
|
|
||||||
return File('${dir.path}/k_phone_enrollments.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
if (_loaded) return;
|
|
||||||
_loaded = true;
|
|
||||||
try {
|
|
||||||
final f = await _dbFile();
|
|
||||||
if (!f.existsSync()) return; // no file = fresh install; start with empty DB
|
|
||||||
final raw = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
||||||
final users = raw['users'] as List? ?? [];
|
|
||||||
for (final item in users) {
|
|
||||||
final e = Enrollment.fromJson(item as Map<String, dynamic>);
|
|
||||||
if (e.username.isNotEmpty) _entries[e.username] = e;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
_entries.clear(); // treat a corrupt/unreadable DB as empty; next save overwrites it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save() async {
|
|
||||||
final f = await _dbFile();
|
|
||||||
final users = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
|
|
||||||
await f.writeAsString(
|
|
||||||
const JsonEncoder.withIndent(' ').convert({'users': users.map((e) => e.toJson()).toList()}) + '\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> ensureLoaded() async {
|
|
||||||
await _serialize(_load);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a new user. Throws [StateError] if already enrolled.
|
|
||||||
Future<Enrollment> register({
|
|
||||||
required String username,
|
|
||||||
String? displayName,
|
|
||||||
String? userIdB64,
|
|
||||||
String? credentialDataB64,
|
|
||||||
}) async {
|
|
||||||
final canonical = normalizeUsername(username);
|
|
||||||
final pretty = normalizeDisplayName(displayName);
|
|
||||||
final now = _nowSecs();
|
|
||||||
Enrollment? result;
|
|
||||||
await _serialize(() async {
|
|
||||||
await _load();
|
|
||||||
if (_entries.containsKey(canonical)) throw StateError('user already enrolled');
|
|
||||||
final e = Enrollment(
|
|
||||||
username: canonical,
|
|
||||||
displayName: pretty,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
userIdB64: userIdB64,
|
|
||||||
credentialDataB64: credentialDataB64,
|
|
||||||
);
|
|
||||||
_entries[canonical] = e;
|
|
||||||
result = e;
|
|
||||||
await _save();
|
|
||||||
});
|
|
||||||
return result!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update display_name (and optionally credential data) for an existing user.
|
|
||||||
/// Throws [StateError] if not found.
|
|
||||||
Future<Enrollment> update({
|
|
||||||
required String username,
|
|
||||||
String? displayName,
|
|
||||||
String? userIdB64,
|
|
||||||
String? credentialDataB64,
|
|
||||||
}) async {
|
|
||||||
final canonical = normalizeUsername(username);
|
|
||||||
final pretty = normalizeDisplayName(displayName);
|
|
||||||
final now = _nowSecs();
|
|
||||||
Enrollment? result;
|
|
||||||
await _serialize(() async {
|
|
||||||
await _load();
|
|
||||||
final existing = _entries[canonical];
|
|
||||||
if (existing == null) throw StateError('user not enrolled');
|
|
||||||
final updated = existing.copyWith(
|
|
||||||
displayName: pretty,
|
|
||||||
updatedAt: now,
|
|
||||||
userIdB64: userIdB64 ?? existing.userIdB64,
|
|
||||||
credentialDataB64: credentialDataB64 ?? existing.credentialDataB64,
|
|
||||||
);
|
|
||||||
_entries[canonical] = updated;
|
|
||||||
result = updated;
|
|
||||||
await _save();
|
|
||||||
});
|
|
||||||
return result!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a user. Throws [StateError] if not found. Returns deleted entry.
|
|
||||||
Future<Enrollment> delete(String username) async {
|
|
||||||
final canonical = normalizeUsername(username);
|
|
||||||
Enrollment? result;
|
|
||||||
await _serialize(() async {
|
|
||||||
await _load();
|
|
||||||
final existing = _entries.remove(canonical);
|
|
||||||
if (existing == null) throw StateError('user not enrolled');
|
|
||||||
result = existing;
|
|
||||||
await _save();
|
|
||||||
});
|
|
||||||
return result!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a single enrollment or null.
|
|
||||||
Future<Enrollment?> get(String username) async {
|
|
||||||
String canonical;
|
|
||||||
try {
|
|
||||||
canonical = normalizeUsername(username);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await ensureLoaded();
|
|
||||||
return _entries[canonical];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all enrollments sorted by username.
|
|
||||||
Future<List<Enrollment>> list() async {
|
|
||||||
await ensureLoaded();
|
|
||||||
final result = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,347 +0,0 @@
|
||||||
// CTAP2 FIDO2 operations — makeCredential, getAssertion, verifyAssertion.
|
|
||||||
// Mirrors the direct-CTAP2 path in k_proxy_app.py.
|
|
||||||
//
|
|
||||||
// Wire format: first byte of ctap2Cbor response is a CTAP status code (0x00 = OK),
|
|
||||||
// remaining bytes are a CBOR map. Request is a CBOR map with no status prefix.
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:cbor/cbor.dart';
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:pointycastle/export.dart';
|
|
||||||
|
|
||||||
import 'ctaphid_channel.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const String kRpId = 'localhost';
|
|
||||||
const String kOrigin = 'https://localhost';
|
|
||||||
const String kRpName = 'ChromeCard Proxy';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public result types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class MakeCredentialResult {
|
|
||||||
/// Raw AttestedCredentialData bytes: aaguid(16) + credIdLen(2) + credId + coseKey
|
|
||||||
final Uint8List credentialData;
|
|
||||||
|
|
||||||
/// base64url of credentialData — store this in EnrollmentDb
|
|
||||||
String get credentialDataB64 => _b64uEncode(credentialData);
|
|
||||||
|
|
||||||
/// The 32-byte user handle used during registration
|
|
||||||
final Uint8List userId;
|
|
||||||
|
|
||||||
/// base64url of userId — store this in EnrollmentDb
|
|
||||||
String get userIdB64 => _b64uEncode(userId);
|
|
||||||
|
|
||||||
MakeCredentialResult({required this.credentialData, required this.userId});
|
|
||||||
}
|
|
||||||
|
|
||||||
class GetAssertionResult {
|
|
||||||
final Uint8List authData;
|
|
||||||
final Uint8List signature;
|
|
||||||
final Uint8List clientDataHash;
|
|
||||||
final String clientDataJson;
|
|
||||||
|
|
||||||
GetAssertionResult({
|
|
||||||
required this.authData,
|
|
||||||
required this.signature,
|
|
||||||
required this.clientDataHash,
|
|
||||||
required this.clientDataJson,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// makeCredential
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Runs CTAP2 authenticatorMakeCredential against the card on [cid].
|
|
||||||
/// Returns credential data that should be persisted in the enrollment store.
|
|
||||||
Future<MakeCredentialResult> makeCredential(
|
|
||||||
int cid,
|
|
||||||
String username, {
|
|
||||||
String? displayName,
|
|
||||||
Uint8List? userId,
|
|
||||||
}) async {
|
|
||||||
final uid = userId ?? _randomBytes(32);
|
|
||||||
final challenge = _randomBytes(32);
|
|
||||||
|
|
||||||
final clientDataJson = _buildClientDataJson('webauthn.create', challenge);
|
|
||||||
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
|
||||||
|
|
||||||
// CBOR map: authenticatorMakeCredential (CTAP2 spec integer keys throughout)
|
|
||||||
final requestMap = CborMap({
|
|
||||||
CborSmallInt(1): CborBytes(clientDataHash),
|
|
||||||
CborSmallInt(2): CborMap({
|
|
||||||
CborString('id'): CborString(kRpId),
|
|
||||||
CborString('name'): CborString(kRpName),
|
|
||||||
}),
|
|
||||||
CborSmallInt(3): CborMap({
|
|
||||||
CborString('id'): CborBytes(uid),
|
|
||||||
CborString('name'): CborString(username),
|
|
||||||
CborString('displayName'): CborString(displayName ?? username),
|
|
||||||
}),
|
|
||||||
CborSmallInt(4): CborList([
|
|
||||||
CborMap({
|
|
||||||
CborString('type'): CborString('public-key'),
|
|
||||||
CborString('alg'): CborSmallInt(-7),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
CborSmallInt(7): CborMap({
|
|
||||||
// rk=false: non-resident — credential ID is stored externally in EnrollmentDb
|
|
||||||
// rather than on the card, so multiple users can enroll on one card.
|
|
||||||
// uv=false: no PIN; authentication uses user-presence (fingerprint touch) only.
|
|
||||||
CborString('rk'): CborBool(false),
|
|
||||||
CborString('uv'): CborBool(false),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// CTAP2 over CTAPHID: first byte is the authenticatorMakeCredential (0x01) command code.
|
|
||||||
final encoded = Uint8List.fromList([0x01, ...cbor.encode(requestMap)]);
|
|
||||||
final response = await ctap2Cbor(cid, encoded);
|
|
||||||
|
|
||||||
final responseMap = _parseCtapResponse(response);
|
|
||||||
final authData = _requireBytes(responseMap, 2, 'makeCredential authData');
|
|
||||||
|
|
||||||
final credData = _extractAttestedCredentialData(authData);
|
|
||||||
return MakeCredentialResult(credentialData: credData, userId: uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// getAssertion
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
|
||||||
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
|
||||||
/// [challenge] overrides the random challenge — use for per-request token binding.
|
|
||||||
Future<GetAssertionResult> getAssertion(
|
|
||||||
int cid,
|
|
||||||
String credentialDataB64, {
|
|
||||||
Uint8List? challenge,
|
|
||||||
}) async {
|
|
||||||
final credData = _b64uDecode(credentialDataB64);
|
|
||||||
final credId = _extractCredentialId(credData);
|
|
||||||
|
|
||||||
final actualChallenge = challenge ?? _randomBytes(32);
|
|
||||||
final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge);
|
|
||||||
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
|
||||||
|
|
||||||
final requestMap = CborMap({
|
|
||||||
CborSmallInt(1): CborString(kRpId),
|
|
||||||
CborSmallInt(2): CborBytes(clientDataHash),
|
|
||||||
CborSmallInt(3): CborList([
|
|
||||||
CborMap({
|
|
||||||
CborString('type'): CborString('public-key'),
|
|
||||||
CborString('id'): CborBytes(credId),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
CborSmallInt(5): CborMap({
|
|
||||||
CborString('up'): CborBool(true), // require fingerprint touch (user presence)
|
|
||||||
CborString('uv'): CborBool(false), // no PIN
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// CTAP2 over CTAPHID: first byte is the authenticatorGetAssertion (0x02) command code.
|
|
||||||
final encoded = Uint8List.fromList([0x02, ...cbor.encode(requestMap)]);
|
|
||||||
final response = await ctap2Cbor(cid, encoded);
|
|
||||||
|
|
||||||
final responseMap = _parseCtapResponse(response);
|
|
||||||
final authData = _requireBytes(responseMap, 2, 'getAssertion authData');
|
|
||||||
final signature = _requireBytes(responseMap, 3, 'getAssertion signature');
|
|
||||||
|
|
||||||
return GetAssertionResult(
|
|
||||||
authData: authData,
|
|
||||||
signature: signature,
|
|
||||||
clientDataHash: clientDataHash,
|
|
||||||
clientDataJson: clientDataJson,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// verifyAssertion
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Verifies the ECDSA-P256 assertion signature.
|
|
||||||
/// [credentialDataB64] is the stored base64url AttestedCredentialData.
|
|
||||||
/// Returns true if the signature is valid.
|
|
||||||
bool verifyAssertion(
|
|
||||||
String credentialDataB64,
|
|
||||||
Uint8List authData,
|
|
||||||
Uint8List signature,
|
|
||||||
Uint8List clientDataHash,
|
|
||||||
) {
|
|
||||||
final credData = _b64uDecode(credentialDataB64);
|
|
||||||
final coseKey = _extractCoseKey(credData);
|
|
||||||
final pubKey = _coseKeyToEcPublicKey(coseKey);
|
|
||||||
|
|
||||||
// CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON).
|
|
||||||
final message = Uint8List(authData.length + clientDataHash.length)
|
|
||||||
..setRange(0, authData.length, authData)
|
|
||||||
..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash);
|
|
||||||
|
|
||||||
final (r, s) = _decodeDerSignature(signature);
|
|
||||||
|
|
||||||
final verifier = ECDSASigner(SHA256Digest())
|
|
||||||
..init(false, PublicKeyParameter<ECPublicKey>(pubKey));
|
|
||||||
|
|
||||||
try {
|
|
||||||
return verifier.verifySignature(message, ECSignature(r, s));
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AuthData parsing helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Extracts AttestedCredentialData from a full authData blob.
|
|
||||||
/// authData layout:
|
|
||||||
/// [0:32] rpIdHash
|
|
||||||
/// [32] flags
|
|
||||||
/// [33:37] signCount (uint32 BE)
|
|
||||||
/// [37:53] aaguid (16 bytes) ← attested cred data starts here
|
|
||||||
/// [53:55] credIdLen (uint16 BE)
|
|
||||||
/// [55:55+n] credId
|
|
||||||
/// [55+n:] COSE key (CBOR)
|
|
||||||
Uint8List _extractAttestedCredentialData(Uint8List authData) {
|
|
||||||
if (authData.length < 55) {
|
|
||||||
throw FormatException('authData too short for attested credential data: ${authData.length}');
|
|
||||||
}
|
|
||||||
// The attested credential data is everything from offset 37 onward.
|
|
||||||
return Uint8List.fromList(authData.sublist(37));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts the credential ID from AttestedCredentialData bytes.
|
|
||||||
/// Layout: aaguid(16) + credIdLen(2) + credId(n) + coseKey
|
|
||||||
Uint8List _extractCredentialId(Uint8List credData) {
|
|
||||||
if (credData.length < 18) {
|
|
||||||
throw FormatException('credentialData too short: ${credData.length}');
|
|
||||||
}
|
|
||||||
final credIdLen = (credData[16] << 8) | credData[17];
|
|
||||||
if (credData.length < 18 + credIdLen) {
|
|
||||||
throw FormatException('credentialData truncated before credId end');
|
|
||||||
}
|
|
||||||
return Uint8List.fromList(credData.sublist(18, 18 + credIdLen));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts the COSE key bytes from AttestedCredentialData.
|
|
||||||
Uint8List _extractCoseKey(Uint8List credData) {
|
|
||||||
if (credData.length < 18) {
|
|
||||||
throw FormatException('credentialData too short for COSE key');
|
|
||||||
}
|
|
||||||
final credIdLen = (credData[16] << 8) | credData[17];
|
|
||||||
final coseStart = 18 + credIdLen;
|
|
||||||
if (credData.length <= coseStart) {
|
|
||||||
throw FormatException('credentialData has no COSE key bytes');
|
|
||||||
}
|
|
||||||
return Uint8List.fromList(credData.sublist(coseStart));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a COSE EC2 key and returns an ECPublicKey for pointycastle.
|
|
||||||
ECPublicKey _coseKeyToEcPublicKey(Uint8List coseKeyBytes) {
|
|
||||||
final decoded = cbor.decode(coseKeyBytes);
|
|
||||||
if (decoded is! CborMap) throw FormatException('COSE key is not a CBOR map');
|
|
||||||
|
|
||||||
Uint8List? x, y;
|
|
||||||
for (final entry in decoded.entries) {
|
|
||||||
final k = entry.key;
|
|
||||||
final v = entry.value;
|
|
||||||
// COSE key -2 = x, -3 = y (represented as CborSmallInt or CborInt)
|
|
||||||
final ki = _cborInt(k);
|
|
||||||
if (ki == -2 && v is CborBytes) x = Uint8List.fromList(v.bytes);
|
|
||||||
if (ki == -3 && v is CborBytes) y = Uint8List.fromList(v.bytes);
|
|
||||||
}
|
|
||||||
if (x == null || y == null) throw FormatException('COSE key missing x or y coordinate');
|
|
||||||
|
|
||||||
final domainParams = ECDomainParameters('prime256v1');
|
|
||||||
final point = domainParams.curve.createPoint(
|
|
||||||
BigInt.parse(x.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16),
|
|
||||||
BigInt.parse(y.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16),
|
|
||||||
);
|
|
||||||
return ECPublicKey(point, domainParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _cborInt(CborValue v) {
|
|
||||||
if (v is CborSmallInt) return v.value;
|
|
||||||
if (v is CborInt) return v.toInt();
|
|
||||||
throw FormatException('expected CBOR int, got ${v.runtimeType}');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DER-decode an ECDSA signature into (r, s) BigInts.
|
|
||||||
(BigInt, BigInt) _decodeDerSignature(Uint8List der) {
|
|
||||||
// SEQUENCE { INTEGER r, INTEGER s }
|
|
||||||
if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag');
|
|
||||||
// P-256 signatures are always ≤72 bytes, so the SEQUENCE length fits in one byte.
|
|
||||||
// Multi-byte BER length encoding (0x81/0x82 prefix) is not handled here.
|
|
||||||
var offset = 2; // skip 0x30 + one-byte length
|
|
||||||
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r');
|
|
||||||
final rLen = der[offset + 1];
|
|
||||||
final rBytes = der.sublist(offset + 2, offset + 2 + rLen);
|
|
||||||
offset += 2 + rLen;
|
|
||||||
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for s');
|
|
||||||
final sLen = der[offset + 1];
|
|
||||||
final sBytes = der.sublist(offset + 2, offset + 2 + sLen);
|
|
||||||
return (_bigIntFromBytes(rBytes), _bigIntFromBytes(sBytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
BigInt _bigIntFromBytes(Uint8List bytes) {
|
|
||||||
var result = BigInt.zero;
|
|
||||||
for (final b in bytes) {
|
|
||||||
result = (result << 8) | BigInt.from(b);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CTAP response parsing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CborMap _parseCtapResponse(Uint8List response) {
|
|
||||||
if (response.isEmpty) throw FormatException('empty CTAP response');
|
|
||||||
final status = response[0];
|
|
||||||
if (status != 0x00) throw FormatException('CTAP error: 0x${status.toRadixString(16)}');
|
|
||||||
final decoded = cbor.decode(response.sublist(1));
|
|
||||||
if (decoded is! CborMap) throw FormatException('CTAP response body is not a CBOR map');
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List _requireBytes(CborMap map, int key, String field) {
|
|
||||||
final v = map[CborSmallInt(key)];
|
|
||||||
if (v == null) throw FormatException('$field: missing key $key in CTAP response');
|
|
||||||
if (v is! CborBytes) throw FormatException('$field: expected bytes, got ${v.runtimeType}');
|
|
||||||
return Uint8List.fromList(v.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
String _buildClientDataJson(String type, Uint8List challenge) {
|
|
||||||
final challengeB64 = _b64uEncode(challenge);
|
|
||||||
return '{"type":"$type","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}';
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List _sha256(List<int> data) {
|
|
||||||
return Uint8List.fromList(sha256.convert(data).bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List _randomBytes(int n) {
|
|
||||||
final rng = Random.secure();
|
|
||||||
return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
|
|
||||||
}
|
|
||||||
|
|
||||||
String _b64uEncode(Uint8List data) {
|
|
||||||
return base64Url.encode(data).replaceAll('=', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List _b64uDecode(String s) {
|
|
||||||
// base64url strips trailing '='; restore padding to the nearest multiple of 4.
|
|
||||||
final padded = s + '=' * ((4 - s.length % 4) % 4);
|
|
||||||
return Uint8List.fromList(base64Url.decode(padded));
|
|
||||||
}
|
|
||||||
|
|
@ -1,575 +0,0 @@
|
||||||
// Component 1 — HTTP proxy with URL gating filter (v2 architecture).
|
|
||||||
//
|
|
||||||
// Routing rule — binary decision per request:
|
|
||||||
// gated host → ask Component 2 for a bearer token (POST /auth/get-token),
|
|
||||||
// then call the endpoint directly with Authorization: Bearer.
|
|
||||||
// other host → forward directly to the target host:port (no auth, port 80)
|
|
||||||
//
|
|
||||||
// For HTTPS (CONNECT) to gated hosts the CONNECT is still relayed through
|
|
||||||
// Component 2 (session-gate check), with Component 2 opening the upstream TCP
|
|
||||||
// connection. TODO: replace with local MITM so Component 2 never contacts
|
|
||||||
// endpoints directly.
|
|
||||||
//
|
|
||||||
// Gated hosts file (gated_hosts.txt in the app documents directory): one entry
|
|
||||||
// per line, either "host" or "host:port". Lines starting with "#" and blank
|
|
||||||
// lines are ignored.
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
const int kFilterProxyPort = 8888;
|
|
||||||
const int kComponent2Port = 8771;
|
|
||||||
const String _kGatedHostsFilename = 'gated_hosts.txt';
|
|
||||||
const int _kMaxHeaderBytes = 64 * 1024;
|
|
||||||
|
|
||||||
final _kBytesConnectOk = utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
||||||
|
|
||||||
class FilterProxy {
|
|
||||||
FilterProxy({
|
|
||||||
int listenPort = kFilterProxyPort,
|
|
||||||
int component2Port = kComponent2Port,
|
|
||||||
}) : _listenPort = listenPort,
|
|
||||||
_component2Port = component2Port;
|
|
||||||
|
|
||||||
final int _listenPort;
|
|
||||||
final int _component2Port;
|
|
||||||
final Set<String> _gatedHosts = {};
|
|
||||||
ServerSocket? _server;
|
|
||||||
|
|
||||||
void Function(String)? onLog;
|
|
||||||
|
|
||||||
// The actual bound port — valid after start() returns.
|
|
||||||
int get port => _server?.port ?? _listenPort;
|
|
||||||
|
|
||||||
// Populate the gated hosts directly without file I/O.
|
|
||||||
// Call this before start() in tests; in production call loadGatedHosts() instead.
|
|
||||||
void setGatedEntries(Iterable<String> entries) {
|
|
||||||
_gatedHosts
|
|
||||||
..clear()
|
|
||||||
..addAll(entries.map((e) => e.trim().toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exposed for unit tests.
|
|
||||||
bool isGatedForTest(String host, int port) => _isGated(host, port);
|
|
||||||
|
|
||||||
// Creates the default gated_hosts.txt (containing httpbin.org) if the file
|
|
||||||
// does not already exist. Call before loadGatedHosts() during startup.
|
|
||||||
Future<void> seedDefaultGatedHosts() async {
|
|
||||||
try {
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
|
||||||
final file = File('${dir.path}/$_kGatedHostsFilename');
|
|
||||||
if (!file.existsSync()) {
|
|
||||||
await file.writeAsString(
|
|
||||||
'# Gated hosts — traffic to these is forwarded through Component 2,\n'
|
|
||||||
'# which requires an active card session before relaying.\n'
|
|
||||||
'#\n'
|
|
||||||
'# One entry per line: "host" or "host:port".\n'
|
|
||||||
'httpbin.org\n',
|
|
||||||
);
|
|
||||||
_log('Created default $_kGatedHostsFilename with httpbin.org');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_log('Could not seed default $_kGatedHostsFilename: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadGatedHosts() async {
|
|
||||||
_gatedHosts.clear();
|
|
||||||
try {
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
|
||||||
final file = File('${dir.path}/$_kGatedHostsFilename');
|
|
||||||
var count = 0;
|
|
||||||
for (final raw in await file.readAsLines()) {
|
|
||||||
final entry = raw.trim().toLowerCase();
|
|
||||||
if (entry.isEmpty || entry.startsWith('#')) continue;
|
|
||||||
_gatedHosts.add(entry);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
_log('Gated hosts loaded: $count ${count == 1 ? 'entry' : 'entries'}');
|
|
||||||
} on FileSystemException {
|
|
||||||
_log('No $_kGatedHostsFilename — all traffic forwarded directly to target');
|
|
||||||
} catch (e) {
|
|
||||||
_log('Gated hosts load error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isGated(String host, int port) {
|
|
||||||
final h = host.toLowerCase();
|
|
||||||
return _gatedHosts.contains(h) || _gatedHosts.contains('$h:$port');
|
|
||||||
}
|
|
||||||
|
|
||||||
// start() does NOT call loadGatedHosts(). Callers are responsible:
|
|
||||||
// production: await proxy.seedDefaultGatedHosts();
|
|
||||||
// await proxy.loadGatedHosts();
|
|
||||||
// await proxy.start();
|
|
||||||
// tests: proxy.setGatedEntries([...]); await proxy.start();
|
|
||||||
Future<void> start() async {
|
|
||||||
_server = await ServerSocket.bind(InternetAddress.anyIPv4, _listenPort);
|
|
||||||
_log('Filter proxy listening on :${_server!.port}');
|
|
||||||
_server!.listen(
|
|
||||||
(client) => _handle(client).catchError((e) {
|
|
||||||
_log('Connection error: $e');
|
|
||||||
try { client.destroy(); } catch (_) {}
|
|
||||||
}),
|
|
||||||
onError: (e) => _log('Server error: $e'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stop() async {
|
|
||||||
await _server?.close();
|
|
||||||
_server = null;
|
|
||||||
_log('Filter proxy stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-connection handler
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handle(Socket client) async {
|
|
||||||
final buf = <int>[];
|
|
||||||
int headerEnd = -1;
|
|
||||||
late StreamSubscription<List<int>> sub;
|
|
||||||
final headersReady = Completer<void>();
|
|
||||||
|
|
||||||
sub = client.listen(
|
|
||||||
(data) {
|
|
||||||
if (headersReady.isCompleted) return;
|
|
||||||
buf.addAll(data);
|
|
||||||
if (buf.length > _kMaxHeaderBytes) {
|
|
||||||
sub.cancel();
|
|
||||||
headersReady.complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Scan only the overlap between previous and new data to avoid O(n²).
|
|
||||||
// Must look back 3 bytes into the previous chunk when scanning for \r\n\r\n.
|
|
||||||
final searchFrom = (buf.length - data.length - 3).clamp(0, buf.length);
|
|
||||||
for (int i = searchFrom; i <= buf.length - 4; i++) {
|
|
||||||
if (buf[i] == 13 && buf[i + 1] == 10 && buf[i + 2] == 13 && buf[i + 3] == 10) {
|
|
||||||
headerEnd = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (headerEnd >= 0) {
|
|
||||||
sub.pause();
|
|
||||||
headersReady.complete();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (e) { if (!headersReady.isCompleted) headersReady.completeError(e); },
|
|
||||||
onDone: () { if (!headersReady.isCompleted) headersReady.complete(); },
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await headersReady.future.timeout(const Duration(seconds: 15));
|
|
||||||
} on TimeoutException {
|
|
||||||
sub.cancel();
|
|
||||||
client.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (headerEnd < 0) {
|
|
||||||
sub.cancel();
|
|
||||||
client.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final headerText = String.fromCharCodes(buf.sublist(0, headerEnd));
|
|
||||||
final remainder = buf.sublist(headerEnd + 4);
|
|
||||||
final lines = headerText.split('\r\n');
|
|
||||||
final requestParts = lines[0].trim().split(' ');
|
|
||||||
if (requestParts.length < 2) {
|
|
||||||
sub.cancel();
|
|
||||||
client.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final method = requestParts[0].toUpperCase();
|
|
||||||
final target = requestParts[1];
|
|
||||||
|
|
||||||
if (method == 'CONNECT') {
|
|
||||||
final (:host, :port) = _parseHostPort(target);
|
|
||||||
if (_isGated(host, port)) {
|
|
||||||
await _handleGatedConnect(client, sub, target, remainder);
|
|
||||||
} else {
|
|
||||||
await _handleDirectConnect(client, sub, host, port, remainder);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await _handleHttp(client, sub, method, target, lines.sublist(1), remainder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Direct CONNECT tunnel (non-gated hosts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleDirectConnect(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String host,
|
|
||||||
int port,
|
|
||||||
List<int> remainder,
|
|
||||||
) async {
|
|
||||||
Socket upstream;
|
|
||||||
try {
|
|
||||||
upstream = await Socket.connect(host, port).timeout(const Duration(seconds: 10));
|
|
||||||
} catch (e) {
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.add(_kBytesConnectOk);
|
|
||||||
|
|
||||||
if (remainder.isNotEmpty) upstream.add(remainder);
|
|
||||||
|
|
||||||
upstream.listen(
|
|
||||||
client.add,
|
|
||||||
onDone: client.destroy,
|
|
||||||
onError: (_) { upstream.destroy(); client.destroy(); },
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
sub.onData(upstream.add);
|
|
||||||
sub.onDone(upstream.destroy);
|
|
||||||
sub.onError((_) { upstream.destroy(); client.destroy(); });
|
|
||||||
sub.resume();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Gated CONNECT tunnel — relay CONNECT request through Component 2
|
|
||||||
//
|
|
||||||
// We MUST NOT pipe raw TLS to Component 2's HttpServer. Instead we forward
|
|
||||||
// a CONNECT request to it, wait for its HTTP response (200 = auth OK,
|
|
||||||
// 403 = no active session, 502 = upstream error), and only then tell the
|
|
||||||
// browser whether the tunnel was established.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleGatedConnect(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String target,
|
|
||||||
List<int> remainder,
|
|
||||||
) async {
|
|
||||||
Socket comp2;
|
|
||||||
try {
|
|
||||||
comp2 = await Socket.connect('127.0.0.1', _component2Port)
|
|
||||||
.timeout(const Duration(seconds: 5));
|
|
||||||
} catch (e) {
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward the CONNECT request to Component 2.
|
|
||||||
comp2.add(utf8.encode('CONNECT $target HTTP/1.1\r\nHost: $target\r\n\r\n'));
|
|
||||||
|
|
||||||
// Read Component 2's response headers.
|
|
||||||
final respBuf = <int>[];
|
|
||||||
int respHeaderEnd = -1;
|
|
||||||
final respReady = Completer<void>();
|
|
||||||
late StreamSubscription<List<int>> comp2Sub;
|
|
||||||
|
|
||||||
comp2Sub = comp2.listen(
|
|
||||||
(data) {
|
|
||||||
if (respReady.isCompleted) return;
|
|
||||||
respBuf.addAll(data);
|
|
||||||
if (respBuf.length > _kMaxHeaderBytes) {
|
|
||||||
comp2Sub.pause();
|
|
||||||
respReady.complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final searchFrom = (respBuf.length - data.length - 3).clamp(0, respBuf.length);
|
|
||||||
for (int i = searchFrom; i <= respBuf.length - 4; i++) {
|
|
||||||
if (respBuf[i] == 13 && respBuf[i + 1] == 10 &&
|
|
||||||
respBuf[i + 2] == 13 && respBuf[i + 3] == 10) {
|
|
||||||
respHeaderEnd = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (respHeaderEnd >= 0) {
|
|
||||||
comp2Sub.pause();
|
|
||||||
respReady.complete();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (e) { if (!respReady.isCompleted) respReady.completeError(e); },
|
|
||||||
onDone: () { if (!respReady.isCompleted) respReady.complete(); },
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await respReady.future.timeout(const Duration(seconds: 10));
|
|
||||||
} on TimeoutException {
|
|
||||||
comp2Sub.cancel();
|
|
||||||
comp2.destroy();
|
|
||||||
_deny(client, sub, 504, 'Gateway Timeout');
|
|
||||||
return;
|
|
||||||
} catch (_) {
|
|
||||||
comp2Sub.cancel();
|
|
||||||
comp2.destroy();
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (respHeaderEnd < 0) {
|
|
||||||
comp2Sub.cancel();
|
|
||||||
comp2.destroy();
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the status code from Component 2's response.
|
|
||||||
final respText = String.fromCharCodes(respBuf.sublist(0, respHeaderEnd));
|
|
||||||
final statusLine = respText.split('\r\n').first;
|
|
||||||
final statusParts = statusLine.split(' ');
|
|
||||||
final statusCode = statusParts.length >= 2 ? int.tryParse(statusParts[1]) ?? 0 : 0;
|
|
||||||
|
|
||||||
if (statusCode != 200) {
|
|
||||||
comp2Sub.cancel();
|
|
||||||
comp2.destroy();
|
|
||||||
_deny(client, sub,
|
|
||||||
statusCode == 403 ? 403 : 502,
|
|
||||||
statusCode == 403 ? 'Forbidden' : 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tunnel is established. Any bytes Component 2 sent after the CONNECT
|
|
||||||
// headers are already tunneled data (rare but possible).
|
|
||||||
final comp2Remainder = respBuf.sublist(respHeaderEnd + 4);
|
|
||||||
|
|
||||||
// Tell the browser the tunnel is open.
|
|
||||||
client.add(_kBytesConnectOk);
|
|
||||||
if (remainder.isNotEmpty) comp2.add(remainder);
|
|
||||||
if (comp2Remainder.isNotEmpty) client.add(comp2Remainder);
|
|
||||||
|
|
||||||
// Pipe browser ↔ Component 2 ↔ upstream (Component 2 owns the upstream half).
|
|
||||||
comp2Sub.onData(client.add);
|
|
||||||
comp2Sub.onDone(client.destroy);
|
|
||||||
comp2Sub.onError((_) { comp2.destroy(); client.destroy(); });
|
|
||||||
comp2Sub.resume();
|
|
||||||
|
|
||||||
sub.onData(comp2.add);
|
|
||||||
sub.onDone(comp2.destroy);
|
|
||||||
sub.onError((_) { comp2.destroy(); client.destroy(); });
|
|
||||||
sub.resume();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Plain HTTP request
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleHttp(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String method,
|
|
||||||
String targetUrl,
|
|
||||||
List<String> headerLines,
|
|
||||||
List<int> remainder,
|
|
||||||
) async {
|
|
||||||
Uri uri;
|
|
||||||
try {
|
|
||||||
uri = Uri.parse(targetUrl);
|
|
||||||
} catch (_) {
|
|
||||||
sub.cancel();
|
|
||||||
client.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final host = uri.host;
|
|
||||||
final port = uri.hasPort ? uri.port : 80;
|
|
||||||
|
|
||||||
int contentLength = 0;
|
|
||||||
for (final h in headerLines) {
|
|
||||||
if (h.toLowerCase().startsWith('content-length:')) {
|
|
||||||
contentLength = int.tryParse(h.split(':').last.trim()) ?? 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isGated(host, port)) {
|
|
||||||
await _handleGatedHttp(client, sub, method, uri, headerLines, remainder, contentLength);
|
|
||||||
} else {
|
|
||||||
await _handleDirectHttp(client, sub, method, uri, headerLines, remainder, contentLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gated plain HTTP (v2): get token from Component 2, then call endpoint directly.
|
|
||||||
Future<void> _handleGatedHttp(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String method,
|
|
||||||
Uri uri,
|
|
||||||
List<String> headerLines,
|
|
||||||
List<int> remainder,
|
|
||||||
int contentLength,
|
|
||||||
) async {
|
|
||||||
String token;
|
|
||||||
try {
|
|
||||||
token = await _getAuthToken(uri);
|
|
||||||
} catch (_) {
|
|
||||||
_deny(client, sub, 407, 'Proxy Authentication Required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Socket upstream;
|
|
||||||
try {
|
|
||||||
upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
} catch (_) {
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final out = StringBuffer();
|
|
||||||
_writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines, bearerToken: token);
|
|
||||||
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-gated plain HTTP: forward directly, no auth.
|
|
||||||
Future<void> _handleDirectHttp(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
String method,
|
|
||||||
Uri uri,
|
|
||||||
List<String> headerLines,
|
|
||||||
List<int> remainder,
|
|
||||||
int contentLength,
|
|
||||||
) async {
|
|
||||||
Socket upstream;
|
|
||||||
try {
|
|
||||||
upstream = await Socket.connect(uri.host, uri.hasPort ? uri.port : 80)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
} catch (_) {
|
|
||||||
_deny(client, sub, 502, 'Bad Gateway');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final out = StringBuffer();
|
|
||||||
_writeProxyHeaders(out, method, _relativePath(uri), uri, headerLines);
|
|
||||||
await _forwardHttpRequest(client, sub, upstream, out.toString(), remainder, contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calls POST /auth/get-token on Component 2 with domain-level binding and
|
|
||||||
// returns the bearer token (a self-contained FIDO2 assertion bundle).
|
|
||||||
// Throws if card is unavailable or Component 2 is unreachable.
|
|
||||||
Future<String> _getAuthToken(Uri uri) async {
|
|
||||||
final nonce = _secureHex(16);
|
|
||||||
final payload = utf8.encode(jsonEncode({
|
|
||||||
'host': uri.host,
|
|
||||||
'nonce': nonce,
|
|
||||||
}));
|
|
||||||
final httpClient = HttpClient()
|
|
||||||
..connectionTimeout = const Duration(seconds: 5);
|
|
||||||
try {
|
|
||||||
final req = await httpClient.postUrl(
|
|
||||||
Uri(scheme: 'http', host: '127.0.0.1', port: _component2Port, path: '/auth/get-token'),
|
|
||||||
);
|
|
||||||
req.headers.contentType = ContentType.json;
|
|
||||||
req.contentLength = payload.length;
|
|
||||||
req.add(payload);
|
|
||||||
final resp = await req.close();
|
|
||||||
final body = await resp.transform(utf8.decoder).join();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
if (json['ok'] == true) {
|
|
||||||
return json['token'] as String;
|
|
||||||
}
|
|
||||||
throw Exception(json['error'] ?? 'auth failed');
|
|
||||||
} finally {
|
|
||||||
httpClient.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _secureHex(int bytes) {
|
|
||||||
final rng = Random.secure();
|
|
||||||
return List.generate(bytes, (_) => rng.nextInt(256))
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void _writeProxyHeaders(
|
|
||||||
StringBuffer out,
|
|
||||||
String method,
|
|
||||||
String path,
|
|
||||||
Uri uri,
|
|
||||||
List<String> headerLines, {
|
|
||||||
String? bearerToken,
|
|
||||||
}) {
|
|
||||||
out
|
|
||||||
..write('$method $path HTTP/1.1\r\n')
|
|
||||||
..write('Host: ${uri.host}${uri.hasPort ? ":${uri.port}" : ""}\r\n');
|
|
||||||
if (bearerToken != null) out.write('Authorization: Bearer $bearerToken\r\n');
|
|
||||||
for (final h in headerLines) {
|
|
||||||
if (h.isEmpty) continue;
|
|
||||||
final lower = h.toLowerCase();
|
|
||||||
if (lower.startsWith('host:') ||
|
|
||||||
lower.startsWith('proxy-connection:') ||
|
|
||||||
lower.startsWith('proxy-authorization:') ||
|
|
||||||
(bearerToken != null && lower.startsWith('authorization:'))) continue;
|
|
||||||
out.write('$h\r\n');
|
|
||||||
}
|
|
||||||
out.write('Connection: close\r\n\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _forwardHttpRequest(
|
|
||||||
Socket client,
|
|
||||||
StreamSubscription<List<int>> sub,
|
|
||||||
Socket upstream,
|
|
||||||
String headers,
|
|
||||||
List<int> remainder,
|
|
||||||
int contentLength,
|
|
||||||
) async {
|
|
||||||
upstream.add(utf8.encode(headers));
|
|
||||||
if (remainder.isNotEmpty) upstream.add(remainder);
|
|
||||||
|
|
||||||
final bodyLeft = contentLength - remainder.length;
|
|
||||||
if (bodyLeft > 0) {
|
|
||||||
sub.onData(upstream.add);
|
|
||||||
sub.onDone(upstream.destroy);
|
|
||||||
sub.onError((_) { upstream.destroy(); client.destroy(); });
|
|
||||||
sub.resume();
|
|
||||||
} else {
|
|
||||||
sub.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
final done = Completer<void>();
|
|
||||||
upstream.listen(
|
|
||||||
client.add,
|
|
||||||
// flush() drains the write buffer before closing; destroy() would drop it.
|
|
||||||
onDone: () { client.flush().whenComplete(client.destroy).whenComplete(() { if (!done.isCompleted) done.complete(); }); },
|
|
||||||
onError: (_) { upstream.destroy(); client.destroy(); if (!done.isCompleted) done.complete(); },
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
await done.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
|
|
||||||
sub.cancel();
|
|
||||||
client.add(utf8.encode(
|
|
||||||
'HTTP/1.1 $code $reason\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
|
|
||||||
));
|
|
||||||
client.flush().then((_) => client.destroy()).catchError((_) => client.destroy());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parses "host:port" strings (e.g. CONNECT target). Uses [defaultPort] when no port present.
|
|
||||||
({String host, int port}) _parseHostPort(String target, {int defaultPort = 443}) {
|
|
||||||
final colon = target.lastIndexOf(':');
|
|
||||||
if (colon < 0) return (host: target, port: defaultPort);
|
|
||||||
return (
|
|
||||||
host: target.substring(0, colon),
|
|
||||||
port: int.tryParse(target.substring(colon + 1)) ?? defaultPort,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converts a proxy-style absolute URI to a relative path+query string.
|
|
||||||
String _relativePath(Uri uri) {
|
|
||||||
final base = uri.path.isEmpty ? '/' : uri.path;
|
|
||||||
return uri.hasQuery ? '$base?${uri.query}' : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _log(String msg) => onLog?.call('[FilterProxy] $msg');
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
|
||||||
import 'enrollment_db.dart';
|
|
||||||
import 'filter_proxy.dart';
|
|
||||||
import 'proxy_service.dart';
|
|
||||||
import 'session_manager.dart';
|
|
||||||
|
|
||||||
Future<void> main() async {
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
await ProxyService.initialize();
|
|
||||||
runApp(const KPhoneApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
class KPhoneApp extends StatelessWidget {
|
|
||||||
const KPhoneApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'k_phone',
|
|
||||||
theme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
home: const ProxyStatusScreen(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProxyStatusScreen extends StatefulWidget {
|
|
||||||
const ProxyStatusScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ProxyStatusScreen> createState() => _ProxyStatusScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
|
|
||||||
bool _serviceRunning = false;
|
|
||||||
bool _cardAttached = false;
|
|
||||||
String _statusMessage = 'Stopped';
|
|
||||||
final List<String> _log = [];
|
|
||||||
// Debug-only test state — stripped in release builds via kDebugMode.
|
|
||||||
bool _testRunning = false;
|
|
||||||
final _db = EnrollmentDb();
|
|
||||||
final _session = SessionManager();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_subscribeToService();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _subscribeToService() {
|
|
||||||
final service = FlutterBackgroundService();
|
|
||||||
|
|
||||||
// Sync initial running state
|
|
||||||
service.isRunning().then((running) {
|
|
||||||
if (mounted) setState(() => _serviceRunning = running);
|
|
||||||
});
|
|
||||||
|
|
||||||
service.on('status').listen((event) {
|
|
||||||
if (event == null) return;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_serviceRunning = event['running'] as bool? ?? false;
|
|
||||||
_cardAttached = event['cardAttached'] as bool? ?? false;
|
|
||||||
_statusMessage = event['message'] as String? ?? '';
|
|
||||||
final log = event['log'] as String?;
|
|
||||||
if (log != null) {
|
|
||||||
_log.insert(0, log);
|
|
||||||
if (_log.length > 200) _log.removeLast();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addLog(String msg) {
|
|
||||||
setState(() {
|
|
||||||
_log.insert(0, msg);
|
|
||||||
if (_log.length > 200) _log.removeLast();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runRegistrationLoginTest() async {
|
|
||||||
if (_testRunning) return;
|
|
||||||
setState(() => _testRunning = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// --- Registrering ---
|
|
||||||
_addLog('[REGISTRATION] Starter: enrolling "testbruger"');
|
|
||||||
try {
|
|
||||||
final enrollment = await _db.register(
|
|
||||||
username: 'testbruger',
|
|
||||||
displayName: 'Test Bruger',
|
|
||||||
);
|
|
||||||
_addLog('[REGISTRATION] OK: bruger="${enrollment.username}" '
|
|
||||||
'displayName="${enrollment.displayName}"');
|
|
||||||
} on StateError {
|
|
||||||
_addLog('[REGISTRATION] INFO: "testbruger" allerede enrollet — sletter og prøver igen');
|
|
||||||
await _db.delete('testbruger');
|
|
||||||
final enrollment = await _db.register(
|
|
||||||
username: 'testbruger',
|
|
||||||
displayName: 'Test Bruger',
|
|
||||||
);
|
|
||||||
_addLog('[REGISTRATION] OK: bruger="${enrollment.username}" genregistreret');
|
|
||||||
}
|
|
||||||
|
|
||||||
final list = await _db.list();
|
|
||||||
_addLog('[REGISTRATION] Enrollment-liste: ${list.map((e) => e.username).join(', ')}');
|
|
||||||
|
|
||||||
_addLog('[REGISTRATION] Test duplikat afvisning...');
|
|
||||||
try {
|
|
||||||
await _db.register(username: 'testbruger');
|
|
||||||
_addLog('[REGISTRATION] FEJL: duplikat burde være afvist!');
|
|
||||||
} on StateError catch (e) {
|
|
||||||
_addLog('[REGISTRATION] OK: duplikat afvist — ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Login ---
|
|
||||||
_addLog('[LOGIN] Udsteder session for "testbruger"...');
|
|
||||||
final token = _session.issue('testbruger');
|
|
||||||
_addLog('[LOGIN] Token: ${token.substring(0, 8)}... (${token.length} tegn)');
|
|
||||||
|
|
||||||
final valid = _session.isValid(token);
|
|
||||||
_addLog('[LOGIN] Session gyldig: $valid');
|
|
||||||
|
|
||||||
final entry = _session.getSession(token);
|
|
||||||
_addLog('[LOGIN] Udløber: ${entry?.expires.toLocal().toString().substring(0, 19)}');
|
|
||||||
|
|
||||||
_addLog('[LOGIN] Tilbagekalder session...');
|
|
||||||
_session.revoke(token);
|
|
||||||
_addLog('[LOGIN] Session gyldig efter revoke: ${_session.isValid(token)}');
|
|
||||||
|
|
||||||
_addLog('[LOGIN] FÆRDIG — alle flows OK');
|
|
||||||
} catch (e) {
|
|
||||||
_addLog('[FEJL] $e');
|
|
||||||
} finally {
|
|
||||||
setState(() => _testRunning = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleService() async {
|
|
||||||
final service = FlutterBackgroundService();
|
|
||||||
final running = await service.isRunning();
|
|
||||||
if (running) {
|
|
||||||
service.invoke('stop');
|
|
||||||
setState(() {
|
|
||||||
_serviceRunning = false;
|
|
||||||
_cardAttached = false;
|
|
||||||
_statusMessage = 'Stopped';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await service.startService();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('k_phone — ChromeCard proxy'),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_StatusTile(
|
|
||||||
label: 'Filter proxy (Comp 1)',
|
|
||||||
ok: _serviceRunning,
|
|
||||||
value: _serviceRunning ? 'Running on :$kFilterProxyPort' : 'Stopped',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_StatusTile(
|
|
||||||
label: 'Auth proxy (Comp 2)',
|
|
||||||
ok: _serviceRunning,
|
|
||||||
value: _serviceRunning ? 'Running on :$kProxyPort' : 'Stopped',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_StatusTile(
|
|
||||||
label: 'ChromeCard (USB)',
|
|
||||||
ok: _cardAttached,
|
|
||||||
value: _cardAttached ? 'Attached' : 'Not detected',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_statusMessage,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _toggleService,
|
|
||||||
child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'),
|
|
||||||
),
|
|
||||||
if (kDebugMode) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
FilledButton.tonal(
|
|
||||||
onPressed: _testRunning ? null : _runRegistrationLoginTest,
|
|
||||||
child: _testRunning
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Kør registrering & login'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
|
||||||
const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _log.length,
|
|
||||||
itemBuilder: (_, i) => Text(
|
|
||||||
_log[i],
|
|
||||||
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusTile extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final bool ok;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const _StatusTile({
|
|
||||||
required this.label,
|
|
||||||
required this.ok,
|
|
||||||
required this.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
ok ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
||||||
color: ok ? Colors.green : Colors.grey,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('$label: ', style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
Text(value),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>''';
|
|
||||||
|
|
@ -1,655 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
|
|
||||||
import 'ctaphid_channel.dart';
|
|
||||||
import 'enrollment_db.dart';
|
|
||||||
import 'filter_proxy.dart';
|
|
||||||
import 'fido2_ops.dart';
|
|
||||||
import 'portal_html.dart';
|
|
||||||
import 'session_manager.dart';
|
|
||||||
|
|
||||||
const int kProxyPort = 8771;
|
|
||||||
const String kNotificationChannelId = 'kphone_proxy';
|
|
||||||
const String kNotificationChannelName = 'k_phone proxy service';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Top-level entry points — required by flutter_background_service isolate
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
Future<bool> onIosBackground(ServiceInstance service) async => true;
|
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
void onServiceStart(ServiceInstance service) async {
|
|
||||||
final proxy = _ProxyServer(service);
|
|
||||||
service.on('stop').listen((_) async {
|
|
||||||
await proxy.stop();
|
|
||||||
service.stopSelf();
|
|
||||||
});
|
|
||||||
await proxy.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Service bootstrap (called from main())
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
class ProxyService {
|
|
||||||
static Future<void> initialize() async {
|
|
||||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
|
||||||
const channel = AndroidNotificationChannel(
|
|
||||||
kNotificationChannelId,
|
|
||||||
kNotificationChannelName,
|
|
||||||
description: 'Shows when the ChromeCard proxy is running',
|
|
||||||
importance: Importance.low,
|
|
||||||
);
|
|
||||||
await flutterLocalNotificationsPlugin
|
|
||||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
|
||||||
?.createNotificationChannel(channel);
|
|
||||||
|
|
||||||
final service = FlutterBackgroundService();
|
|
||||||
await service.configure(
|
|
||||||
androidConfiguration: AndroidConfiguration(
|
|
||||||
onStart: onServiceStart,
|
|
||||||
autoStart: true,
|
|
||||||
isForegroundMode: true,
|
|
||||||
notificationChannelId: kNotificationChannelId,
|
|
||||||
initialNotificationTitle: 'k_phone proxy',
|
|
||||||
initialNotificationContent: 'Starting…',
|
|
||||||
foregroundServiceNotificationId: 1,
|
|
||||||
),
|
|
||||||
iosConfiguration: IosConfiguration(
|
|
||||||
autoStart: false,
|
|
||||||
onForeground: onServiceStart,
|
|
||||||
onBackground: onIosBackground,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Proxy server (runs inside the background service isolate)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _ProxyServer {
|
|
||||||
final ServiceInstance _service;
|
|
||||||
HttpServer? _server;
|
|
||||||
final FilterProxy _filterProxy = FilterProxy();
|
|
||||||
final SessionManager _sessions = SessionManager();
|
|
||||||
final EnrollmentDb _db = EnrollmentDb();
|
|
||||||
int? _cardCid;
|
|
||||||
bool _cardAttached = false;
|
|
||||||
bool _running = false;
|
|
||||||
|
|
||||||
_ProxyServer(this._service);
|
|
||||||
|
|
||||||
void _emit(String msg) {
|
|
||||||
_service.invoke('status', {
|
|
||||||
'running': _running,
|
|
||||||
'cardAttached': _cardAttached,
|
|
||||||
'message': msg,
|
|
||||||
'log': '[${DateTime.now().toIso8601String()}] $msg',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> start() async {
|
|
||||||
_running = true;
|
|
||||||
_emit('Starting proxy on :$kProxyPort');
|
|
||||||
|
|
||||||
_filterProxy.onLog = _emit;
|
|
||||||
try {
|
|
||||||
await _filterProxy.seedDefaultGatedHosts();
|
|
||||||
await _filterProxy.loadGatedHosts();
|
|
||||||
await _filterProxy.start();
|
|
||||||
} catch (e) {
|
|
||||||
_emit('Filter proxy failed to start: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card detection and DB loading are independent — run in parallel.
|
|
||||||
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
|
|
||||||
|
|
||||||
_emit('No TLS certs found — running plain HTTP (dev mode)');
|
|
||||||
try {
|
|
||||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
|
|
||||||
_emit('Listening on :$kProxyPort');
|
|
||||||
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
|
|
||||||
} catch (e) {
|
|
||||||
_emit('FATAL: Could not bind :$kProxyPort — $e');
|
|
||||||
_running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stop() async {
|
|
||||||
_running = false;
|
|
||||||
try {
|
|
||||||
await _filterProxy.stop();
|
|
||||||
await _server?.close(force: true);
|
|
||||||
await closeCard();
|
|
||||||
} finally {
|
|
||||||
_emit('Stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Request dispatch
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleRequest(HttpRequest req) async {
|
|
||||||
final path = req.uri.path;
|
|
||||||
_emit('${req.method} $path');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (req.method == 'GET') {
|
|
||||||
switch (path) {
|
|
||||||
case '/':
|
|
||||||
await _serveHtmlBytes(req, kPortalHtmlBytes);
|
|
||||||
case '/enroll':
|
|
||||||
await _serveHtmlBytes(req, kEnrollHtmlBytes);
|
|
||||||
case '/health':
|
|
||||||
await _handleHealth(req);
|
|
||||||
case '/enroll/list':
|
|
||||||
await _handleEnrollList(req);
|
|
||||||
default:
|
|
||||||
if (path.startsWith('/enroll/status')) {
|
|
||||||
await _handleEnrollStatus(req);
|
|
||||||
} else {
|
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (req.method == 'POST') {
|
|
||||||
switch (path) {
|
|
||||||
case '/enroll/register':
|
|
||||||
await _handleEnrollRegister(req);
|
|
||||||
case '/enroll/update':
|
|
||||||
await _handleEnrollUpdate(req);
|
|
||||||
case '/enroll/delete':
|
|
||||||
await _handleEnrollDelete(req);
|
|
||||||
case '/session/login':
|
|
||||||
await _handleSessionLogin(req);
|
|
||||||
case '/session/status':
|
|
||||||
await _handleSessionStatus(req);
|
|
||||||
case '/session/logout':
|
|
||||||
await _handleSessionLogout(req);
|
|
||||||
case '/auth/get-token':
|
|
||||||
await _handleAuthGetToken(req);
|
|
||||||
default:
|
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
|
||||||
}
|
|
||||||
} else if (req.method == 'CONNECT') {
|
|
||||||
await _handleConnect(req);
|
|
||||||
} else {
|
|
||||||
await _send(req.response, 405, {'ok': false, 'error': 'method not allowed'});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_emit('Error handling $path: $e');
|
|
||||||
try {
|
|
||||||
await _send(req.response, 500, {'ok': false, 'error': 'internal error'});
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Enrollment endpoints
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleEnrollRegister(HttpRequest req) async {
|
|
||||||
final body = await _readJson(req);
|
|
||||||
if (body == null) return;
|
|
||||||
|
|
||||||
final r = await _parseUsernameAndDisplay(req, body);
|
|
||||||
if (r == null) return;
|
|
||||||
final (canonical, pretty) = r;
|
|
||||||
|
|
||||||
await _ensureCardOpen();
|
|
||||||
MakeCredentialResult? credential;
|
|
||||||
if (_cardAttached && _cardCid != null) {
|
|
||||||
try {
|
|
||||||
credential = await makeCredential(_cardCid!, canonical, displayName: pretty);
|
|
||||||
} catch (e) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card registration failed: $e'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final enrollment = await _db.register(
|
|
||||||
username: canonical,
|
|
||||||
displayName: pretty,
|
|
||||||
userIdB64: credential?.userIdB64,
|
|
||||||
credentialDataB64: credential?.credentialDataB64,
|
|
||||||
);
|
|
||||||
final users = await _db.list();
|
|
||||||
await _send(req.response, 200, {
|
|
||||||
..._enrollmentPayload(enrollment, created: true),
|
|
||||||
'users': users.map(_userSummary).toList(),
|
|
||||||
});
|
|
||||||
} on StateError {
|
|
||||||
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleEnrollUpdate(HttpRequest req) async {
|
|
||||||
final body = await _readJson(req);
|
|
||||||
if (body == null) return;
|
|
||||||
|
|
||||||
final r = await _parseUsernameAndDisplay(req, body);
|
|
||||||
if (r == null) return;
|
|
||||||
final (canonical, pretty) = r;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final enrollment = await _db.update(username: canonical, displayName: pretty);
|
|
||||||
await _send(req.response, 200, _enrollmentPayload(enrollment));
|
|
||||||
} on StateError {
|
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleEnrollDelete(HttpRequest req) async {
|
|
||||||
final body = await _readJson(req);
|
|
||||||
if (body == null) return;
|
|
||||||
|
|
||||||
final canonical = await _parseUsername(req, body);
|
|
||||||
if (canonical == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final enrollment = await _db.delete(canonical);
|
|
||||||
_sessions.revokeAll(canonical);
|
|
||||||
final users = await _db.list();
|
|
||||||
await _send(req.response, 200, {
|
|
||||||
'ok': true,
|
|
||||||
'username': enrollment.username,
|
|
||||||
'deleted': true,
|
|
||||||
'users': users.map(_userSummary).toList(),
|
|
||||||
});
|
|
||||||
} on StateError {
|
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleEnrollStatus(HttpRequest req) async {
|
|
||||||
final username = req.uri.queryParameters['username'] ?? '';
|
|
||||||
if (username.isEmpty) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': 'username query required'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final enrollment = await _db.get(username);
|
|
||||||
if (enrollment == null) {
|
|
||||||
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled', 'username': username});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _send(req.response, 200, _enrollmentPayload(enrollment));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleEnrollList(HttpRequest req) async {
|
|
||||||
final users = await _db.list();
|
|
||||||
await _send(req.response, 200, {
|
|
||||||
'ok': true,
|
|
||||||
'users': users.map(_userSummary).toList(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Session endpoints
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleSessionLogin(HttpRequest req) async {
|
|
||||||
final body = await _readJson(req);
|
|
||||||
if (body == null) return;
|
|
||||||
|
|
||||||
final canonical = await _parseUsername(req, body);
|
|
||||||
if (canonical == null) return;
|
|
||||||
|
|
||||||
final enrollment = await _db.get(canonical);
|
|
||||||
if (enrollment == null) {
|
|
||||||
await _send(req.response, 403, {'ok': false, 'error': 'user not enrolled', 'username': canonical});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _ensureCardOpen();
|
|
||||||
if (enrollment.hasCredential && _cardCid != null) {
|
|
||||||
// FIDO2-direct: getAssertion + local verify.
|
|
||||||
// Random challenge is intentional here: session login only proves the
|
|
||||||
// user CAN authenticate (user-presence check). The resulting session token
|
|
||||||
// is for portal access. Per-request resource binding (challenge = SHA256
|
|
||||||
// of url|method|nonce) happens in _handleAuthGetToken, not here.
|
|
||||||
GetAssertionResult assertionResult;
|
|
||||||
try {
|
|
||||||
assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!);
|
|
||||||
} catch (e) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': e.toString()});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final ok = verifyAssertion(
|
|
||||||
enrollment.credentialDataB64!,
|
|
||||||
assertionResult.authData,
|
|
||||||
assertionResult.signature,
|
|
||||||
assertionResult.clientDataHash,
|
|
||||||
);
|
|
||||||
if (!ok) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'signature verification failed'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (!_cardAttached) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// else: probe-mode enrollment (no FIDO2 credential stored) and card is
|
|
||||||
// physically attached — card presence is the only factor, accept the login.
|
|
||||||
|
|
||||||
final token = _sessions.issue(canonical);
|
|
||||||
final session = _sessions.getSession(token)!;
|
|
||||||
final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final authMode = enrollment.hasCredential ? 'fido2_assertion' : 'card_presence_probe';
|
|
||||||
|
|
||||||
await _send(req.response, 200, {
|
|
||||||
'ok': true,
|
|
||||||
'username': canonical,
|
|
||||||
'session_token': token,
|
|
||||||
'expires_at': expiresAt,
|
|
||||||
'ttl_seconds': SessionManager.ttlSeconds,
|
|
||||||
'auth_mode': authMode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSessionStatus(HttpRequest req) async {
|
|
||||||
await _drainBody(req);
|
|
||||||
final token = _extractBearerToken(req);
|
|
||||||
if (token == null) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final session = _sessions.getSession(token);
|
|
||||||
if (session == null) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final secondsRemaining = session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999);
|
|
||||||
await _send(req.response, 200, {
|
|
||||||
'ok': true,
|
|
||||||
'username': session.username,
|
|
||||||
'expires_at': expiresAt,
|
|
||||||
'seconds_remaining': secondsRemaining,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSessionLogout(HttpRequest req) async {
|
|
||||||
await _drainBody(req);
|
|
||||||
final token = _extractBearerToken(req);
|
|
||||||
if (token == null) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final wasValid = _sessions.isValid(token);
|
|
||||||
_sessions.revoke(token);
|
|
||||||
await _send(req.response, 200, {'ok': true, 'invalidated': wasValid});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// CONNECT tunnel (gated HTTPS)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleConnect(HttpRequest req) async {
|
|
||||||
if (!_sessions.hasAnyActiveSession()) {
|
|
||||||
await _send(req.response, 407, {'ok': false, 'error': 'authentication required'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract host:port from the Host header (CONNECT target is "host:port")
|
|
||||||
final hostHeader = req.headers.value('host') ?? '';
|
|
||||||
final lastColon = hostHeader.lastIndexOf(':');
|
|
||||||
String connectHost;
|
|
||||||
int connectPort;
|
|
||||||
if (lastColon > 0) {
|
|
||||||
connectHost = hostHeader.substring(0, lastColon);
|
|
||||||
connectPort = int.tryParse(hostHeader.substring(lastColon + 1)) ?? 443;
|
|
||||||
} else {
|
|
||||||
connectHost = hostHeader;
|
|
||||||
connectPort = 443;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectHost.isEmpty) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': 'missing CONNECT target'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Socket upstream;
|
|
||||||
try {
|
|
||||||
upstream = await Socket.connect(connectHost, connectPort)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
} catch (e) {
|
|
||||||
_emit('CONNECT $connectHost:$connectPort failed: $e');
|
|
||||||
await _send(req.response, 502, {'ok': false, 'error': 'cannot connect to upstream'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final clientSocket = await req.response.detachSocket(writeHeaders: false);
|
|
||||||
clientSocket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
|
|
||||||
await clientSocket.flush();
|
|
||||||
|
|
||||||
clientSocket.listen(
|
|
||||||
upstream.add,
|
|
||||||
onDone: upstream.destroy,
|
|
||||||
onError: (_) => upstream.destroy(),
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
upstream.listen(
|
|
||||||
clientSocket.add,
|
|
||||||
onDone: clientSocket.destroy,
|
|
||||||
onError: (_) => clientSocket.destroy(),
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Auth token endpoint (v2 architecture — domain-level token binding)
|
|
||||||
//
|
|
||||||
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
|
|
||||||
// {host, nonce} for each gated request. A fresh FIDO2 assertion is
|
|
||||||
// produced with challenge = SHA256(host|nonce). The self-contained
|
|
||||||
// assertion bundle is returned as a base64url Bearer token the server can
|
|
||||||
// verify without calling back to this service.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleAuthGetToken(HttpRequest req) async {
|
|
||||||
final body = await _readJson(req);
|
|
||||||
if (body == null) return;
|
|
||||||
|
|
||||||
final host = body['host'] as String? ?? '';
|
|
||||||
final nonce = body['nonce'] as String? ?? '';
|
|
||||||
|
|
||||||
if (host.isEmpty || nonce.isEmpty) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': 'host, nonce required'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _ensureCardOpen();
|
|
||||||
if (!_cardAttached || _cardCid == null) {
|
|
||||||
await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find first enrolled user with a FIDO2 credential.
|
|
||||||
final users = await _db.list();
|
|
||||||
Enrollment? enrolled;
|
|
||||||
for (final u in users) {
|
|
||||||
if (u.hasCredential) { enrolled = u; break; }
|
|
||||||
}
|
|
||||||
if (enrolled == null) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'no enrolled credential'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Challenge = SHA256(host | "|" | nonce)
|
|
||||||
final challenge = Uint8List.fromList(
|
|
||||||
sha256.convert(utf8.encode('$host|$nonce')).bytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
GetAssertionResult assertionResult;
|
|
||||||
try {
|
|
||||||
assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge);
|
|
||||||
} catch (e) {
|
|
||||||
await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-contained bundle the server can verify without calling back to the phone.
|
|
||||||
final bundleJson = jsonEncode({
|
|
||||||
'v': 1,
|
|
||||||
'host': host,
|
|
||||||
'nonce': nonce,
|
|
||||||
'authData': base64Url.encode(assertionResult.authData).replaceAll('=', ''),
|
|
||||||
'sig': base64Url.encode(assertionResult.signature).replaceAll('=', ''),
|
|
||||||
'cdj': base64Url.encode(utf8.encode(assertionResult.clientDataJson)).replaceAll('=', ''),
|
|
||||||
'cred': enrolled.credentialDataB64,
|
|
||||||
'user': enrolled.username,
|
|
||||||
});
|
|
||||||
final token = base64Url.encode(utf8.encode(bundleJson)).replaceAll('=', '');
|
|
||||||
|
|
||||||
await _send(req.response, 200, {'ok': true, 'token': token, 'username': enrolled.username});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Health + HTML
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _handleHealth(HttpRequest req) async {
|
|
||||||
await _send(req.response, 200, {
|
|
||||||
'ok': true,
|
|
||||||
'service': 'k_phone',
|
|
||||||
'card': _cardAttached,
|
|
||||||
'active_sessions': 0,
|
|
||||||
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _serveHtmlBytes(HttpRequest req, List<int> bytes) async {
|
|
||||||
req.response.statusCode = 200;
|
|
||||||
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
|
||||||
req.response.headers.contentLength = bytes.length;
|
|
||||||
req.response.add(bytes);
|
|
||||||
await req.response.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Card management
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<void> _tryOpenCard() async {
|
|
||||||
try {
|
|
||||||
_cardAttached = await openCard();
|
|
||||||
if (!_cardAttached) {
|
|
||||||
_emit('No USB card — trying emulator bridge on 10.0.2.2:8772');
|
|
||||||
useEmulator(host: '10.0.2.2');
|
|
||||||
_cardAttached = await openCard();
|
|
||||||
}
|
|
||||||
if (_cardAttached) {
|
|
||||||
_cardCid = await ctaphidInit();
|
|
||||||
_emit('Card open, CID=0x${_cardCid!.toRadixString(16)}');
|
|
||||||
} else {
|
|
||||||
_emit('No card and no emulator bridge — card operations unavailable');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_emit('Card open failed: $e');
|
|
||||||
_cardAttached = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-open the card if the socket has been closed since startup.
|
|
||||||
/// Called before card operations so a bridge restart doesn't require an app restart.
|
|
||||||
Future<void> _ensureCardOpen() async {
|
|
||||||
if (!_cardAttached || _cardCid == null || !(await isCardAttached())) {
|
|
||||||
_emit('Card not open — reconnecting...');
|
|
||||||
_cardAttached = false;
|
|
||||||
_cardCid = null;
|
|
||||||
await _tryOpenCard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
String? _extractBearerToken(HttpRequest req) {
|
|
||||||
final auth = req.headers.value('authorization') ?? '';
|
|
||||||
if (!auth.startsWith('Bearer ')) return null;
|
|
||||||
final token = auth.substring(7).trim();
|
|
||||||
return token.isEmpty ? null : token;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> _readJson(HttpRequest req) async {
|
|
||||||
try {
|
|
||||||
final bb = BytesBuilder(copy: false);
|
|
||||||
await for (final chunk in req) {
|
|
||||||
bb.add(chunk);
|
|
||||||
}
|
|
||||||
final bytes = bb.takeBytes();
|
|
||||||
if (bytes.isEmpty) return {};
|
|
||||||
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': 'invalid json'});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _drainBody(HttpRequest req) async {
|
|
||||||
await req.fold<void>(null, (_, __) {});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _send(HttpResponse res, int status, Map<String, dynamic> body) async {
|
|
||||||
final encoded = utf8.encode(jsonEncode(body));
|
|
||||||
res.statusCode = status;
|
|
||||||
res.headers.contentType = ContentType.json;
|
|
||||||
res.headers.contentLength = encoded.length;
|
|
||||||
res.add(encoded);
|
|
||||||
await res.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact user entry for embedded lists in register/delete/list responses.
|
|
||||||
Map<String, dynamic> _userSummary(Enrollment e) => {
|
|
||||||
'username': e.username,
|
|
||||||
'display_name': e.displayName,
|
|
||||||
'has_credential': e.hasCredential,
|
|
||||||
};
|
|
||||||
|
|
||||||
Map<String, dynamic> _enrollmentPayload(Enrollment e, {bool? created}) {
|
|
||||||
final m = <String, dynamic>{
|
|
||||||
'ok': true,
|
|
||||||
'username': e.username,
|
|
||||||
'display_name': e.displayName,
|
|
||||||
'created_at': e.createdAt,
|
|
||||||
'updated_at': e.updatedAt,
|
|
||||||
'has_credential': e.hasCredential,
|
|
||||||
};
|
|
||||||
if (created != null) m['created'] = created;
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _parseUsername(HttpRequest req, Map<String, dynamic> body) async {
|
|
||||||
try {
|
|
||||||
return normalizeUsername(body['username'] as String? ?? '');
|
|
||||||
} on ArgumentError catch (e) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<(String, String?)?> _parseUsernameAndDisplay(
|
|
||||||
HttpRequest req, Map<String, dynamic> body) async {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
normalizeUsername(body['username'] as String? ?? ''),
|
|
||||||
normalizeDisplayName(body['display_name'] as String?),
|
|
||||||
);
|
|
||||||
} on ArgumentError catch (e) {
|
|
||||||
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
// Session token management — mirrors k_proxy_app.py session logic.
|
|
||||||
// Tokens are 32-byte hex strings; stored in memory only.
|
|
||||||
|
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
class SessionEntry {
|
|
||||||
final String username;
|
|
||||||
final DateTime expires;
|
|
||||||
SessionEntry({required this.username, required this.expires});
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionManager {
|
|
||||||
final Map<String, SessionEntry> _sessions = {};
|
|
||||||
static const int ttlSeconds = 300;
|
|
||||||
static const Duration _ttl = Duration(seconds: ttlSeconds);
|
|
||||||
|
|
||||||
/// Issue a new session token for [username].
|
|
||||||
/// _purgeExpired is only called here, not on every lookup, so tokens accumulate
|
|
||||||
/// until the next login — acceptable for the low-traffic embedded use case.
|
|
||||||
String issue(String username) {
|
|
||||||
_purgeExpired();
|
|
||||||
final token = _randomToken();
|
|
||||||
_sessions[token] = SessionEntry(
|
|
||||||
username: username,
|
|
||||||
expires: DateTime.now().add(_ttl),
|
|
||||||
);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the session entry for [token], or null if missing/expired.
|
|
||||||
SessionEntry? getSession(String token) {
|
|
||||||
final s = _sessions[token];
|
|
||||||
if (s == null) return null;
|
|
||||||
if (DateTime.now().isAfter(s.expires)) {
|
|
||||||
_sessions.remove(token);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if [token] is known and not expired.
|
|
||||||
bool isValid(String token) => getSession(token) != null;
|
|
||||||
|
|
||||||
/// Revoke [token] immediately.
|
|
||||||
void revoke(String token) => _sessions.remove(token);
|
|
||||||
|
|
||||||
/// Returns true if at least one session is currently active (not expired).
|
|
||||||
/// Used by gated-proxy forwarding: personal-device model means any live
|
|
||||||
/// login counts as authorisation for the proxied request.
|
|
||||||
bool hasAnyActiveSession() {
|
|
||||||
_purgeExpired();
|
|
||||||
return _sessions.isNotEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the token and session entry of any currently active session.
|
|
||||||
/// Used by /auth/get-token to return an existing token without card interaction.
|
|
||||||
(String token, SessionEntry session)? anyActive() {
|
|
||||||
_purgeExpired();
|
|
||||||
if (_sessions.isEmpty) return null;
|
|
||||||
final e = _sessions.entries.first;
|
|
||||||
return (e.key, e.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revoke all sessions for [username].
|
|
||||||
void revokeAll(String username) {
|
|
||||||
_sessions.removeWhere((_, s) => s.username == username);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _purgeExpired() {
|
|
||||||
final now = DateTime.now();
|
|
||||||
_sessions.removeWhere((_, s) => now.isAfter(s.expires));
|
|
||||||
}
|
|
||||||
|
|
||||||
String _randomToken() {
|
|
||||||
final rng = Random.secure();
|
|
||||||
final bytes = List.generate(32, (_) => rng.nextInt(256));
|
|
||||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,620 +0,0 @@
|
||||||
# Generated by pub
|
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
|
||||||
packages:
|
|
||||||
args:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: args
|
|
||||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.7.0"
|
|
||||||
async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: async
|
|
||||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.13.1"
|
|
||||||
boolean_selector:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: boolean_selector
|
|
||||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.2"
|
|
||||||
cbor:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: cbor
|
|
||||||
sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.1"
|
|
||||||
characters:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: characters
|
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.1"
|
|
||||||
clock:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: clock
|
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.2"
|
|
||||||
code_assets:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: code_assets
|
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
collection:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: collection
|
|
||||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.19.1"
|
|
||||||
convert:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: convert
|
|
||||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.2"
|
|
||||||
crypto:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: crypto
|
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.7"
|
|
||||||
dbus:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dbus
|
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.12"
|
|
||||||
fake_async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: fake_async
|
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.3"
|
|
||||||
ffi:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: ffi
|
|
||||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
file:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: file
|
|
||||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.1"
|
|
||||||
fixnum:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: fixnum
|
|
||||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
flutter:
|
|
||||||
dependency: "direct main"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_background_service:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_background_service
|
|
||||||
sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.1.0"
|
|
||||||
flutter_background_service_android:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_background_service_android
|
|
||||||
sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.3.1"
|
|
||||||
flutter_background_service_ios:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_background_service_ios
|
|
||||||
sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.0.3"
|
|
||||||
flutter_background_service_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_background_service_platform_interface
|
|
||||||
sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.1.2"
|
|
||||||
flutter_driver:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_lints:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: flutter_lints
|
|
||||||
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.2"
|
|
||||||
flutter_local_notifications:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications
|
|
||||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "18.0.1"
|
|
||||||
flutter_local_notifications_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_linux
|
|
||||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.0.0"
|
|
||||||
flutter_local_notifications_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_local_notifications_platform_interface
|
|
||||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "8.0.0"
|
|
||||||
flutter_test:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
fuchsia_remote_debug_protocol:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
hex:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: hex
|
|
||||||
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.0"
|
|
||||||
hooks:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: hooks
|
|
||||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.3"
|
|
||||||
http:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http
|
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.6.0"
|
|
||||||
http_parser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http_parser
|
|
||||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.2"
|
|
||||||
integration_test:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
jni:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: jni
|
|
||||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
jni_flutter:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: jni_flutter
|
|
||||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
js:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: js
|
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.2"
|
|
||||||
leak_tracker:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker
|
|
||||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "11.0.2"
|
|
||||||
leak_tracker_flutter_testing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker_flutter_testing
|
|
||||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.10"
|
|
||||||
leak_tracker_testing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker_testing
|
|
||||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.2"
|
|
||||||
lints:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: lints
|
|
||||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.0"
|
|
||||||
logging:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logging
|
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
matcher:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: matcher
|
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.12.19"
|
|
||||||
material_color_utilities:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: material_color_utilities
|
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.13.0"
|
|
||||||
meta:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: meta
|
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.17.0"
|
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.6"
|
|
||||||
objective_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: objective_c
|
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.3.0"
|
|
||||||
package_config:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: package_config
|
|
||||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
path:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path
|
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.9.1"
|
|
||||||
path_provider:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: path_provider
|
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.5"
|
|
||||||
path_provider_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path_provider_android
|
|
||||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.3.1"
|
|
||||||
path_provider_foundation:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path_provider_foundation
|
|
||||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.6.0"
|
|
||||||
path_provider_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path_provider_linux
|
|
||||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.1"
|
|
||||||
path_provider_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path_provider_platform_interface
|
|
||||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.2"
|
|
||||||
path_provider_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path_provider_windows
|
|
||||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.3.0"
|
|
||||||
petitparser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: petitparser
|
|
||||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.2"
|
|
||||||
platform:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: platform
|
|
||||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.6"
|
|
||||||
plugin_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: plugin_platform_interface
|
|
||||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.8"
|
|
||||||
pointycastle:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: pointycastle
|
|
||||||
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.9.1"
|
|
||||||
process:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: process
|
|
||||||
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.0.5"
|
|
||||||
pub_semver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: pub_semver
|
|
||||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
record_use:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: record_use
|
|
||||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.6.0"
|
|
||||||
sky_engine:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
source_span:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: source_span
|
|
||||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.10.2"
|
|
||||||
stack_trace:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stack_trace
|
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.12.1"
|
|
||||||
stream_channel:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stream_channel
|
|
||||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.4"
|
|
||||||
string_scanner:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: string_scanner
|
|
||||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.1"
|
|
||||||
sync_http:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sync_http
|
|
||||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.3.1"
|
|
||||||
term_glyph:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: term_glyph
|
|
||||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.2"
|
|
||||||
test_api:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: test_api
|
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.10"
|
|
||||||
timezone:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: timezone
|
|
||||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.10.1"
|
|
||||||
typed_data:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: typed_data
|
|
||||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.0"
|
|
||||||
uuid:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: uuid
|
|
||||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.5.3"
|
|
||||||
vector_math:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vector_math
|
|
||||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
vm_service:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vm_service
|
|
||||||
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "15.1.0"
|
|
||||||
web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: web
|
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
webdriver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: webdriver
|
|
||||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.0"
|
|
||||||
xdg_directories:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xdg_directories
|
|
||||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.0"
|
|
||||||
xml:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xml
|
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.6.1"
|
|
||||||
yaml:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: yaml
|
|
||||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.3"
|
|
||||||
sdks:
|
|
||||||
dart: ">=3.10.3 <4.0.0"
|
|
||||||
flutter: ">=3.38.4"
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
name: k_phone
|
|
||||||
description: ChromeCard FIDO2 proxy phone app — replaces k_proxy in the auth chain.
|
|
||||||
publish_to: 'none'
|
|
||||||
version: 1.0.0+1
|
|
||||||
|
|
||||||
environment:
|
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
flutter:
|
|
||||||
sdk: flutter
|
|
||||||
flutter_background_service: ^5.0.5
|
|
||||||
flutter_background_service_android: ^6.2.0
|
|
||||||
flutter_local_notifications: ^18.0.0
|
|
||||||
uuid: ^4.4.0
|
|
||||||
cbor: ^6.3.0
|
|
||||||
pointycastle: ^3.7.3
|
|
||||||
path_provider: ^2.1.4
|
|
||||||
crypto: ^3.0.5
|
|
||||||
|
|
||||||
dev_dependencies:
|
|
||||||
flutter_test:
|
|
||||||
sdk: flutter
|
|
||||||
integration_test:
|
|
||||||
sdk: flutter
|
|
||||||
flutter_lints: ^3.0.0
|
|
||||||
|
|
||||||
flutter:
|
|
||||||
uses-material-design: true
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
// Tests for EnrollmentDb — verifies that users are created, listed, and
|
|
||||||
// deleted correctly on the phone.
|
|
||||||
//
|
|
||||||
// All tests use an injected temp directory so path_provider is not needed.
|
|
||||||
//
|
|
||||||
// Run: flutter test test/enrollment_test.dart
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import '../lib/enrollment_db.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late Directory tmp;
|
|
||||||
late EnrollmentDb db;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
tmp = await Directory.systemTemp.createTemp('enrollment_test_');
|
|
||||||
db = EnrollmentDb(baseDir: tmp);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() => tmp.delete(recursive: true));
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Registration
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('register', () {
|
|
||||||
test('creates probe-mode enrollment when no credential data provided', () async {
|
|
||||||
final e = await db.register(username: 'alice', displayName: 'Alice Example');
|
|
||||||
|
|
||||||
expect(e.username, 'alice');
|
|
||||||
expect(e.displayName, 'Alice Example');
|
|
||||||
expect(e.hasCredential, isFalse);
|
|
||||||
expect(e.credentialDataB64, isNull);
|
|
||||||
expect(e.userIdB64, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates FIDO2 enrollment when credential data provided', () async {
|
|
||||||
final e = await db.register(
|
|
||||||
username: 'alice',
|
|
||||||
userIdB64: 'dXNlcklk',
|
|
||||||
credentialDataB64: 'Y3JlZERhdGE=',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(e.hasCredential, isTrue);
|
|
||||||
expect(e.credentialDataB64, 'Y3JlZERhdGE=');
|
|
||||||
expect(e.userIdB64, 'dXNlcklk');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizes username to lowercase and trims whitespace', () async {
|
|
||||||
final e = await db.register(username: ' BOB ');
|
|
||||||
expect(e.username, 'bob');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sets createdAt and updatedAt to current time', () async {
|
|
||||||
final before = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final e = await db.register(username: 'alice');
|
|
||||||
final after = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
||||||
|
|
||||||
expect(e.createdAt, inInclusiveRange(before, after));
|
|
||||||
expect(e.updatedAt, e.createdAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws StateError on duplicate username', () async {
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
await expectLater(
|
|
||||||
db.register(username: 'alice'),
|
|
||||||
throwsA(isA<StateError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('duplicate check is case-insensitive', () async {
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
await expectLater(
|
|
||||||
db.register(username: 'ALICE'),
|
|
||||||
throwsA(isA<StateError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects invalid username', () async {
|
|
||||||
// Two-char usernames are rejected by the regex (1 or 3–32 chars only).
|
|
||||||
await expectLater(
|
|
||||||
db.register(username: 'ab'),
|
|
||||||
throwsA(isA<ArgumentError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects username with special characters', () async {
|
|
||||||
await expectLater(
|
|
||||||
db.register(username: 'alice!'),
|
|
||||||
throwsA(isA<ArgumentError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('list', () {
|
|
||||||
test('returns empty list when no users registered', () async {
|
|
||||||
expect(await db.list(), isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns all registered users sorted alphabetically', () async {
|
|
||||||
await db.register(username: 'charlie');
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
await db.register(username: 'bob');
|
|
||||||
|
|
||||||
final names = (await db.list()).map((e) => e.username).toList();
|
|
||||||
expect(names, ['alice', 'bob', 'charlie']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reflects correct hasCredential for each user', () async {
|
|
||||||
await db.register(username: 'probe');
|
|
||||||
await db.register(
|
|
||||||
username: 'fido',
|
|
||||||
userIdB64: 'dXNlcg==',
|
|
||||||
credentialDataB64: 'Y3JlZA==',
|
|
||||||
);
|
|
||||||
|
|
||||||
final list = await db.list();
|
|
||||||
expect(list.firstWhere((e) => e.username == 'probe').hasCredential, isFalse);
|
|
||||||
expect(list.firstWhere((e) => e.username == 'fido').hasCredential, isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('delete', () {
|
|
||||||
test('removes the user from the list', () async {
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
await db.delete('alice');
|
|
||||||
|
|
||||||
expect(await db.list(), isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns the deleted enrollment', () async {
|
|
||||||
await db.register(username: 'alice', displayName: 'Alice');
|
|
||||||
final deleted = await db.delete('alice');
|
|
||||||
|
|
||||||
expect(deleted.username, 'alice');
|
|
||||||
expect(deleted.displayName, 'Alice');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('only removes the target user, not others', () async {
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
await db.register(username: 'bob');
|
|
||||||
await db.delete('alice');
|
|
||||||
|
|
||||||
final names = (await db.list()).map((e) => e.username).toList();
|
|
||||||
expect(names, ['bob']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws StateError when user does not exist', () async {
|
|
||||||
await expectLater(
|
|
||||||
db.delete('nobody'),
|
|
||||||
throwsA(isA<StateError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Persistence
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('persistence', () {
|
|
||||||
test('enrollments survive across new EnrollmentDb instances', () async {
|
|
||||||
await db.register(username: 'alice', displayName: 'Alice');
|
|
||||||
await db.register(username: 'bob');
|
|
||||||
|
|
||||||
final db2 = EnrollmentDb(baseDir: tmp);
|
|
||||||
final names = (await db2.list()).map((e) => e.username).toList();
|
|
||||||
expect(names, ['alice', 'bob']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('credential data survives reload', () async {
|
|
||||||
await db.register(
|
|
||||||
username: 'alice',
|
|
||||||
userIdB64: 'dXNlcg==',
|
|
||||||
credentialDataB64: 'Y3JlZA==',
|
|
||||||
);
|
|
||||||
|
|
||||||
final db2 = EnrollmentDb(baseDir: tmp);
|
|
||||||
final e = await db2.get('alice');
|
|
||||||
expect(e, isNotNull);
|
|
||||||
expect(e!.hasCredential, isTrue);
|
|
||||||
expect(e.credentialDataB64, 'Y3JlZA==');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deletion persists across new instances', () async {
|
|
||||||
await db.register(username: 'alice');
|
|
||||||
await db.delete('alice');
|
|
||||||
|
|
||||||
final db2 = EnrollmentDb(baseDir: tmp);
|
|
||||||
expect(await db2.list(), isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two concurrent writes both complete without corruption', () async {
|
|
||||||
// Simultaneous register calls must queue, not corrupt the file.
|
|
||||||
await Future.wait([
|
|
||||||
db.register(username: 'alice'),
|
|
||||||
db.register(username: 'bob'),
|
|
||||||
db.register(username: 'charlie'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
final names = (await db.list()).map((e) => e.username).toList();
|
|
||||||
expect(names, containsAll(['alice', 'bob', 'charlie']));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('update', () {
|
|
||||||
test('changes display name', () async {
|
|
||||||
await db.register(username: 'alice', displayName: 'Old Name');
|
|
||||||
await db.update(username: 'alice', displayName: 'New Name');
|
|
||||||
|
|
||||||
final e = await db.get('alice');
|
|
||||||
expect(e!.displayName, 'New Name');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updatedAt advances after update', () async {
|
|
||||||
final e1 = await db.register(username: 'alice');
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
await db.update(username: 'alice', displayName: 'Alice');
|
|
||||||
final e2 = await db.get('alice');
|
|
||||||
|
|
||||||
expect(e2!.updatedAt, greaterThanOrEqualTo(e1.updatedAt));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws StateError when user does not exist', () async {
|
|
||||||
await expectLater(
|
|
||||||
db.update(username: 'nobody'),
|
|
||||||
throwsA(isA<StateError>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
// FIDO2 Dart unit tests.
|
|
||||||
//
|
|
||||||
// Tests 1-2 run without any external dependency.
|
|
||||||
// Tests 3-6 require card_emulator_bridge.py on TCP port 8772:
|
|
||||||
//
|
|
||||||
// uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
|
|
||||||
// tests/card_emulator_bridge.py
|
|
||||||
//
|
|
||||||
// Then run: flutter test test/fido2_test.dart
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:cbor/cbor.dart';
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import '../lib/ctaphid_channel.dart';
|
|
||||||
import '../lib/fido2_ops.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Test 1: CBOR encode/decode round-trip
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
test('CBOR makeCredential request map encodes and decodes correctly', () {
|
|
||||||
final clientDataHash = Uint8List(32)..fillRange(0, 32, 0xAB);
|
|
||||||
|
|
||||||
final requestMap = CborMap({
|
|
||||||
CborSmallInt(1): CborBytes(clientDataHash),
|
|
||||||
CborSmallInt(2): CborMap({
|
|
||||||
CborString('id'): CborString(kRpId),
|
|
||||||
CborString('name'): CborString(kRpName),
|
|
||||||
}),
|
|
||||||
CborSmallInt(4): CborList([
|
|
||||||
CborMap({
|
|
||||||
CborString('type'): CborString('public-key'),
|
|
||||||
CborString('alg'): CborSmallInt(-7),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
CborSmallInt(7): CborMap({
|
|
||||||
CborString('rk'): CborBool(false),
|
|
||||||
CborString('uv'): CborBool(false),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
final encoded = Uint8List.fromList(cbor.encode(requestMap));
|
|
||||||
expect(encoded, isNotEmpty);
|
|
||||||
|
|
||||||
final decoded = cbor.decode(encoded);
|
|
||||||
expect(decoded, isA<CborMap>());
|
|
||||||
final m = decoded as CborMap;
|
|
||||||
final hash = m[CborSmallInt(1)];
|
|
||||||
expect(hash, isA<CborBytes>());
|
|
||||||
expect((hash as CborBytes).bytes, equals(clientDataHash));
|
|
||||||
|
|
||||||
final rp = m[CborSmallInt(2)] as CborMap;
|
|
||||||
expect((rp[CborString('id')] as CborString).toString(), equals(kRpId));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Test 2: clientDataHash is SHA256 of known JSON
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
test('clientDataHash matches SHA256 of known clientDataJSON', () {
|
|
||||||
// Fixed challenge for deterministic test
|
|
||||||
final challenge = Uint8List.fromList(List.generate(32, (i) => i));
|
|
||||||
final challengeB64 = base64Url.encode(challenge).replaceAll('=', '');
|
|
||||||
final clientDataJson =
|
|
||||||
'{"type":"webauthn.create","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}';
|
|
||||||
|
|
||||||
final expected = Uint8List.fromList(sha256.convert(utf8.encode(clientDataJson)).bytes);
|
|
||||||
expect(expected.length, equals(32));
|
|
||||||
expect(expected, isNot(equals(Uint8List(32))));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tests 3-6: require card_emulator_bridge.py on 127.0.0.1:8772
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('emulator bridge', () {
|
|
||||||
late int cid;
|
|
||||||
late String credentialDataB64;
|
|
||||||
|
|
||||||
setUpAll(() async {
|
|
||||||
useEmulator(host: '127.0.0.1', port: 8772);
|
|
||||||
final connected = await openCard();
|
|
||||||
if (!connected) {
|
|
||||||
markTestSkipped('card_emulator_bridge.py not reachable on :8772');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cid = await ctaphidInit();
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDownAll(() async {
|
|
||||||
await closeCard();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 3: makeCredential returns valid AttestedCredentialData
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
test('makeCredential returns non-empty credentialData with valid structure', () async {
|
|
||||||
final result = await makeCredential(cid, 'testuser', displayName: 'Test User');
|
|
||||||
|
|
||||||
expect(result.credentialData, isNotEmpty);
|
|
||||||
expect(result.userId.length, equals(32));
|
|
||||||
|
|
||||||
// AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey
|
|
||||||
final cd = result.credentialData;
|
|
||||||
expect(cd.length, greaterThan(18));
|
|
||||||
final credIdLen = (cd[16] << 8) | cd[17];
|
|
||||||
expect(credIdLen, greaterThan(0));
|
|
||||||
expect(cd.length, greaterThanOrEqualTo(18 + credIdLen + 1)); // at least 1 byte of COSE key
|
|
||||||
|
|
||||||
credentialDataB64 = result.credentialDataB64;
|
|
||||||
expect(credentialDataB64, isNotEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 4: getAssertion returns non-empty authData and signature
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
test('getAssertion returns authData and signature bytes', () async {
|
|
||||||
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
|
|
||||||
|
|
||||||
final result = await getAssertion(cid, credentialDataB64);
|
|
||||||
|
|
||||||
expect(result.authData, isNotEmpty);
|
|
||||||
expect(result.signature, isNotEmpty);
|
|
||||||
expect(result.clientDataHash.length, equals(32));
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 5: verifyAssertion accepts valid signature
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
test('verifyAssertion accepts valid assertion signature', () async {
|
|
||||||
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
|
|
||||||
|
|
||||||
final assertion = await getAssertion(cid, credentialDataB64);
|
|
||||||
|
|
||||||
final ok = verifyAssertion(
|
|
||||||
credentialDataB64,
|
|
||||||
assertion.authData,
|
|
||||||
assertion.signature,
|
|
||||||
assertion.clientDataHash,
|
|
||||||
);
|
|
||||||
expect(ok, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 6: verifyAssertion rejects tampered authData
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
test('verifyAssertion rejects tampered authData', () async {
|
|
||||||
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
|
|
||||||
|
|
||||||
final assertion = await getAssertion(cid, credentialDataB64);
|
|
||||||
|
|
||||||
// Flip one byte in authData (byte 32 = flags byte)
|
|
||||||
final tampered = Uint8List.fromList(assertion.authData);
|
|
||||||
tampered[32] ^= 0xFF;
|
|
||||||
|
|
||||||
final ok = verifyAssertion(
|
|
||||||
credentialDataB64,
|
|
||||||
tampered,
|
|
||||||
assertion.signature,
|
|
||||||
assertion.clientDataHash,
|
|
||||||
);
|
|
||||||
expect(ok, isFalse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,575 +0,0 @@
|
||||||
// Tests for Component 1 (FilterProxy).
|
|
||||||
//
|
|
||||||
// All tests are self-contained: they bind local servers and never hit the
|
|
||||||
// internet. Port 0 lets the OS assign a free port for each server.
|
|
||||||
//
|
|
||||||
// Run: flutter test test/filter_proxy_test.dart
|
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import '../lib/filter_proxy.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const _kTimeout = Duration(seconds: 5);
|
|
||||||
|
|
||||||
// Start an HttpServer that records the first request and replies 200 OK.
|
|
||||||
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
|
|
||||||
final server = await HttpServer.bind('127.0.0.1', 0);
|
|
||||||
final c = Completer<HttpRequest>();
|
|
||||||
server.listen((req) async {
|
|
||||||
await req.drain<void>();
|
|
||||||
req.response
|
|
||||||
..statusCode = 200
|
|
||||||
..headers.set('content-type', 'text/plain')
|
|
||||||
..headers.set('content-length', '2')
|
|
||||||
..headers.set('connection', 'close')
|
|
||||||
..write('OK');
|
|
||||||
await req.response.close();
|
|
||||||
if (!c.isCompleted) c.complete(req);
|
|
||||||
});
|
|
||||||
return (server: server, completer: c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock for Component 2's /auth/get-token endpoint.
|
|
||||||
// Reads and captures the full request body; completes [tokenReq] on the first call.
|
|
||||||
// When [ok] is false, returns 401.
|
|
||||||
Future<({HttpServer server, Completer<({HttpRequest req, String rawBody})> tokenReq})>
|
|
||||||
_mockTokenServer({
|
|
||||||
String token = 'test-bearer-token',
|
|
||||||
bool ok = true,
|
|
||||||
}) async {
|
|
||||||
final server = await HttpServer.bind('127.0.0.1', 0);
|
|
||||||
final c = Completer<({HttpRequest req, String rawBody})>();
|
|
||||||
server.listen((req) async {
|
|
||||||
final bb = BytesBuilder(copy: false);
|
|
||||||
await for (final chunk in req) bb.add(chunk);
|
|
||||||
final rawBody = utf8.decode(bb.takeBytes());
|
|
||||||
if (!c.isCompleted) c.complete((req: req, rawBody: rawBody));
|
|
||||||
final resp = ok
|
|
||||||
? '{"ok":true,"token":"$token","expires_in":300}'
|
|
||||||
: '{"ok":false,"error":"card not available","login_required":true}';
|
|
||||||
req.response
|
|
||||||
..statusCode = ok ? 200 : 401
|
|
||||||
..headers.set('content-type', 'application/json')
|
|
||||||
..headers.set('content-length', '${resp.length}')
|
|
||||||
..headers.set('connection', 'close')
|
|
||||||
..write(resp);
|
|
||||||
await req.response.close();
|
|
||||||
});
|
|
||||||
return (server: server, tokenReq: c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start a raw TCP server that hands back the accepted Socket.
|
|
||||||
Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
|
|
||||||
final server = await ServerSocket.bind('127.0.0.1', 0);
|
|
||||||
final c = Completer<Socket>();
|
|
||||||
server.listen((sock) { if (!c.isCompleted) c.complete(sock); });
|
|
||||||
return (server: server, socket: c.future);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start a mock Component 2 TCP server that speaks the CONNECT handshake:
|
|
||||||
// reads incoming request headers, responds HTTP 200, then completes with
|
|
||||||
// the socket and its paused subscription for bidirectional tunnel testing.
|
|
||||||
// The subscription is paused so the caller can install its own onData
|
|
||||||
// handler before resuming, guaranteeing no data is missed.
|
|
||||||
Future<({
|
|
||||||
ServerSocket server,
|
|
||||||
Future<({Socket socket, StreamSubscription<List<int>> sub})> conn,
|
|
||||||
})> _mockComp2Tcp() async {
|
|
||||||
final server = await ServerSocket.bind('127.0.0.1', 0);
|
|
||||||
final c = Completer<({Socket socket, StreamSubscription<List<int>> sub})>();
|
|
||||||
server.listen((sock) {
|
|
||||||
if (c.isCompleted) return;
|
|
||||||
final buf = <int>[];
|
|
||||||
late StreamSubscription<List<int>> sub;
|
|
||||||
sub = sock.listen((data) {
|
|
||||||
buf.addAll(data);
|
|
||||||
if (!c.isCompleted &&
|
|
||||||
utf8.decode(buf, allowMalformed: true).contains('\r\n\r\n')) {
|
|
||||||
sub.pause();
|
|
||||||
sock.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
|
|
||||||
sock.flush().then((_) => c.complete((socket: sock, sub: sub)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return (server: server, conn: c.future);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send [request] to [proxyPort] and collect the full response.
|
|
||||||
// Assumes the server closes the connection after the response.
|
|
||||||
Future<String> _round(int proxyPort, String request) async {
|
|
||||||
final sock = await Socket.connect('127.0.0.1', proxyPort)
|
|
||||||
.timeout(_kTimeout);
|
|
||||||
sock.write(request);
|
|
||||||
await sock.flush();
|
|
||||||
final buf = <int>[];
|
|
||||||
await sock.listen(buf.addAll).asFuture<void>().timeout(_kTimeout);
|
|
||||||
return utf8.decode(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads from [client] until the CONNECT 200 response header block arrives.
|
|
||||||
Future<String> _waitForConnectResponse(Socket client) async {
|
|
||||||
final buf = <int>[];
|
|
||||||
final done = Completer<void>();
|
|
||||||
late StreamSubscription<List<int>> sub;
|
|
||||||
sub = client.listen((d) {
|
|
||||||
buf.addAll(d);
|
|
||||||
if (!done.isCompleted &&
|
|
||||||
utf8.decode(buf, allowMalformed: true).contains('\r\n\r\n')) {
|
|
||||||
done.complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await done.future.timeout(_kTimeout);
|
|
||||||
sub.cancel();
|
|
||||||
return utf8.decode(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Group 1: gated host matching — unit tests, no network
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('gated host matching (unit)', () {
|
|
||||||
late FilterProxy proxy;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
proxy = FilterProxy(component2Port: 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('host-only entry matches any port', () {
|
|
||||||
proxy.setGatedEntries(['example.com']);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 80), isTrue);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 443), isTrue);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 8771), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('host:port entry matches only that port', () {
|
|
||||||
proxy.setGatedEntries(['example.com:8771']);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 8771), isTrue);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 80), isFalse);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 443), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matching is case-insensitive', () {
|
|
||||||
proxy.setGatedEntries(['EXAMPLE.COM:8771']);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 8771), isTrue);
|
|
||||||
expect(proxy.isGatedForTest('EXAMPLE.COM', 8771), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different hostname is not matched', () {
|
|
||||||
proxy.setGatedEntries(['example.com']);
|
|
||||||
expect(proxy.isGatedForTest('other.com', 80), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty gated list matches nothing', () {
|
|
||||||
proxy.setGatedEntries([]);
|
|
||||||
expect(proxy.isGatedForTest('example.com', 80), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial hostname is not matched', () {
|
|
||||||
proxy.setGatedEntries(['example.com']);
|
|
||||||
expect(proxy.isGatedForTest('sub.example.com', 80), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setGatedEntries replaces previous entries', () {
|
|
||||||
proxy.setGatedEntries(['first.com']);
|
|
||||||
proxy.setGatedEntries(['second.com']);
|
|
||||||
expect(proxy.isGatedForTest('first.com', 80), isFalse);
|
|
||||||
expect(proxy.isGatedForTest('second.com', 80), isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Group 2: HTTP routing (v2 semantics)
|
|
||||||
//
|
|
||||||
// Gated HTTP: proxy calls comp2 POST /auth/get-token, then forwards the
|
|
||||||
// request directly to the endpoint with Authorization: Bearer <token>.
|
|
||||||
// Non-gated HTTP: proxy forwards directly, no token fetch.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('HTTP routing', () {
|
|
||||||
late FilterProxy proxy;
|
|
||||||
late HttpServer comp2;
|
|
||||||
late Completer<({HttpRequest req, String rawBody})> comp2TokenReq;
|
|
||||||
late HttpServer endpoint;
|
|
||||||
late Completer<HttpRequest> endpointReq;
|
|
||||||
late HttpServer directServer;
|
|
||||||
late Completer<HttpRequest> directReq;
|
|
||||||
|
|
||||||
const testToken = 'test-bearer-token';
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
// Component 2 mock: handles POST /auth/get-token → returns token.
|
|
||||||
final c2 = await _mockTokenServer(token: testToken);
|
|
||||||
comp2 = c2.server;
|
|
||||||
comp2TokenReq = c2.tokenReq;
|
|
||||||
|
|
||||||
// Gated endpoint mock: the actual resource the proxy calls directly.
|
|
||||||
final ep = await _mockHttp();
|
|
||||||
endpoint = ep.server;
|
|
||||||
endpointReq = ep.completer;
|
|
||||||
|
|
||||||
// Non-gated target mock.
|
|
||||||
final d = await _mockHttp();
|
|
||||||
directServer = d.server;
|
|
||||||
directReq = d.completer;
|
|
||||||
|
|
||||||
proxy = FilterProxy(
|
|
||||||
listenPort: 0,
|
|
||||||
component2Port: comp2.port,
|
|
||||||
);
|
|
||||||
// Gate the endpoint address; 127.0.0.1 + endpoint port is resolvable in tests.
|
|
||||||
proxy.setGatedEntries(['127.0.0.1:${endpoint.port}']);
|
|
||||||
await proxy.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await proxy.stop();
|
|
||||||
await comp2.close(force: true);
|
|
||||||
await endpoint.close(force: true);
|
|
||||||
await directServer.close(force: true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated host: token is fetched from component2', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
final (:req, rawBody: _) = await comp2TokenReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.method, 'POST');
|
|
||||||
expect(req.uri.path, '/auth/get-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated host: /auth/get-token body carries host, nonce', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/api?x=1 HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
final (:req, :rawBody) = await comp2TokenReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.uri.path, '/auth/get-token');
|
|
||||||
final body = jsonDecode(rawBody) as Map<String, dynamic>;
|
|
||||||
expect(body['host'], '127.0.0.1');
|
|
||||||
expect(body.containsKey('url'), isFalse);
|
|
||||||
expect(body.containsKey('method'), isFalse);
|
|
||||||
expect(body['nonce'], isA<String>());
|
|
||||||
expect((body['nonce'] as String).length, greaterThan(8));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated POST: /auth/get-token body carries host, nonce (no method)', () async {
|
|
||||||
const postBody = '{"key":"val"}';
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'POST http://127.0.0.1:${endpoint.port}/submit HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
|
||||||
'Content-Type: application/json\r\n'
|
|
||||||
'Content-Length: ${postBody.length}\r\n\r\n'
|
|
||||||
'$postBody',
|
|
||||||
);
|
|
||||||
final (:req, :rawBody) = await comp2TokenReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.uri.path, '/auth/get-token');
|
|
||||||
final body = jsonDecode(rawBody) as Map<String, dynamic>;
|
|
||||||
expect(body['host'], '127.0.0.1');
|
|
||||||
expect(body.containsKey('method'), isFalse);
|
|
||||||
expect(body['nonce'], isA<String>());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated host: request goes directly to endpoint with Bearer token', () async {
|
|
||||||
final response = await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
|
|
||||||
expect(req.method, 'GET');
|
|
||||||
expect(req.uri.path, '/api');
|
|
||||||
expect(req.headers.value('authorization'), 'Bearer $testToken');
|
|
||||||
expect(response, contains('200 OK'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-gated host is forwarded directly', () async {
|
|
||||||
final response = await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await directReq.future.timeout(_kTimeout);
|
|
||||||
|
|
||||||
expect(req.method, 'GET');
|
|
||||||
expect(req.uri.path, '/page');
|
|
||||||
expect(response, contains('200 OK'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-gated request does NOT reach component2', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${directServer.port}/page HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${directServer.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
await directReq.future.timeout(_kTimeout);
|
|
||||||
expect(comp2TokenReq.isCompleted, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: request line is rewritten to relative path', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/session/login?foo=bar HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.uri.path, '/session/login');
|
|
||||||
expect(req.uri.query, 'foo=bar');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: Proxy-Connection header is stripped', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
|
||||||
'Proxy-Connection: keep-alive\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.headers.value('proxy-connection'), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: Proxy-Authorization header is stripped', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
|
||||||
'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.headers.value('proxy-authorization'), isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: existing Authorization header is replaced with Bearer token', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
|
||||||
'Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.headers.value('authorization'), 'Bearer $testToken');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: custom header is preserved', () async {
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/health HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
|
||||||
'X-Custom: hello\r\n\r\n',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.headers.value('x-custom'), 'hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: POST body is forwarded to endpoint', () async {
|
|
||||||
const body = '{"username":"alice"}';
|
|
||||||
await _round(
|
|
||||||
proxy.port,
|
|
||||||
'POST http://127.0.0.1:${endpoint.port}/session/login HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n'
|
|
||||||
'Content-Type: application/json\r\n'
|
|
||||||
'Content-Length: ${body.length}\r\n\r\n'
|
|
||||||
'$body',
|
|
||||||
);
|
|
||||||
final req = await endpointReq.future.timeout(_kTimeout);
|
|
||||||
expect(req.method, 'POST');
|
|
||||||
expect(req.uri.path, '/session/login');
|
|
||||||
expect(req.headers.value('authorization'), 'Bearer $testToken');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated: 407 returned when component2 has no active session', () async {
|
|
||||||
final c2Err = await _mockTokenServer(ok: false);
|
|
||||||
final proxy2 = FilterProxy(
|
|
||||||
listenPort: 0,
|
|
||||||
component2Port: c2Err.server.port,
|
|
||||||
);
|
|
||||||
proxy2.setGatedEntries(['127.0.0.1:${endpoint.port}']);
|
|
||||||
await proxy2.start();
|
|
||||||
addTearDown(() async {
|
|
||||||
await proxy2.stop();
|
|
||||||
await c2Err.server.close(force: true);
|
|
||||||
});
|
|
||||||
|
|
||||||
final response = await _round(
|
|
||||||
proxy2.port,
|
|
||||||
'GET http://127.0.0.1:${endpoint.port}/api HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${endpoint.port}\r\n\r\n',
|
|
||||||
);
|
|
||||||
expect(response, contains('407'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Group 3: CONNECT tunnel routing
|
|
||||||
// (Gated CONNECT is still relayed through Component 2 — unchanged in v2.)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('CONNECT routing', () {
|
|
||||||
late FilterProxy proxy;
|
|
||||||
late ServerSocket comp2Tcp;
|
|
||||||
late Future<({Socket socket, StreamSubscription<List<int>> sub})> comp2Conn;
|
|
||||||
late ServerSocket directTcp;
|
|
||||||
late Future<Socket> directConn;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
final c2 = await _mockComp2Tcp();
|
|
||||||
comp2Tcp = c2.server;
|
|
||||||
comp2Conn = c2.conn;
|
|
||||||
|
|
||||||
final d = await _mockTcp();
|
|
||||||
directTcp = d.server;
|
|
||||||
directConn = d.socket;
|
|
||||||
|
|
||||||
proxy = FilterProxy(
|
|
||||||
listenPort: 0,
|
|
||||||
component2Port: comp2Tcp.port,
|
|
||||||
);
|
|
||||||
proxy.setGatedEntries(['auth.local:443']);
|
|
||||||
await proxy.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await proxy.stop();
|
|
||||||
await comp2Tcp.close();
|
|
||||||
await directTcp.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gated CONNECT returns 200 and tunnels to component2', () async {
|
|
||||||
final client = await Socket.connect('127.0.0.1', proxy.port)
|
|
||||||
.timeout(_kTimeout);
|
|
||||||
client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n');
|
|
||||||
await client.flush();
|
|
||||||
|
|
||||||
final response = await _waitForConnectResponse(client);
|
|
||||||
expect(response, contains('200 Connection Established'));
|
|
||||||
|
|
||||||
// Verify the tunnel endpoint is component2
|
|
||||||
final conn = await comp2Conn.timeout(_kTimeout);
|
|
||||||
conn.sub.cancel();
|
|
||||||
client.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-gated CONNECT returns 200 and tunnels to direct target', () async {
|
|
||||||
final client = await Socket.connect('127.0.0.1', proxy.port)
|
|
||||||
.timeout(_kTimeout);
|
|
||||||
// Use 127.0.0.1:${directTcp.port} as the CONNECT target (not gated)
|
|
||||||
client.write(
|
|
||||||
'CONNECT 127.0.0.1:${directTcp.port} HTTP/1.1\r\n'
|
|
||||||
'Host: 127.0.0.1:${directTcp.port}\r\n\r\n');
|
|
||||||
await client.flush();
|
|
||||||
|
|
||||||
final response = await _waitForConnectResponse(client);
|
|
||||||
expect(response, contains('200 Connection Established'));
|
|
||||||
await directConn.timeout(_kTimeout);
|
|
||||||
client.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('data flows through CONNECT tunnel in both directions', () async {
|
|
||||||
final client = await Socket.connect('127.0.0.1', proxy.port)
|
|
||||||
.timeout(_kTimeout);
|
|
||||||
|
|
||||||
// Single listener + broadcast controller so multiple await-for loops can
|
|
||||||
// consume the stream without "already listened" errors.
|
|
||||||
final clientBuf = <int>[];
|
|
||||||
final clientCtrl = StreamController<List<int>>.broadcast();
|
|
||||||
client.listen((d) { clientBuf.addAll(d); clientCtrl.add(d); });
|
|
||||||
|
|
||||||
// Wait until clientBuf contains [expected]. Checks buffer first so data
|
|
||||||
// that arrived before the await-for subscription is never lost.
|
|
||||||
Future<void> waitClient(String expected) async {
|
|
||||||
if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return;
|
|
||||||
await for (final _ in clientCtrl.stream) {
|
|
||||||
if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n');
|
|
||||||
await client.flush();
|
|
||||||
await waitClient('\r\n\r\n').timeout(_kTimeout);
|
|
||||||
expect(utf8.decode(clientBuf), contains('200 Connection Established'));
|
|
||||||
|
|
||||||
// Component2 side of the tunnel: mock already consumed the CONNECT
|
|
||||||
// request and sent 200; sub is paused so we can install our handler
|
|
||||||
// before any tunnel data arrives.
|
|
||||||
final conn = await comp2Conn.timeout(_kTimeout);
|
|
||||||
final serverSide = conn.socket;
|
|
||||||
final serverSub = conn.sub;
|
|
||||||
|
|
||||||
// Client → component2
|
|
||||||
final serverBuf = <int>[];
|
|
||||||
final serverGotPing = Completer<void>();
|
|
||||||
serverSub.onData((d) {
|
|
||||||
serverBuf.addAll(d);
|
|
||||||
if (!serverGotPing.isCompleted &&
|
|
||||||
utf8.decode(serverBuf, allowMalformed: true).contains('PING')) {
|
|
||||||
serverGotPing.complete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
serverSub.resume();
|
|
||||||
|
|
||||||
client.write('PING');
|
|
||||||
await client.flush();
|
|
||||||
await serverGotPing.future.timeout(_kTimeout);
|
|
||||||
expect(utf8.decode(serverBuf), 'PING');
|
|
||||||
|
|
||||||
// Component2 → client
|
|
||||||
final prevLen = clientBuf.length;
|
|
||||||
serverSide.write('PONG');
|
|
||||||
await serverSide.flush();
|
|
||||||
await waitClient('PONG').timeout(_kTimeout);
|
|
||||||
expect(utf8.decode(clientBuf.sublist(prevLen)), 'PONG');
|
|
||||||
|
|
||||||
await clientCtrl.close();
|
|
||||||
client.destroy();
|
|
||||||
serverSide.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Group 4: edge cases
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
group('edge cases', () {
|
|
||||||
late FilterProxy proxy;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
proxy = FilterProxy(listenPort: 0, component2Port: 0);
|
|
||||||
proxy.setGatedEntries([]);
|
|
||||||
await proxy.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() => proxy.stop());
|
|
||||||
|
|
||||||
test('connection with no headers is closed cleanly', () async {
|
|
||||||
final client = await Socket.connect('127.0.0.1', proxy.port)
|
|
||||||
.timeout(_kTimeout);
|
|
||||||
// Send nothing — proxy should close when client closes
|
|
||||||
await client.close();
|
|
||||||
// No exception means the proxy handled it gracefully
|
|
||||||
});
|
|
||||||
|
|
||||||
test('malformed request line is closed cleanly', () async {
|
|
||||||
final response = await _round(proxy.port, 'NOT-HTTP\r\n\r\n');
|
|
||||||
// Proxy should close the connection without crashing
|
|
||||||
expect(response, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('request with only spaces in request line is closed cleanly', () async {
|
|
||||||
final response = await _round(proxy.port, ' \r\n\r\n');
|
|
||||||
expect(response, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// Placeholder — real tests will cover proxy logic and CTAPHID framing.
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('placeholder', () => expect(1 + 1, 2));
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
k_proxy — session gateway and card authentication bridge.
|
Minimal k_proxy service for Phase 5 bring-up.
|
||||||
|
|
||||||
Creates short-lived bearer sessions after a card-backed auth gate, then
|
Behavior:
|
||||||
proxies authenticated requests to k_server. Enrollment metadata and session
|
- Creates short-lived sessions after a card-backed auth gate.
|
||||||
state are both process-local; sessions do not survive a restart.
|
- Reuses valid sessions to access k_server protected counter endpoint.
|
||||||
|
- Supports enrollment, session status, and logout.
|
||||||
|
|
||||||
Default auth mode is a lightweight card-presence probe (subprocess call to
|
Notes:
|
||||||
fido2_probe.py). Pass --auth-mode fido2-direct for real CTAP2
|
- Default runtime still uses the legacy card-presence probe gate.
|
||||||
makeCredential/getAssertion against the attached ChromeCard.
|
- Experimental direct FIDO2 registration/assertion lives behind `--auth-mode fido2-direct`.
|
||||||
|
- This is still a prototype and not a final production auth design.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -53,11 +55,8 @@ from fido2.webauthn import (
|
||||||
UserVerificationRequirement,
|
UserVerificationRequirement,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None:
|
||||||
if getattr(fido2.features.webauthn_json_mapping, "_enabled", None) is None:
|
fido2.features.webauthn_json_mapping.enabled = True
|
||||||
fido2.features.webauthn_json_mapping.enabled = True
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
HTML = """<!doctype html>
|
HTML = """<!doctype html>
|
||||||
|
|
@ -544,7 +543,6 @@ class ProxyState:
|
||||||
return time.time()
|
return time.time()
|
||||||
|
|
||||||
def _gc_locked(self) -> None:
|
def _gc_locked(self) -> None:
|
||||||
# Caller must hold self.lock.
|
|
||||||
now = self._now()
|
now = self._now()
|
||||||
dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now]
|
dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now]
|
||||||
for token in dead:
|
for token in dead:
|
||||||
|
|
@ -673,9 +671,6 @@ class ProxyState:
|
||||||
self._drop_direct_device_locked()
|
self._drop_direct_device_locked()
|
||||||
|
|
||||||
def _with_direct_ctap2(self, action):
|
def _with_direct_ctap2(self, action):
|
||||||
# First attempt reuses the cached handle; if it fails (e.g. the card was
|
|
||||||
# briefly removed or the CTAPHID channel desynchronised), we reopen once
|
|
||||||
# and retry before propagating the error.
|
|
||||||
with self.direct_device_lock:
|
with self.direct_device_lock:
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for reopen in (False, True):
|
for reopen in (False, True):
|
||||||
|
|
@ -964,8 +959,6 @@ class UpstreamPool:
|
||||||
conn.request("POST", full_path, body=body, headers=req_headers)
|
conn.request("POST", full_path, body=body, headers=req_headers)
|
||||||
resp = conn.getresponse()
|
resp = conn.getresponse()
|
||||||
raw = resp.read()
|
raw = resp.read()
|
||||||
# will_close is set by the server when it intends to close the connection
|
|
||||||
# after this response; reusing such a connection would hit an EOF.
|
|
||||||
reusable = not resp.will_close
|
reusable = not resp.will_close
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw.decode("utf-8")) if raw else {}
|
data = json.loads(raw.decode("utf-8")) if raw else {}
|
||||||
|
|
@ -1008,20 +1001,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return json.loads(raw.decode("utf-8"))
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
def _discard_request_body(self) -> None:
|
def _discard_request_body(self) -> None:
|
||||||
# HTTP/1.1 keep-alive: body must be consumed before the response is sent.
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
if length > 0:
|
if length > 0:
|
||||||
self.rfile.read(length)
|
self.rfile.read(length)
|
||||||
|
|
||||||
def _require_json(self) -> dict[str, Any] | None:
|
|
||||||
# Returns None and sends 400 when the body is unparseable; callers must
|
|
||||||
# return immediately without sending a second response.
|
|
||||||
try:
|
|
||||||
return self._read_json()
|
|
||||||
except Exception:
|
|
||||||
self._json(400, {"ok": False, "error": "invalid json"})
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _bearer_token(self) -> str | None:
|
def _bearer_token(self) -> str | None:
|
||||||
value = self.headers.get("Authorization", "")
|
value = self.headers.get("Authorization", "")
|
||||||
if not value.startswith("Bearer "):
|
if not value.startswith("Bearer "):
|
||||||
|
|
@ -1030,8 +1013,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return token or None
|
return token or None
|
||||||
|
|
||||||
def _require_session(self) -> tuple[str, Session] | None:
|
def _require_session(self) -> tuple[str, Session] | None:
|
||||||
# Returns None when auth fails; the 401 has already been sent, so callers
|
|
||||||
# must return immediately without writing a second response.
|
|
||||||
token = self._bearer_token()
|
token = self._bearer_token()
|
||||||
if not token:
|
if not token:
|
||||||
self._json(401, {"ok": False, "error": "missing bearer token"})
|
self._json(401, {"ok": False, "error": "missing bearer token"})
|
||||||
|
|
@ -1092,8 +1073,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
def _session_login(self) -> None:
|
def _session_login(self) -> None:
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1124,8 +1107,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _enroll_register(self) -> None:
|
def _enroll_register(self) -> None:
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1146,8 +1131,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at))
|
self._json(200, enrollment_payload(enrollment, created=enrollment.created_at == enrollment.updated_at))
|
||||||
|
|
||||||
def _enroll_update(self) -> None:
|
def _enroll_update(self) -> None:
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
enrollment = self.state.update_enrollment(
|
enrollment = self.state.update_enrollment(
|
||||||
|
|
@ -1163,8 +1150,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(200, enrollment_payload(enrollment))
|
self._json(200, enrollment_payload(enrollment))
|
||||||
|
|
||||||
def _enroll_delete(self) -> None:
|
def _enroll_delete(self) -> None:
|
||||||
data = self._require_json()
|
try:
|
||||||
if data is None:
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
enrollment = self.state.delete_enrollment(str(data.get("username", "")))
|
enrollment = self.state.delete_enrollment(str(data.get("username", "")))
|
||||||
|
|
|
||||||
109
k_server_app.py
109
k_server_app.py
|
|
@ -1,17 +1,16 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
k_server — protected resource backend.
|
Minimal k_server service for Phase 5/5.5 bring-up.
|
||||||
|
|
||||||
Exposes a monotonic counter behind a shared proxy token. Only k_proxy
|
Behavior:
|
||||||
is expected to reach this service; k_client should have no direct path.
|
- Exposes a protected monotonic counter endpoint.
|
||||||
All state is process-local and resets on restart.
|
- Accepts only requests from k_proxy via a shared proxy token header.
|
||||||
|
- Uses thread-safe counter increments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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,88 +20,9 @@ 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, expected_host: str) -> bool:
|
|
||||||
"""Verify a base64url-encoded FIDO2 domain-level assertion bundle.
|
|
||||||
|
|
||||||
Bundle fields (JSON, then base64url-encoded):
|
|
||||||
v version (1)
|
|
||||||
host hostname used to derive the challenge
|
|
||||||
nonce random hex nonce used to derive the challenge
|
|
||||||
authData base64url authenticator data
|
|
||||||
sig base64url ECDSA signature
|
|
||||||
cdj base64url clientDataJson bytes
|
|
||||||
cred base64url AttestedCredentialData (aaguid+credIdLen+credId+coseKey)
|
|
||||||
user enrolled username (informational)
|
|
||||||
|
|
||||||
expected_host must match bundle["host"] exactly (case-insensitive) to prevent
|
|
||||||
cross-server replay: a token issued for server-a must not pass on server-b.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import cbor2
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
||||||
ECDSA,
|
|
||||||
EllipticCurvePublicNumbers,
|
|
||||||
SECP256R1,
|
|
||||||
)
|
|
||||||
from cryptography.hazmat.primitives.hashes import SHA256
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
|
||||||
|
|
||||||
bundle = json.loads(_b64u_decode(token).decode("utf-8"))
|
|
||||||
|
|
||||||
host = bundle["host"]
|
|
||||||
nonce = bundle["nonce"]
|
|
||||||
|
|
||||||
if host.lower() != expected_host.lower():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Verify challenge claim: challenge == b64u(SHA256(host|nonce))
|
|
||||||
binding = f"{host}|{nonce}".encode()
|
|
||||||
expected_challenge = base64.urlsafe_b64encode(hashlib.sha256(binding).digest()).rstrip(b"=").decode()
|
|
||||||
|
|
||||||
cdj_bytes = _b64u_decode(bundle["cdj"])
|
|
||||||
cdj = json.loads(cdj_bytes)
|
|
||||||
if cdj.get("type") != "webauthn.get":
|
|
||||||
return False
|
|
||||||
if cdj.get("challenge") != expected_challenge:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Verify ECDSA-P256 signature over authData || SHA256(clientDataJson).
|
|
||||||
auth_data = _b64u_decode(bundle["authData"])
|
|
||||||
signature = _b64u_decode(bundle["sig"])
|
|
||||||
client_data_hash = hashlib.sha256(cdj_bytes).digest()
|
|
||||||
message = auth_data + client_data_hash
|
|
||||||
|
|
||||||
# Extract P-256 public key from AttestedCredentialData.
|
|
||||||
cred_data = _b64u_decode(bundle["cred"])
|
|
||||||
cred_id_len = (cred_data[16] << 8) | cred_data[17]
|
|
||||||
cose_bytes = cred_data[18 + cred_id_len:]
|
|
||||||
cose_key = cbor2.loads(cose_bytes)
|
|
||||||
x = cose_key[-2]
|
|
||||||
y = cose_key[-3]
|
|
||||||
|
|
||||||
pub_key = EllipticCurvePublicNumbers(
|
|
||||||
x=int.from_bytes(x, "big"),
|
|
||||||
y=int.from_bytes(y, "big"),
|
|
||||||
curve=SECP256R1(),
|
|
||||||
).public_key(default_backend())
|
|
||||||
|
|
||||||
pub_key.verify(signature, message, ECDSA(SHA256()))
|
|
||||||
return True
|
|
||||||
except (InvalidSignature, Exception):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class ServerState:
|
class ServerState:
|
||||||
# All state is process-local; a restart resets the counter to zero.
|
def __init__(self, proxy_token: str):
|
||||||
def __init__(self, proxy_token: str, protected_host: str = "127.0.0.1"):
|
|
||||||
self.proxy_token = proxy_token
|
self.proxy_token = proxy_token
|
||||||
self.protected_host = protected_host
|
|
||||||
self.counter = 0
|
self.counter = 0
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
|
@ -125,20 +45,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _discard_request_body(self) -> None:
|
def _discard_request_body(self) -> None:
|
||||||
# HTTP/1.1 keep-alive: the connection is reused, so the body must be fully
|
|
||||||
# consumed before we send the response, even for endpoints that ignore it.
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
if length > 0:
|
if length > 0:
|
||||||
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(), self.state.protected_host)
|
|
||||||
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
|
||||||
|
|
@ -187,11 +99,6 @@ def parse_args() -> argparse.Namespace:
|
||||||
default="dev-proxy-token",
|
default="dev-proxy-token",
|
||||||
help="Shared token expected in X-Proxy-Token from k_proxy",
|
help="Shared token expected in X-Proxy-Token from k_proxy",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--protected-host",
|
|
||||||
default="127.0.0.1",
|
|
||||||
help="Hostname this server protects; Bearer tokens must be issued for this host",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -200,7 +107,7 @@ def main() -> int:
|
||||||
if bool(args.tls_certfile) != bool(args.tls_keyfile):
|
if bool(args.tls_certfile) != bool(args.tls_keyfile):
|
||||||
raise SystemExit("Both --tls-certfile and --tls-keyfile are required to enable HTTPS")
|
raise SystemExit("Both --tls-certfile and --tls-keyfile are required to enable HTTPS")
|
||||||
|
|
||||||
state = ServerState(proxy_token=args.proxy_token, protected_host=args.protected_host)
|
state = ServerState(proxy_token=args.proxy_token)
|
||||||
Handler.state = state
|
Handler.state = state
|
||||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "chromecard-browser-regression",
|
"name": "chromecard-browser-regression",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
|
||||||
"playwright": "^1.59.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.54.2"
|
"@playwright/test": "^1.54.2"
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +31,7 @@
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -48,6 +46,7 @@
|
||||||
"version": "1.59.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.59.1"
|
"playwright-core": "1.59.1"
|
||||||
|
|
@ -66,6 +65,7 @@
|
||||||
"version": "1.59.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,5 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.54.2"
|
"@playwright/test": "^1.54.2"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "^1.59.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const { defineConfig } = require("@playwright/test");
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
timeout: 60_000,
|
timeout: 180_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
"""
|
|
||||||
CardEmulator — software emulator of the ChromeCard FIDO2 authenticator
|
|
||||||
======================================================================
|
|
||||||
|
|
||||||
What it is
|
|
||||||
----------
|
|
||||||
CardEmulator is a drop-in replacement for the physical ChromeCard in tests.
|
|
||||||
It implements the two Ctap2 methods that k_proxy_app calls in fido2-direct
|
|
||||||
mode — make_credential (registration) and get_assertion (authentication) —
|
|
||||||
using real P-256 cryptography and an in-memory credential store.
|
|
||||||
|
|
||||||
The auth_data layout and COSE key encoding mirror the firmware exactly
|
|
||||||
(see fido_make_cred.c and fido_get_assertion.c), so fido2.server's
|
|
||||||
register_complete and authenticate_complete accept the emulator's responses
|
|
||||||
without any extra patching.
|
|
||||||
|
|
||||||
|
|
||||||
Wiring the emulator into a ProxyState test
|
|
||||||
------------------------------------------
|
|
||||||
Two patches are needed: one to replace _with_direct_ctap2 so it hands the
|
|
||||||
emulator to the lambda instead of opening a real HID device, and one to
|
|
||||||
suppress _drop_direct_device which would otherwise try to close a real handle.
|
|
||||||
|
|
||||||
A convenience helper for this is provided in test_k_proxy.py:
|
|
||||||
|
|
||||||
def _patch_emulator(state, emulator):
|
|
||||||
return patch.multiple(
|
|
||||||
state,
|
|
||||||
_with_direct_ctap2=lambda fn: fn(emulator),
|
|
||||||
_drop_direct_device=lambda: None,
|
|
||||||
)
|
|
||||||
|
|
||||||
Typical test setup:
|
|
||||||
|
|
||||||
from card_emulator import CardEmulator
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
emulator = CardEmulator()
|
|
||||||
state = _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT)
|
|
||||||
|
|
||||||
with _patch_emulator(state, emulator):
|
|
||||||
enrollment = state.register_enrollment("alice", "Alice")
|
|
||||||
ok, msg = state.authenticate_with_card("alice") # True, "assertion verified"
|
|
||||||
|
|
||||||
|
|
||||||
User confirmation (Yes / No on the card)
|
|
||||||
-----------------------------------------
|
|
||||||
Both make_credential and get_assertion accept a `user_confirms` keyword
|
|
||||||
argument (default True). Setting it to False raises
|
|
||||||
CtapError(OPERATION_DENIED), exactly as the firmware does when the user taps
|
|
||||||
No on the card's confirmation dialog.
|
|
||||||
|
|
||||||
Direct calls — pass the flag explicitly:
|
|
||||||
|
|
||||||
attest = emulator.make_credential(
|
|
||||||
client_data_hash=..., rp=..., user=..., key_params=...,
|
|
||||||
user_confirms=False,
|
|
||||||
) # raises CtapError(OPERATION_DENIED)
|
|
||||||
|
|
||||||
Integration tests through _with_direct_ctap2 — the lambda that ProxyState
|
|
||||||
builds cannot inject user_confirms, so use refusing() instead. It returns
|
|
||||||
a thin wrapper whose methods forward to the emulator with user_confirms=False:
|
|
||||||
|
|
||||||
with _patch_emulator(state, emulator.refusing()):
|
|
||||||
ok, msg = state.authenticate_with_card("alice") # False
|
|
||||||
# msg contains "assertion verification failed: CTAP error: OPERATION_DENIED"
|
|
||||||
|
|
||||||
with _patch_emulator(state, emulator.refusing()):
|
|
||||||
with self.assertRaises(RuntimeError):
|
|
||||||
state.register_enrollment("bob", None)
|
|
||||||
# raises RuntimeError("card registration failed: ...")
|
|
||||||
|
|
||||||
refusing() shares the same credential store as the parent emulator, so
|
|
||||||
credentials registered before or after the call are visible to both.
|
|
||||||
|
|
||||||
|
|
||||||
Simulating card-side credential removal
|
|
||||||
----------------------------------------
|
|
||||||
forget_user(username) removes all credentials for that user from the
|
|
||||||
emulator's store and returns the count removed. Use it to simulate a
|
|
||||||
factory reset or a deliberate key deletion:
|
|
||||||
|
|
||||||
emulator.forget_user("alice")
|
|
||||||
ok, msg = state.authenticate_with_card("alice") # False
|
|
||||||
|
|
||||||
|
|
||||||
API summary
|
|
||||||
-----------
|
|
||||||
CardEmulator()
|
|
||||||
Create a new emulator with an empty credential store.
|
|
||||||
|
|
||||||
make_credential(client_data_hash, rp, user, key_params, *, user_confirms=True)
|
|
||||||
Simulate CTAP2 makeCredential. Returns AttestationResponse.
|
|
||||||
|
|
||||||
get_assertion(rp_id, client_data_hash, allow_list, *, user_confirms=True)
|
|
||||||
Simulate CTAP2 getAssertion. Returns AssertionResponse.
|
|
||||||
Raises CtapError(NO_CREDENTIALS) if no matching credential is found.
|
|
||||||
|
|
||||||
refusing() -> _RefusingView
|
|
||||||
Return a wrapper that forces user_confirms=False on every call.
|
|
||||||
|
|
||||||
forget_user(username) -> int
|
|
||||||
Remove all credentials for username. Returns count removed.
|
|
||||||
|
|
||||||
credential_count() -> int
|
|
||||||
Total credentials currently in the store.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
from fido2.ctap import CtapError
|
|
||||||
from fido2.ctap2 import AssertionResponse, AttestationResponse
|
|
||||||
from fido2.webauthn import AuthenticatorData
|
|
||||||
|
|
||||||
# AAGUID from fido_make_cred.c
|
|
||||||
_AAGUID = bytes([
|
|
||||||
0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF,
|
|
||||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _cose_es256(x: bytes, y: bytes) -> bytes:
|
|
||||||
"""CBOR-encoded COSE ES256 public key, byte-for-byte as the firmware builds it."""
|
|
||||||
return (
|
|
||||||
bytes([0xA5]) # map(5)
|
|
||||||
+ bytes([0x01, 0x02]) # kty: 2 (EC2)
|
|
||||||
+ bytes([0x03, 0x26]) # alg: -7 (ES256)
|
|
||||||
+ bytes([0x20, 0x01]) # crv (-1): 1 (P-256)
|
|
||||||
+ bytes([0x21, 0x58, 0x20]) + x # x (-2): bstr(32)
|
|
||||||
+ bytes([0x22, 0x58, 0x20]) + y # y (-3): bstr(32)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Credential:
|
|
||||||
private_key: ec.EllipticCurvePrivateKey
|
|
||||||
rp_id_hash: bytes
|
|
||||||
user_id: bytes
|
|
||||||
username: str
|
|
||||||
sign_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class CardEmulator:
|
|
||||||
"""In-process emulator of a ChromeCard FIDO2 authenticator.
|
|
||||||
|
|
||||||
Implements make_credential and get_assertion with the same signatures as
|
|
||||||
fido2.ctap2.Ctap2, plus forget_user() for test teardown.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._creds: dict[bytes, _Credential] = {}
|
|
||||||
|
|
||||||
# ── CTAP2 interface ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def make_credential(
|
|
||||||
self,
|
|
||||||
client_data_hash: bytes,
|
|
||||||
rp: Mapping[str, Any],
|
|
||||||
user: Mapping[str, Any],
|
|
||||||
key_params: list[Mapping[str, Any]],
|
|
||||||
exclude_list: list[Mapping[str, Any]] | None = None,
|
|
||||||
extensions: Mapping[str, Any] | None = None,
|
|
||||||
options: Mapping[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
user_confirms: bool = True,
|
|
||||||
**_: Any,
|
|
||||||
) -> AttestationResponse:
|
|
||||||
"""Simulate makeCredential.
|
|
||||||
|
|
||||||
When user_confirms is False the call raises CtapError(OPERATION_DENIED),
|
|
||||||
mirroring the firmware's response when the user taps No on the card.
|
|
||||||
Otherwise a real P-256 keypair is generated, stored, and returned as a
|
|
||||||
fmt='none' AttestationResponse with a valid COSE ES256 public key.
|
|
||||||
"""
|
|
||||||
if not user_confirms:
|
|
||||||
raise CtapError(CtapError.ERR.OPERATION_DENIED)
|
|
||||||
|
|
||||||
rp_id: str = rp["id"]
|
|
||||||
rp_id_hash = hashlib.sha256(rp_id.encode()).digest()
|
|
||||||
|
|
||||||
priv = ec.generate_private_key(ec.SECP256R1())
|
|
||||||
pub_nums = priv.public_key().public_numbers()
|
|
||||||
x = pub_nums.x.to_bytes(32, "big")
|
|
||||||
y = pub_nums.y.to_bytes(32, "big")
|
|
||||||
|
|
||||||
credential_id = os.urandom(32)
|
|
||||||
|
|
||||||
raw_user_id: bytes = user.get("id", b"") # type: ignore[assignment]
|
|
||||||
if isinstance(raw_user_id, str):
|
|
||||||
raw_user_id = raw_user_id.encode()
|
|
||||||
|
|
||||||
cred = _Credential(
|
|
||||||
private_key=priv,
|
|
||||||
rp_id_hash=rp_id_hash,
|
|
||||||
user_id=raw_user_id,
|
|
||||||
username=user.get("name", ""), # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
|
|
||||||
# authData layout matches fido_make_cred.c build_make_credential_response():
|
|
||||||
# rpIdHash(32) | flags(1) | signCount(4) | aaguid(16)
|
|
||||||
# | credIdLen(2) | credId(32) | coseKey(77)
|
|
||||||
auth_data_bytes = (
|
|
||||||
rp_id_hash
|
|
||||||
+ bytes([0x41]) # flags: UP=1, AT=1
|
|
||||||
+ struct.pack(">I", cred.sign_count)
|
|
||||||
+ _AAGUID
|
|
||||||
+ struct.pack(">H", len(credential_id))
|
|
||||||
+ credential_id
|
|
||||||
+ _cose_es256(x, y)
|
|
||||||
)
|
|
||||||
cred.sign_count += 1
|
|
||||||
self._creds[credential_id] = cred
|
|
||||||
|
|
||||||
return AttestationResponse(
|
|
||||||
fmt="none",
|
|
||||||
auth_data=AuthenticatorData(auth_data_bytes),
|
|
||||||
att_stmt={},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_assertion(
|
|
||||||
self,
|
|
||||||
rp_id: str,
|
|
||||||
client_data_hash: bytes,
|
|
||||||
allow_list: list[Mapping[str, Any]] | None = None,
|
|
||||||
extensions: Mapping[str, Any] | None = None,
|
|
||||||
options: Mapping[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
user_confirms: bool = True,
|
|
||||||
**_: Any,
|
|
||||||
) -> AssertionResponse:
|
|
||||||
"""Simulate getAssertion.
|
|
||||||
|
|
||||||
When user_confirms is False raises CtapError(OPERATION_DENIED).
|
|
||||||
Otherwise finds the credential, builds authData (37 bytes, no AT flag),
|
|
||||||
signs authData || clientDataHash with ECDSA-SHA256 (DER), and returns
|
|
||||||
an AssertionResponse — byte-for-byte compatible with the firmware output.
|
|
||||||
"""
|
|
||||||
if not user_confirms:
|
|
||||||
raise CtapError(CtapError.ERR.OPERATION_DENIED)
|
|
||||||
|
|
||||||
rp_id_hash = hashlib.sha256(rp_id.encode()).digest()
|
|
||||||
|
|
||||||
if not allow_list:
|
|
||||||
raise CtapError(CtapError.ERR.NO_CREDENTIALS)
|
|
||||||
|
|
||||||
cred_id: bytes | None = None
|
|
||||||
cred: _Credential | None = None
|
|
||||||
for desc in allow_list:
|
|
||||||
cid: bytes = desc["id"] if isinstance(desc, dict) else getattr(desc, "id")
|
|
||||||
entry = self._creds.get(cid)
|
|
||||||
if entry is not None and entry.rp_id_hash == rp_id_hash:
|
|
||||||
cred_id = cid
|
|
||||||
cred = entry
|
|
||||||
break
|
|
||||||
|
|
||||||
if cred is None or cred_id is None:
|
|
||||||
raise CtapError(CtapError.ERR.NO_CREDENTIALS)
|
|
||||||
|
|
||||||
# authData layout matches fido_get_assertion.c build_get_assertion_response():
|
|
||||||
# rpIdHash(32) | flags(1) | signCount(4)
|
|
||||||
auth_data_bytes = (
|
|
||||||
rp_id_hash
|
|
||||||
+ bytes([0x01]) # flags: UP=1
|
|
||||||
+ struct.pack(">I", cred.sign_count)
|
|
||||||
)
|
|
||||||
cred.sign_count += 1
|
|
||||||
|
|
||||||
# Signature over authData || clientDataHash, DER-encoded.
|
|
||||||
# Matches drv_crypto_sign_hash_DER() in the firmware.
|
|
||||||
sig = cred.private_key.sign(
|
|
||||||
auth_data_bytes + client_data_hash,
|
|
||||||
ec.ECDSA(hashes.SHA256()),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_field: dict[str, Any] | None = {"id": cred.user_id} if cred.user_id else None
|
|
||||||
|
|
||||||
return AssertionResponse(
|
|
||||||
credential={"id": cred_id, "type": "public-key"},
|
|
||||||
auth_data=AuthenticatorData(auth_data_bytes),
|
|
||||||
signature=sig,
|
|
||||||
user=user_field,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── test helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def refusing(self) -> "_RefusingView":
|
|
||||||
"""Return a view of this emulator that always declines confirmation.
|
|
||||||
|
|
||||||
Use this when the test goes through _with_direct_ctap2, which calls
|
|
||||||
make_credential / get_assertion via a lambda and cannot inject
|
|
||||||
user_confirms directly:
|
|
||||||
|
|
||||||
with patch.object(state, "_with_direct_ctap2",
|
|
||||||
side_effect=lambda fn: fn(emulator.refusing())):
|
|
||||||
ok, msg = state.authenticate_with_card("alice")
|
|
||||||
self.assertFalse(ok)
|
|
||||||
"""
|
|
||||||
return _RefusingView(self)
|
|
||||||
|
|
||||||
def forget_user(self, username: str) -> int:
|
|
||||||
"""Remove all credentials for *username* from the emulator store.
|
|
||||||
|
|
||||||
Returns the number of credentials removed. Use this to simulate a
|
|
||||||
card-side credential deletion (factory reset, or deliberate removal).
|
|
||||||
"""
|
|
||||||
to_delete = [cid for cid, e in self._creds.items() if e.username == username]
|
|
||||||
for cid in to_delete:
|
|
||||||
del self._creds[cid]
|
|
||||||
return len(to_delete)
|
|
||||||
|
|
||||||
def credential_count(self) -> int:
|
|
||||||
"""Total number of credentials currently in the emulator store."""
|
|
||||||
return len(self._creds)
|
|
||||||
|
|
||||||
|
|
||||||
class _RefusingView:
|
|
||||||
"""Thin proxy returned by CardEmulator.refusing().
|
|
||||||
|
|
||||||
Forwards make_credential and get_assertion to the underlying emulator
|
|
||||||
with user_confirms=False, so every call raises OPERATION_DENIED.
|
|
||||||
The credential store is shared with the parent emulator.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, emulator: CardEmulator) -> None:
|
|
||||||
self._emulator = emulator
|
|
||||||
|
|
||||||
def make_credential(self, **kwargs: Any) -> AttestationResponse:
|
|
||||||
return self._emulator.make_credential(**kwargs, user_confirms=False)
|
|
||||||
|
|
||||||
def get_assertion(self, **kwargs: Any) -> AssertionResponse:
|
|
||||||
return self._emulator.get_assertion(**kwargs, user_confirms=False)
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
card_emulator_bridge.py — CTAPHID TCP bridge for Android emulator testing.
|
|
||||||
|
|
||||||
The Dart ctaphid_channel.dart speaks raw 64-byte CTAPHID packets over TCP.
|
|
||||||
This bridge listens on :8772 (Android emulator reaches the Mac host at
|
|
||||||
10.0.2.2), translates CTAPHID frames into CardEmulator calls, and sends
|
|
||||||
framed CTAPHID responses back.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \\
|
|
||||||
tests/card_emulator_bridge.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import cbor2
|
|
||||||
from fido2.ctap import CtapError
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
from card_emulator import CardEmulator
|
|
||||||
|
|
||||||
LOG = logging.getLogger("bridge")
|
|
||||||
|
|
||||||
# CTAPHID constants
|
|
||||||
_BROADCAST_CID = 0xFFFFFFFF
|
|
||||||
_CMD_INIT = 0x06
|
|
||||||
_CMD_CBOR = 0x10
|
|
||||||
_CMD_ERROR = 0x3F
|
|
||||||
_CMD_KEEPALIVE = 0x3B
|
|
||||||
|
|
||||||
_HID_SIZE = 64
|
|
||||||
_INIT_PAYLOAD = _HID_SIZE - 7 # 57 bytes usable in an init packet
|
|
||||||
_CONT_PAYLOAD = _HID_SIZE - 5 # 59 bytes usable in a continuation packet
|
|
||||||
|
|
||||||
# CTAP2 authenticator command codes (first byte of CTAPHID_CBOR payload)
|
|
||||||
_CTAP2_MAKE_CREDENTIAL = 0x01
|
|
||||||
_CTAP2_GET_ASSERTION = 0x02
|
|
||||||
_CTAP2_GET_INFO = 0x04
|
|
||||||
|
|
||||||
# CTAP error codes
|
|
||||||
_ERR_INVALID_CMD = 0x01
|
|
||||||
_ERR_INVALID_LEN = 0x03
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Packet helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _pack(cid: int, cmd: int, payload: bytes) -> list[bytes]:
|
|
||||||
"""Fragment payload into CTAPHID init + continuation packets."""
|
|
||||||
packets: list[bytes] = []
|
|
||||||
cid_b = struct.pack(">I", cid)
|
|
||||||
|
|
||||||
init = bytearray(_HID_SIZE)
|
|
||||||
init[:4] = cid_b
|
|
||||||
init[4] = (cmd & 0x7F) | 0x80
|
|
||||||
init[5] = (len(payload) >> 8) & 0xFF
|
|
||||||
init[6] = len(payload) & 0xFF
|
|
||||||
first_chunk = payload[:_INIT_PAYLOAD]
|
|
||||||
init[7: 7 + len(first_chunk)] = first_chunk
|
|
||||||
packets.append(bytes(init))
|
|
||||||
|
|
||||||
offset, seq = len(first_chunk), 0
|
|
||||||
while offset < len(payload):
|
|
||||||
cont = bytearray(_HID_SIZE)
|
|
||||||
cont[:4] = cid_b
|
|
||||||
cont[4] = seq & 0x7F
|
|
||||||
chunk = payload[offset: offset + _CONT_PAYLOAD]
|
|
||||||
cont[5: 5 + len(chunk)] = chunk
|
|
||||||
packets.append(bytes(cont))
|
|
||||||
offset += len(chunk)
|
|
||||||
seq += 1
|
|
||||||
|
|
||||||
return packets
|
|
||||||
|
|
||||||
|
|
||||||
def _error_pkt(cid: int, code: int) -> bytes:
|
|
||||||
return _pack(cid, _CMD_ERROR, bytes([code]))[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _keepalive_pkt(cid: int) -> bytes:
|
|
||||||
# Status 0x02 = TUP_NEEDED (card is processing)
|
|
||||||
return _pack(cid, _CMD_KEEPALIVE, bytes([0x02]))[0]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Per-connection handler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _Handler:
|
|
||||||
"""Handles one Android emulator TCP connection."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
reader: asyncio.StreamReader,
|
|
||||||
writer: asyncio.StreamWriter,
|
|
||||||
emulator: CardEmulator,
|
|
||||||
) -> None:
|
|
||||||
self._r = reader
|
|
||||||
self._w = writer
|
|
||||||
self._emulator = emulator
|
|
||||||
self._allocated_cid = 1 # fixed CID for single-connection bridge
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
peer = self._w.get_extra_info("peername")
|
|
||||||
LOG.info("connect from %s", peer)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
cid, cmd, data = await self._recv_message()
|
|
||||||
await self._dispatch(cid, cmd, data)
|
|
||||||
except (asyncio.IncompleteReadError, ConnectionResetError, EOFError):
|
|
||||||
LOG.info("disconnect from %s", peer)
|
|
||||||
except Exception:
|
|
||||||
LOG.exception("handler error")
|
|
||||||
finally:
|
|
||||||
self._w.close()
|
|
||||||
try:
|
|
||||||
await self._w.wait_closed()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ---- I/O ----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _recv_pkt(self) -> bytes:
|
|
||||||
return await self._r.readexactly(_HID_SIZE)
|
|
||||||
|
|
||||||
async def _send(self, packets: list[bytes]) -> None:
|
|
||||||
for pkt in packets:
|
|
||||||
self._w.write(pkt)
|
|
||||||
await self._w.drain()
|
|
||||||
|
|
||||||
# ---- Message reassembly -------------------------------------------------
|
|
||||||
|
|
||||||
async def _recv_message(self) -> tuple[int, int, bytes]:
|
|
||||||
"""Read one full CTAPHID message (init + any continuations).
|
|
||||||
|
|
||||||
After every non-final packet the Dart client is blocked waiting for a
|
|
||||||
response. We send a keepalive so it resumes and sends the next packet.
|
|
||||||
"""
|
|
||||||
init_pkt = await self._recv_pkt()
|
|
||||||
|
|
||||||
cid = struct.unpack(">I", init_pkt[:4])[0]
|
|
||||||
cmd = init_pkt[4] & 0x7F
|
|
||||||
bcnt = (init_pkt[5] << 8) | init_pkt[6]
|
|
||||||
|
|
||||||
buf = bytearray(init_pkt[7: 7 + min(bcnt, _INIT_PAYLOAD)])
|
|
||||||
|
|
||||||
while len(buf) < bcnt:
|
|
||||||
# Unblock Dart's _sendPacket which is awaiting a response.
|
|
||||||
self._w.write(_keepalive_pkt(cid))
|
|
||||||
await self._w.drain()
|
|
||||||
|
|
||||||
cont = await self._recv_pkt()
|
|
||||||
remaining = bcnt - len(buf)
|
|
||||||
buf.extend(cont[5: 5 + min(remaining, _CONT_PAYLOAD)])
|
|
||||||
|
|
||||||
return cid, cmd, bytes(buf)
|
|
||||||
|
|
||||||
# ---- Dispatch -----------------------------------------------------------
|
|
||||||
|
|
||||||
async def _dispatch(self, cid: int, cmd: int, data: bytes) -> None:
|
|
||||||
if cmd == _CMD_INIT:
|
|
||||||
await self._handle_init(cid, data)
|
|
||||||
elif cmd == _CMD_CBOR:
|
|
||||||
await self._handle_cbor(cid, data)
|
|
||||||
else:
|
|
||||||
LOG.warning("unknown CTAPHID cmd=0x%02x cid=0x%08x", cmd, cid)
|
|
||||||
await self._send([_error_pkt(cid, _ERR_INVALID_CMD)])
|
|
||||||
|
|
||||||
# ---- CTAPHID INIT -------------------------------------------------------
|
|
||||||
|
|
||||||
async def _handle_init(self, cid: int, data: bytes) -> None:
|
|
||||||
if len(data) < 8:
|
|
||||||
await self._send([_error_pkt(cid, _ERR_INVALID_LEN)])
|
|
||||||
return
|
|
||||||
nonce = data[:8]
|
|
||||||
new_cid = self._allocated_cid
|
|
||||||
# Response payload: nonce(8) + new_cid(4) + CTAPHID_version(1)
|
|
||||||
# + major(1) + minor(1) + build(1) + capabilities(1)
|
|
||||||
payload = nonce + struct.pack(">I", new_cid) + bytes([2, 1, 0, 0, 0x04])
|
|
||||||
await self._send(_pack(_BROADCAST_CID, _CMD_INIT, payload))
|
|
||||||
LOG.info("INIT → CID=0x%08x", new_cid)
|
|
||||||
|
|
||||||
# ---- CTAPHID CBOR (CTAP2) -----------------------------------------------
|
|
||||||
|
|
||||||
async def _handle_cbor(self, cid: int, data: bytes) -> None:
|
|
||||||
if not data:
|
|
||||||
await self._send([_error_pkt(cid, _ERR_INVALID_LEN)])
|
|
||||||
return
|
|
||||||
|
|
||||||
ctap2_cmd = data[0]
|
|
||||||
body = data[1:] if len(data) > 1 else b""
|
|
||||||
|
|
||||||
LOG.info("CTAP2 cmd=0x%02x body=%d bytes", ctap2_cmd, len(body))
|
|
||||||
|
|
||||||
try:
|
|
||||||
if ctap2_cmd == _CTAP2_MAKE_CREDENTIAL:
|
|
||||||
resp_cbor = self._make_credential(body)
|
|
||||||
elif ctap2_cmd == _CTAP2_GET_ASSERTION:
|
|
||||||
resp_cbor = self._get_assertion(body)
|
|
||||||
elif ctap2_cmd == _CTAP2_GET_INFO:
|
|
||||||
resp_cbor = self._get_info()
|
|
||||||
else:
|
|
||||||
LOG.warning("unsupported CTAP2 cmd=0x%02x", ctap2_cmd)
|
|
||||||
await self._send([_error_pkt(cid, _ERR_INVALID_CMD)])
|
|
||||||
return
|
|
||||||
except CtapError as exc:
|
|
||||||
code = exc.code.value if hasattr(exc.code, "value") else int(exc.code)
|
|
||||||
LOG.warning("CtapError 0x%02x: %s", code, exc)
|
|
||||||
await self._send(_pack(cid, _CMD_CBOR, bytes([code])))
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
LOG.exception("CTAP2 processing error")
|
|
||||||
await self._send(_pack(cid, _CMD_CBOR, bytes([0x01])))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Success: status 0x00 + CBOR-encoded response map
|
|
||||||
await self._send(_pack(cid, _CMD_CBOR, bytes([0x00]) + resp_cbor))
|
|
||||||
|
|
||||||
# ---- CTAP2 operations ---------------------------------------------------
|
|
||||||
|
|
||||||
def _make_credential(self, body: bytes) -> bytes:
|
|
||||||
params: dict[Any, Any] = cbor2.loads(body)
|
|
||||||
|
|
||||||
# CTAP2 spec integer keys: 1=clientDataHash, 2=rp, 3=user,
|
|
||||||
# 4=pubKeyCredParams, 7=options
|
|
||||||
client_data_hash: bytes = bytes(params[1])
|
|
||||||
rp: dict = dict(params[2])
|
|
||||||
user: dict = dict(params[3])
|
|
||||||
key_params: list = [dict(kp) for kp in params[4]]
|
|
||||||
options: dict = dict(params.get(7, {}))
|
|
||||||
|
|
||||||
LOG.info("makeCredential rp_id=%r user=%r", rp.get("id"), user.get("name"))
|
|
||||||
|
|
||||||
resp = self._emulator.make_credential(
|
|
||||||
client_data_hash=client_data_hash,
|
|
||||||
rp=rp,
|
|
||||||
user=user,
|
|
||||||
key_params=key_params,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_data_bytes = bytes(resp.auth_data)
|
|
||||||
LOG.info("makeCredential OK auth_data=%d bytes", len(auth_data_bytes))
|
|
||||||
|
|
||||||
# CTAP2 makeCredential response map: 1=fmt, 2=authData, 3=attStmt
|
|
||||||
return cbor2.dumps({
|
|
||||||
1: resp.fmt,
|
|
||||||
2: auth_data_bytes,
|
|
||||||
3: resp.att_stmt or {},
|
|
||||||
})
|
|
||||||
|
|
||||||
def _get_assertion(self, body: bytes) -> bytes:
|
|
||||||
params: dict[Any, Any] = cbor2.loads(body)
|
|
||||||
|
|
||||||
# CTAP2 spec integer keys: 1=rpId, 2=clientDataHash, 3=allowList, 5=options
|
|
||||||
rp_id: str = params[1]
|
|
||||||
client_data_hash: bytes = bytes(params[2])
|
|
||||||
allow_list_raw: list = list(params.get(3, []))
|
|
||||||
options: dict = dict(params.get(5, {}))
|
|
||||||
|
|
||||||
allow_list = [dict(item) for item in allow_list_raw] or None
|
|
||||||
|
|
||||||
LOG.info("getAssertion rp_id=%r allow_list_len=%s",
|
|
||||||
rp_id, len(allow_list) if allow_list else 0)
|
|
||||||
|
|
||||||
resp = self._emulator.get_assertion(
|
|
||||||
rp_id=rp_id,
|
|
||||||
client_data_hash=client_data_hash,
|
|
||||||
allow_list=allow_list,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_data_bytes = bytes(resp.auth_data)
|
|
||||||
signature = bytes(resp.signature)
|
|
||||||
|
|
||||||
# Build credential descriptor for key 1
|
|
||||||
cred = resp.credential
|
|
||||||
if hasattr(cred, "id"):
|
|
||||||
cred_map: dict = {"type": "public-key", "id": bytes(cred.id)}
|
|
||||||
else:
|
|
||||||
cred_map = {
|
|
||||||
k: (bytes(v) if isinstance(v, (bytes, bytearray, memoryview)) else v)
|
|
||||||
for k, v in (cred or {}).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG.info("getAssertion OK auth_data=%d bytes sig=%d bytes",
|
|
||||||
len(auth_data_bytes), len(signature))
|
|
||||||
|
|
||||||
# CTAP2 getAssertion response map: 1=credential, 2=authData, 3=signature
|
|
||||||
resp_map: dict[int, Any] = {1: cred_map, 2: auth_data_bytes, 3: signature}
|
|
||||||
if resp.user:
|
|
||||||
u = resp.user
|
|
||||||
resp_map[4] = dict(u) if hasattr(u, "items") else u
|
|
||||||
|
|
||||||
return cbor2.dumps(resp_map)
|
|
||||||
|
|
||||||
def _get_info(self) -> bytes:
|
|
||||||
# Minimal getInfo response
|
|
||||||
return cbor2.dumps({
|
|
||||||
1: ["FIDO_2_0"], # versions
|
|
||||||
3: b"\x00" * 16, # aaguid
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Server bootstrap
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _serve(host: str, port: int) -> None:
|
|
||||||
emulator = CardEmulator()
|
|
||||||
LOG.info("card_emulator_bridge listening on %s:%d — ctrl-C to stop", host, port)
|
|
||||||
|
|
||||||
async def _on_connect(
|
|
||||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
||||||
) -> None:
|
|
||||||
await _Handler(reader, writer, emulator).run()
|
|
||||||
|
|
||||||
server = await asyncio.start_server(_on_connect, host, port)
|
|
||||||
async with server:
|
|
||||||
await server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
def _main() -> None:
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="CTAPHID TCP bridge for Android emulator ↔ CardEmulator"
|
|
||||||
)
|
|
||||||
ap.add_argument("--host", default="0.0.0.0", help="Listen host (default 0.0.0.0)")
|
|
||||||
ap.add_argument("--port", type=int, default=8772, help="Listen port (default 8772)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)s %(message)s",
|
|
||||||
)
|
|
||||||
asyncio.run(_serve(args.host, args.port))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
_main()
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
/**
|
|
||||||
* Acceptance tests for k_phone proxy routing — Chrome inside the Android emulator.
|
|
||||||
*
|
|
||||||
* Same four serial tests as k_phone_proxy.spec.js, but the browser runs inside
|
|
||||||
* the emulator via Playwright's Android module. From inside the emulator
|
|
||||||
* 127.0.0.1:8888 IS Component 1 (filter_proxy.dart) — no adb port-forward needed.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* 1. Android emulator running with the k_phone app started.
|
|
||||||
* 2. ADB connected: adb devices shows the emulator.
|
|
||||||
* 3. card_emulator_bridge.py running on the Mac (auto-approves FIDO2 assertions):
|
|
||||||
* uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
|
|
||||||
* tests/card_emulator_bridge.py
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* npx playwright test tests/k_phone_android.spec.js
|
|
||||||
* npx playwright test tests/k_phone_android.spec.js --headed # shows emulator Chrome
|
|
||||||
*
|
|
||||||
* Tests skip automatically if no Android device/emulator is found via ADB.
|
|
||||||
*
|
|
||||||
* Env vars:
|
|
||||||
* GATED_URL URL of a gated resource (default: http://httpbin.org/get)
|
|
||||||
* GATED_METHOD HTTP method for gated request (default: GET)
|
|
||||||
* UNGATED_URL URL of a non-gated resource (default: http://example.com)
|
|
||||||
* CARD_REGISTRATION_TIMEOUT_MS (default: 90000)
|
|
||||||
* CARD_LOGIN_TIMEOUT_MS (default: 90000)
|
|
||||||
*
|
|
||||||
* Note on proxy bypass:
|
|
||||||
* Chrome bypasses --proxy-server for 127.0.0.1 / localhost by default.
|
|
||||||
* Portal API calls (127.0.0.1:8771) therefore reach Component 2 directly.
|
|
||||||
* External host requests (httpbin.org, example.com) go through Component 1.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { test, expect, chromium } = require('@playwright/test');
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
const ADB = (() => {
|
|
||||||
const candidates = [
|
|
||||||
process.env.ADB,
|
|
||||||
`${process.env.HOME}/Library/Android/sdk/platform-tools/adb`,
|
|
||||||
'/usr/local/bin/adb',
|
|
||||||
].filter(Boolean);
|
|
||||||
for (const p of candidates) {
|
|
||||||
try { execSync(`"${p}" version`, { stdio: 'pipe' }); return p; } catch {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const CDP_PORT = 9222;
|
|
||||||
|
|
||||||
const GATED_URL = process.env.GATED_URL || 'http://httpbin.org/get';
|
|
||||||
const GATED_METHOD = (process.env.GATED_METHOD || 'GET').toUpperCase();
|
|
||||||
const UNGATED_URL = process.env.UNGATED_URL || 'http://example.com';
|
|
||||||
|
|
||||||
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || '90000');
|
|
||||||
const cardAssertionTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || '90000');
|
|
||||||
|
|
||||||
// Component 2 is always at 127.0.0.1:8771 from inside the emulator.
|
|
||||||
const PORTAL = 'http://127.0.0.1:8771';
|
|
||||||
|
|
||||||
function uniqueUsername() {
|
|
||||||
return `pw_${Date.now().toString(36)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// chromeFetch — makes an HTTP request from inside Android Chrome.
|
|
||||||
//
|
|
||||||
// fetch() in the page context uses Chrome's --proxy-server for external hosts
|
|
||||||
// and bypasses the proxy for 127.0.0.1. Returns { status, ok, body }.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function chromeFetch(page, url, { method = 'GET', data = null, timeoutMs = 15_000 } = {}) {
|
|
||||||
return page.evaluate(
|
|
||||||
async ({ url, method, data, timeoutMs }) => {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const tid = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const opts = { method, signal: ctrl.signal };
|
|
||||||
if (data !== null) {
|
|
||||||
opts.headers = { 'Content-Type': 'application/json' };
|
|
||||||
opts.body = JSON.stringify(data);
|
|
||||||
}
|
|
||||||
const r = await fetch(url, opts);
|
|
||||||
clearTimeout(tid);
|
|
||||||
let body = null;
|
|
||||||
try { body = await r.clone().json(); } catch { body = await r.text(); }
|
|
||||||
return { status: r.status, ok: r.ok, body };
|
|
||||||
} catch (e) {
|
|
||||||
clearTimeout(tid);
|
|
||||||
return { status: 0, ok: false, error: e.message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ url, method, data, timeoutMs },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Suite
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test.describe.serial('k_phone proxy routing — Android Chrome', () => {
|
|
||||||
let browser = null; // CDP-connected browser (chromium.connectOverCDP)
|
|
||||||
let proxyCtx = null; // BrowserContext for test pages
|
|
||||||
let skipReason = null;
|
|
||||||
let enrolledUser = null;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
if (!ADB) {
|
|
||||||
skipReason = 'adb not found — install Android SDK platform-tools';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const devicesOut = execSync(`"${ADB}" devices`, { stdio: 'pipe' }).toString();
|
|
||||||
const connected = devicesOut.split('\n').slice(1).some(l => l.includes('\tdevice'));
|
|
||||||
if (!connected) {
|
|
||||||
skipReason = 'No Android emulator connected — run: adb devices';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write --proxy-server flag into Chrome's command-line file, then restart.
|
|
||||||
// Chrome on Android reads /data/local/tmp/chrome-command-line on startup.
|
|
||||||
execSync(`"${ADB}" shell "echo 'chrome --proxy-server=127.0.0.1:8888' > /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
|
|
||||||
execSync(`"${ADB}" shell "chmod 644 /data/local/tmp/chrome-command-line"`, { stdio: 'pipe' });
|
|
||||||
execSync(`"${ADB}" shell am force-stop com.android.chrome`, { stdio: 'pipe' });
|
|
||||||
await new Promise(r => setTimeout(r, 1200));
|
|
||||||
execSync(`"${ADB}" shell am start -n com.android.chrome/com.google.android.apps.chrome.Main about:blank`, { stdio: 'pipe' });
|
|
||||||
|
|
||||||
// Poll until Chrome's CDP socket appears (up to 15 s).
|
|
||||||
execSync(`"${ADB}" forward tcp:${CDP_PORT} localabstract:chrome_devtools_remote`, { stdio: 'pipe' });
|
|
||||||
browser = null;
|
|
||||||
for (let attempt = 0; attempt < 15; attempt++) {
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
|
||||||
try {
|
|
||||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`, { timeout: 3000 });
|
|
||||||
break;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (!browser) throw new Error('Chrome CDP not ready after 15 s');
|
|
||||||
proxyCtx = browser.contexts()[0] ?? await browser.newContext();
|
|
||||||
|
|
||||||
// Clean state: delete any users left from previous runs.
|
|
||||||
// 127.0.0.1 bypasses the proxy, so these calls reach Component 2 directly.
|
|
||||||
const page = await proxyCtx.newPage();
|
|
||||||
const list = await chromeFetch(page, `${PORTAL}/enroll/list`);
|
|
||||||
for (const u of list.body?.users ?? []) {
|
|
||||||
await chromeFetch(page, `${PORTAL}/enroll/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
data: { username: u.username },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await page.close();
|
|
||||||
} catch (e) {
|
|
||||||
skipReason = `Android setup failed: ${e.message}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Skip every test in the suite if the emulator was not found.
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
if (skipReason) test.skip(true, skipReason);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
if (enrolledUser && proxyCtx) {
|
|
||||||
const page = await proxyCtx.newPage().catch(() => null);
|
|
||||||
if (page) {
|
|
||||||
await chromeFetch(page, `${PORTAL}/enroll/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
data: { username: enrolledUser },
|
|
||||||
}).catch(() => {});
|
|
||||||
await page.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await browser?.close().catch(() => {});
|
|
||||||
try { execSync(`"${ADB}" forward --remove tcp:${CDP_PORT}`, { stdio: 'pipe' }); } catch {}
|
|
||||||
try { execSync(`"${ADB}" shell rm /data/local/tmp/chrome-command-line`, { stdio: 'pipe' }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 1: no users — non-gated request passes through.
|
|
||||||
//
|
|
||||||
// Chrome navigates to a non-gated host. Component 1 forwards the traffic
|
|
||||||
// directly without contacting Component 2 or the card.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('1. no users: non-gated request passes through', async () => {
|
|
||||||
const page = await proxyCtx.newPage();
|
|
||||||
try {
|
|
||||||
const response = await page.goto(UNGATED_URL, {
|
|
||||||
timeout: 15_000,
|
|
||||||
waitUntil: 'commit',
|
|
||||||
});
|
|
||||||
expect(response?.status()).toBeLessThan(500);
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 2: no users — gated request blocked.
|
|
||||||
//
|
|
||||||
// Component 1 asks Component 2 for a token; Component 2 finds no enrolled
|
|
||||||
// user and returns an error; Component 1 responds 407. Chrome's fetch()
|
|
||||||
// surfaces this as either a 407 response or a network error.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('2. no users: gated request blocked', async () => {
|
|
||||||
const page = await proxyCtx.newPage();
|
|
||||||
try {
|
|
||||||
const result = await chromeFetch(page, GATED_URL, { method: GATED_METHOD });
|
|
||||||
// 407 (proxy auth required) or status 0 (network error) both mean blocked.
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 3: register user — non-gated request still passes through.
|
|
||||||
//
|
|
||||||
// Card step: makeCredential. card_emulator_bridge.py auto-approves instantly
|
|
||||||
// — no physical fingerprint touch needed.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('3. enroll user: non-gated request still passes through', async () => {
|
|
||||||
test.setTimeout(registrationTimeoutMs + 30_000);
|
|
||||||
|
|
||||||
enrolledUser = uniqueUsername();
|
|
||||||
const page = await proxyCtx.newPage();
|
|
||||||
try {
|
|
||||||
// The portal at 127.0.0.1 bypasses the proxy and loads directly from Component 2.
|
|
||||||
await page.goto(`${PORTAL}/`);
|
|
||||||
await page.locator('#username').fill(enrolledUser);
|
|
||||||
await page.locator('#displayName').fill('Android Chrome Test');
|
|
||||||
await page.locator('#enrollBtn').click();
|
|
||||||
await expect(page.locator('#log')).toContainText('Enrolled', {
|
|
||||||
timeout: registrationTimeoutMs,
|
|
||||||
});
|
|
||||||
await expect(page.locator('#storedUser')).toHaveText(enrolledUser);
|
|
||||||
|
|
||||||
// Non-gated traffic must still be forwarded directly after enrollment.
|
|
||||||
const response = await page.goto(UNGATED_URL, {
|
|
||||||
timeout: 15_000,
|
|
||||||
waitUntil: 'commit',
|
|
||||||
});
|
|
||||||
expect(response?.status()).toBeLessThan(500);
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 4: with enrolled user — gated request succeeds after card assertion.
|
|
||||||
//
|
|
||||||
// Card step: getAssertion. card_emulator_bridge.py auto-approves instantly.
|
|
||||||
//
|
|
||||||
// fetch() inside Chrome flows:
|
|
||||||
// Chrome → Component 1 (127.0.0.1:8888) → POST /auth/get-token →
|
|
||||||
// Component 2 → card emulator bridge (10.0.2.2:8772) → assertion bundle →
|
|
||||||
// Component 1 → gated endpoint with Authorization: Bearer → 200 response.
|
|
||||||
//
|
|
||||||
// Verification:
|
|
||||||
// httpbin.org echoes the Authorization: Bearer header back in the JSON body.
|
|
||||||
// k_server (GATED_URL=http://k-server-ip:8780/resource/counter) validates
|
|
||||||
// the assertion cryptographically and returns {ok, resource, value}.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test('4. enrolled user: gated request succeeds — card asserted', async () => {
|
|
||||||
test.setTimeout(cardAssertionTimeoutMs + 30_000);
|
|
||||||
|
|
||||||
const page = await proxyCtx.newPage();
|
|
||||||
try {
|
|
||||||
const result = await chromeFetch(page, GATED_URL, {
|
|
||||||
method: GATED_METHOD,
|
|
||||||
timeoutMs: cardAssertionTimeoutMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 200 proves Component 2 performed the FIDO2 assertion successfully.
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
|
|
||||||
if (result.body?.headers?.Authorization !== undefined) {
|
|
||||||
// httpbin.org echoes request headers — the Bearer token must be present.
|
|
||||||
expect(result.body.headers.Authorization).toMatch(/^Bearer /i);
|
|
||||||
} else if (result.body?.resource !== undefined) {
|
|
||||||
// k_server validated the assertion token and incremented the counter.
|
|
||||||
expect(result.body.ok).toBe(true);
|
|
||||||
expect(result.body.resource).toBe('counter');
|
|
||||||
expect(typeof result.body.value).toBe('number');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,362 +0,0 @@
|
||||||
"""
|
|
||||||
Unit + round-trip tests for k_server_app._verify_assertion_token.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
|
|
||||||
python3 -m unittest tests/test_k_server.py
|
|
||||||
|
|
||||||
The unit tests (TestVerifyAssertionToken) only need cbor2 + cryptography.
|
|
||||||
The round-trip tests (TestVerifyAssertionTokenRoundTrip) also need fido2
|
|
||||||
(through CardEmulator) — they are skipped automatically if fido2 is absent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
|
|
||||||
import k_server_app
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dependency guards
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
try:
|
|
||||||
import cbor2 # noqa: F401
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
||||||
ECDSA,
|
|
||||||
SECP256R1,
|
|
||||||
generate_private_key,
|
|
||||||
)
|
|
||||||
from cryptography.hazmat.primitives.hashes import SHA256
|
|
||||||
HAS_CRYPTO = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_CRYPTO = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from card_emulator import CardEmulator
|
|
||||||
HAS_FIDO2 = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_FIDO2 = False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _b64u_encode(b: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
||||||
|
|
||||||
|
|
||||||
def _b64u_decode(s: str) -> bytes:
|
|
||||||
padded = s + "=" * ((4 - len(s) % 4) % 4)
|
|
||||||
return base64.urlsafe_b64decode(padded)
|
|
||||||
|
|
||||||
|
|
||||||
# COSE ES256 key layout matching card_emulator._cose_es256 exactly.
|
|
||||||
def _cose_es256(x: bytes, y: bytes) -> bytes:
|
|
||||||
return (
|
|
||||||
bytes([0xA5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20])
|
|
||||||
+ x
|
|
||||||
+ bytes([0x22, 0x58, 0x20])
|
|
||||||
+ y
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_bundle(host: str, nonce: str) -> tuple[str, object]:
|
|
||||||
"""Return (base64url-token, private_key) for a fresh P-256 assertion.
|
|
||||||
|
|
||||||
Mirrors exactly what proxy_service.dart's _handleAuthGetToken produces.
|
|
||||||
"""
|
|
||||||
priv = generate_private_key(SECP256R1(), default_backend())
|
|
||||||
pub = priv.public_key().public_numbers()
|
|
||||||
x = pub.x.to_bytes(32, "big")
|
|
||||||
y = pub.y.to_bytes(32, "big")
|
|
||||||
|
|
||||||
cose_key = _cose_es256(x, y)
|
|
||||||
|
|
||||||
# AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey
|
|
||||||
aaguid = bytes.fromhex("1234567890abcdef0123456789abcdef")
|
|
||||||
cred_id = os.urandom(16)
|
|
||||||
cred_data = aaguid + struct.pack(">H", len(cred_id)) + cred_id + cose_key
|
|
||||||
|
|
||||||
# Challenge = SHA256(host|nonce) — same as proxy_service.dart
|
|
||||||
challenge_b64u = _b64u_encode(hashlib.sha256(f"{host}|{nonce}".encode()).digest())
|
|
||||||
|
|
||||||
cdj = json.dumps(
|
|
||||||
{
|
|
||||||
"type": "webauthn.get",
|
|
||||||
"challenge": challenge_b64u,
|
|
||||||
"origin": "https://localhost",
|
|
||||||
"crossOrigin": False,
|
|
||||||
},
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
cdj_bytes = cdj.encode()
|
|
||||||
cdh = hashlib.sha256(cdj_bytes).digest()
|
|
||||||
|
|
||||||
# authData: rpIdHash(32) + flags(1) + signCount(4)
|
|
||||||
auth_data = hashlib.sha256(b"localhost").digest() + b"\x01" + struct.pack(">I", 1)
|
|
||||||
|
|
||||||
sig = priv.sign(auth_data + cdh, ECDSA(SHA256()))
|
|
||||||
|
|
||||||
bundle = {
|
|
||||||
"v": 1,
|
|
||||||
"host": host,
|
|
||||||
"nonce": nonce,
|
|
||||||
"authData": _b64u_encode(auth_data),
|
|
||||||
"sig": _b64u_encode(sig),
|
|
||||||
"cdj": _b64u_encode(cdj_bytes),
|
|
||||||
"cred": _b64u_encode(cred_data),
|
|
||||||
"user": "testuser",
|
|
||||||
}
|
|
||||||
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode()), priv
|
|
||||||
|
|
||||||
|
|
||||||
def _tamper(token: str, key: str, transform) -> str:
|
|
||||||
bundle = json.loads(_b64u_decode(token))
|
|
||||||
bundle[key] = transform(bundle[key])
|
|
||||||
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Group 1 — unit tests (cbor2 + cryptography only)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@unittest.skipUnless(HAS_CRYPTO, "cbor2 / cryptography not installed")
|
|
||||||
class TestVerifyAssertionToken(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.host = "127.0.0.1"
|
|
||||||
self.nonce = "deadbeef01234567"
|
|
||||||
self.token, _ = _make_bundle(self.host, self.nonce)
|
|
||||||
|
|
||||||
def _check(self, token=None, host=None) -> bool:
|
|
||||||
return k_server_app._verify_assertion_token(
|
|
||||||
self.token if token is None else token,
|
|
||||||
host if host is not None else self.host,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_valid_token_accepted(self):
|
|
||||||
self.assertTrue(self._check())
|
|
||||||
|
|
||||||
def test_token_valid_for_any_path_on_host(self):
|
|
||||||
# Domain-level binding: the same token covers all paths on the host.
|
|
||||||
self.assertTrue(self._check())
|
|
||||||
|
|
||||||
def test_cross_server_replay_rejected(self):
|
|
||||||
# Token issued for self.host must not pass when a different server verifies it.
|
|
||||||
self.assertFalse(self._check(host="other-server.com"))
|
|
||||||
|
|
||||||
def test_cross_server_replay_case_insensitive(self):
|
|
||||||
# Case variation of the expected host still rejects a token for a different host.
|
|
||||||
token_b, _ = _make_bundle("BANK.com", self.nonce)
|
|
||||||
self.assertFalse(k_server_app._verify_assertion_token(token_b, "evil.com"))
|
|
||||||
|
|
||||||
def test_tampered_nonce_invalidates_challenge(self):
|
|
||||||
tampered = _tamper(self.token, "nonce", lambda _: "tampered00000000")
|
|
||||||
self.assertFalse(self._check(tampered))
|
|
||||||
|
|
||||||
def test_tampered_host_invalidates_challenge(self):
|
|
||||||
tampered = _tamper(self.token, "host", lambda _: "attacker.com")
|
|
||||||
self.assertFalse(self._check(tampered))
|
|
||||||
|
|
||||||
def test_tampered_signature_rejected(self):
|
|
||||||
def flip_last_byte(b64: str) -> str:
|
|
||||||
raw = bytearray(_b64u_decode(b64))
|
|
||||||
raw[-1] ^= 0xFF
|
|
||||||
return _b64u_encode(bytes(raw))
|
|
||||||
|
|
||||||
tampered = _tamper(self.token, "sig", flip_last_byte)
|
|
||||||
self.assertFalse(self._check(tampered))
|
|
||||||
|
|
||||||
def test_wrong_public_key_rejected(self):
|
|
||||||
other = generate_private_key(SECP256R1(), default_backend())
|
|
||||||
pub = other.public_key().public_numbers()
|
|
||||||
x = pub.x.to_bytes(32, "big")
|
|
||||||
y = pub.y.to_bytes(32, "big")
|
|
||||||
new_cose = _cose_es256(x, y)
|
|
||||||
|
|
||||||
def swap_key(b64: str) -> str:
|
|
||||||
orig = _b64u_decode(b64)
|
|
||||||
cred_id_len = (orig[16] << 8) | orig[17]
|
|
||||||
return _b64u_encode(orig[:18 + cred_id_len] + new_cose)
|
|
||||||
|
|
||||||
tampered = _tamper(self.token, "cred", swap_key)
|
|
||||||
self.assertFalse(self._check(tampered))
|
|
||||||
|
|
||||||
def test_malformed_token_returns_false(self):
|
|
||||||
self.assertFalse(self._check(token="!!!not-base64!!!"))
|
|
||||||
|
|
||||||
def test_empty_token_returns_false(self):
|
|
||||||
self.assertFalse(self._check(token=""))
|
|
||||||
|
|
||||||
def test_missing_field_returns_false(self):
|
|
||||||
bundle = json.loads(_b64u_decode(self.token))
|
|
||||||
del bundle["sig"]
|
|
||||||
truncated = _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
|
|
||||||
self.assertFalse(self._check(truncated))
|
|
||||||
|
|
||||||
def test_cdj_wrong_type_rejected(self):
|
|
||||||
bundle = json.loads(_b64u_decode(self.token))
|
|
||||||
cdj = json.loads(_b64u_decode(bundle["cdj"]))
|
|
||||||
cdj["type"] = "webauthn.create" # wrong type
|
|
||||||
bundle["cdj"] = _b64u_encode(json.dumps(cdj, separators=(",", ":")).encode())
|
|
||||||
tampered = _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
|
|
||||||
self.assertFalse(self._check(tampered))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Group 2 — end-to-end round-trip via CardEmulator (needs fido2)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@unittest.skipUnless(HAS_FIDO2, "fido2 not installed")
|
|
||||||
class TestVerifyAssertionTokenRoundTrip(unittest.TestCase):
|
|
||||||
"""Full round-trip: CardEmulator → assertion bundle → server verification.
|
|
||||||
|
|
||||||
Mirrors the actual k_phone flow:
|
|
||||||
make_credential (enrollment) → get_assertion (per-request binding)
|
|
||||||
→ bundle as k_server_app.py expects → _verify_assertion_token.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _register_and_assert(
|
|
||||||
self,
|
|
||||||
emulator: "CardEmulator",
|
|
||||||
host: str,
|
|
||||||
nonce: str,
|
|
||||||
) -> str:
|
|
||||||
"""Return a token string after one register + one assertion."""
|
|
||||||
# 1. Register — mirrors makeCredential in fido2_ops.dart
|
|
||||||
reg_cdh = hashlib.sha256(b"registration-placeholder").digest()
|
|
||||||
attest = emulator.make_credential(
|
|
||||||
client_data_hash=reg_cdh,
|
|
||||||
rp={"id": "localhost", "name": "ChromeCard"},
|
|
||||||
user={"id": b"testuid", "name": "alice"},
|
|
||||||
key_params=[{"type": "public-key", "alg": -7}],
|
|
||||||
)
|
|
||||||
# AttestedCredentialData = authData[37:]
|
|
||||||
auth_data_make = bytes(attest.auth_data)
|
|
||||||
cred_data = auth_data_make[37:]
|
|
||||||
cred_id_len = (cred_data[16] << 8) | cred_data[17]
|
|
||||||
cred_id = cred_data[18:18 + cred_id_len]
|
|
||||||
|
|
||||||
# 2. Assert with domain-level challenge — mirrors _handleAuthGetToken in proxy_service.dart
|
|
||||||
challenge_b64u = _b64u_encode(
|
|
||||||
hashlib.sha256(f"{host}|{nonce}".encode()).digest()
|
|
||||||
)
|
|
||||||
cdj = json.dumps(
|
|
||||||
{
|
|
||||||
"type": "webauthn.get",
|
|
||||||
"challenge": challenge_b64u,
|
|
||||||
"origin": "https://localhost",
|
|
||||||
"crossOrigin": False,
|
|
||||||
},
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
cdj_bytes = cdj.encode()
|
|
||||||
assertion = emulator.get_assertion(
|
|
||||||
rp_id="localhost",
|
|
||||||
client_data_hash=hashlib.sha256(cdj_bytes).digest(),
|
|
||||||
allow_list=[{"type": "public-key", "id": cred_id}],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Encode bundle — same as proxy_service.dart _handleAuthGetToken
|
|
||||||
bundle = {
|
|
||||||
"v": 1,
|
|
||||||
"host": host,
|
|
||||||
"nonce": nonce,
|
|
||||||
"authData": _b64u_encode(bytes(assertion.auth_data)),
|
|
||||||
"sig": _b64u_encode(bytes(assertion.signature)),
|
|
||||||
"cdj": _b64u_encode(cdj_bytes),
|
|
||||||
"cred": _b64u_encode(cred_data),
|
|
||||||
"user": "alice",
|
|
||||||
}
|
|
||||||
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
|
|
||||||
|
|
||||||
def test_roundtrip_accepted(self):
|
|
||||||
emulator = CardEmulator()
|
|
||||||
token = self._register_and_assert(emulator, "example.com", "cafebabe12345678")
|
|
||||||
self.assertTrue(
|
|
||||||
k_server_app._verify_assertion_token(token, "example.com"),
|
|
||||||
"valid round-trip token must be accepted",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_roundtrip_valid_for_any_path(self):
|
|
||||||
"""Domain-level token: accepted regardless of which path is requested."""
|
|
||||||
emulator = CardEmulator()
|
|
||||||
token = self._register_and_assert(emulator, "example.com", "aabbccdd11223344")
|
|
||||||
self.assertTrue(
|
|
||||||
k_server_app._verify_assertion_token(token, "example.com"),
|
|
||||||
"domain-level token must be accepted for any path on the host",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_roundtrip_same_token_tampered_nonce_rejected(self):
|
|
||||||
"""Changing the nonce in a real assertion bundle breaks verification."""
|
|
||||||
emulator = CardEmulator()
|
|
||||||
token = self._register_and_assert(emulator, "example.com", "original00000000")
|
|
||||||
tampered = _tamper(token, "nonce", lambda _: "tampered11111111")
|
|
||||||
self.assertFalse(
|
|
||||||
k_server_app._verify_assertion_token(tampered, "example.com"),
|
|
||||||
"tampered nonce must break challenge verification",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_roundtrip_tampered_host_rejected(self):
|
|
||||||
"""Changing the host in the bundle breaks challenge verification."""
|
|
||||||
emulator = CardEmulator()
|
|
||||||
token = self._register_and_assert(emulator, "example.com", "deadbeef00112233")
|
|
||||||
tampered = _tamper(token, "host", lambda _: "attacker.com")
|
|
||||||
self.assertFalse(
|
|
||||||
k_server_app._verify_assertion_token(tampered, "example.com"),
|
|
||||||
"tampered host must break challenge verification",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_roundtrip_cross_server_replay_rejected(self):
|
|
||||||
"""Token issued for server-a must not validate on server-b."""
|
|
||||||
emulator = CardEmulator()
|
|
||||||
token = self._register_and_assert(emulator, "server-a.com", "1122334455667788")
|
|
||||||
self.assertFalse(
|
|
||||||
k_server_app._verify_assertion_token(token, "server-b.com"),
|
|
||||||
"cross-server replay: token for server-a must be rejected by server-b",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_roundtrip_cross_server_replay_accepted_on_correct_server(self):
|
|
||||||
"""Sanity: same token is accepted on the server it was issued for."""
|
|
||||||
emulator = CardEmulator()
|
|
||||||
token = self._register_and_assert(emulator, "server-a.com", "aabbccdd99887766")
|
|
||||||
self.assertTrue(
|
|
||||||
k_server_app._verify_assertion_token(token, "server-a.com"),
|
|
||||||
"token must still be valid on the correct server",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_roundtrip_replayed_for_different_user_rejected(self):
|
|
||||||
"""Two users register separate credentials; each token is only valid for its own key."""
|
|
||||||
em_a = CardEmulator()
|
|
||||||
em_b = CardEmulator()
|
|
||||||
host, nonce = "example.com", "00112233aabbccdd"
|
|
||||||
token_a = self._register_and_assert(em_a, host, nonce)
|
|
||||||
token_b = self._register_and_assert(em_b, host, nonce)
|
|
||||||
|
|
||||||
# token_a's signature was made with em_a's key — must not verify with em_b's public key.
|
|
||||||
# Tamper: swap cred (public key) from token_b into token_a's bundle.
|
|
||||||
bundle_a = json.loads(_b64u_decode(token_a))
|
|
||||||
bundle_b = json.loads(_b64u_decode(token_b))
|
|
||||||
bundle_a["cred"] = bundle_b["cred"]
|
|
||||||
cross = _b64u_encode(json.dumps(bundle_a, separators=(",", ":")).encode())
|
|
||||||
|
|
||||||
self.assertFalse(
|
|
||||||
k_server_app._verify_assertion_token(cross, host),
|
|
||||||
"cross-user key swap must fail verification",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
Loading…
Reference in New Issue