Compare commits

...

4 Commits

Author SHA1 Message Date
Morten V. Christiansen ddeed9b71e Merge remote history: unify diverged repos from machine switch
Local branch was started fresh on new Mac (2026-04-29) as a workspace
snapshot of the full project state, then extended with Phase 9 k_phone
work. Remote has the complete git history up to 2026-04-27.

This merge grafts the two histories together. Local content is preserved
in full (it is a strict superset of the remote state).
2026-05-04 09:00:52 +02:00
Morten V. Christiansen 328c7d7cae Add Component 2 CONNECT handler; fix CONNECT routing tests
proxy_service.dart: _handleConnect gates on hasAnyActiveSession() (407 if
no active session), then connects directly to the upstream external target
(host:port from Host header), detaches the socket, and pipes bytes
bidirectionally. k_server is not involved in CONNECT tunnels.

filter_proxy_test.dart: replace _mockTcp() with _mockComp2Tcp() in the
CONNECT routing group so the mock speaks the full CONNECT handshake
(reads request headers, sends 200 Connection Established, pauses sub).
All 21 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:22:24 +02:00
Morten V. Christiansen 1124a7f5a9 Phase 9: add Component 1 (filter_proxy), tests, session gate, doc update
- k_phone/lib/filter_proxy.dart: Component 1 — raw-socket HTTP proxy with
  gating filter; gated hosts relay to Component 2, others go direct
- k_phone/lib/session_manager.dart: add hasAnyActiveSession() for the
  personal-device gated-proxy authorization model
- k_phone/test/filter_proxy_test.dart: full test suite for Component 1
- k_phone/test/enrollment_test.dart: full test suite for EnrollmentDb
- k_phone/integration_test/registration_login_test.dart: emulator integration test
- Misc k_phone lib fixes (ctaphid_channel, fido2_ops, proxy_service, main,
  enrollment_db, k_server_client) and pubspec/Gradle updates
- CLAUDE.md + Workplan.md: document Component 1, k_phone module map,
  gated terminology (replacing "allowlist"), pending CONNECT handler in
  Component 2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:10:54 +02:00
Morten V. Christiansen 83a6382270 Initial commit: chromecard workspace snapshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 22:06:14 +02:00
35 changed files with 5797 additions and 42 deletions

16
.gitignore vendored Normal file → Executable file
View File

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

153
CLAUDE.md Normal file
View File

@ -0,0 +1,153 @@
# 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 browser configured to use the phone as HTTP/HTTPS proxy. No knowledge of the auth system.
- **Phone:** Central hub. Runs two components, hosts registration page, connects to chromecard via USB or WiFi.
- **Chromecard:** FIDO2 hardware security module. All crypto happens on-card; private keys never leave. Two fingerprint types: *user* (login) and *admin* (registration/deletion).
- **Server:** Accepts TLS only. Runs WebAuthn service that validates FIDO2 tokens before granting access to protected resources.
**Components on the phone:**
- **Component 1 — Proxy + gating filter:** Listens on a local port. Binary decision per request: host is gated → forward to Component 2 (TLS); host is not gated → forward directly to internet on port 80 (no TLS).
- **Component 2 — FIDO2 client + URL recognition:** Receives all requests from Component 1. Detects registration-URL → triggers admin registration flow; other gated URLs → triggers FIDO2 assertion flow (contacts card, gets token, forwards to server via TLS).
- **Registration page:** Local web app on phone. Requires admin fingerprint on the card for enrollment/deletion.
**Three flows:**
- **Flow A (authenticated proxy):** Browser → Component 1 → Component 2 → Card (user fingerprint, generates FIDO2 token) → Server (WebAuthn validates token) → resource returned.
- **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
### Development topology (Qubes 3-VM)
**Qubes 3-VM topology:** `k_client``k_proxy``k_server`, each a Debian 13 AppVM.
Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP routing). Validated chain:
- `k_client localhost:9771``k_proxy:8771`
- `k_proxy localhost:9780``k_server:8780`
**k_proxy_app.py** — session gateway and FIDO2 auth bridge. Two auth modes:
- `probe` (default): validates card presence by subprocess-calling `fido2_probe.py --json`
- `fido2-direct`: performs real CTAP2 `makeCredential`/`getAssertion` against the physical card via `python-fido2`; auto-detects the FIDO hidraw device
`ProxyState` holds all server-side state: in-memory session store (guarded by one lock), enrollment DB (JSON file), and an `UpstreamPool` of persistent TLS connections to k_server. Sessions are lost on restart.
**k_server_app.py** — protected resource backend. Exposes a monotonic counter behind `X-Proxy-Token` auth. Counter state is in-memory only; resets on restart. Lock guards counter increments.
**k_client_portal.py** — thin browser-facing portal in k_client. Delegates all auth and resource calls to k_proxy. Holds only a local preferred username; enrollment and session state live in k_proxy.
**FIDO2 transport:** Card communicates over USB HID (CTAPHID) on `/dev/hidraw0` (FIDO interface, usage page `0xF1D0`). `/dev/hidraw1` is a separate vendor HID interface. If the card re-enumerates, k_proxy auto-detects the correct node. If CTAPHID stops responding, a full USB power cycle is the recovery path.
**CardEmulator** (`tests/card_emulator.py`) — software emulator of the card for unit tests. Implements `make_credential`/`get_assertion` with real P-256 crypto; `user_confirms=False` simulates card rejection. Wire it into tests by patching `_with_direct_ctap2` and `_drop_direct_device` on `ProxyState`. See the module docstring for the exact patch pattern.
**Key enrollment endpoints on k_proxy:** `POST /enroll/register`, `GET /enroll/status`, `POST /enroll/update`, `POST /enroll/delete`, `GET /enroll/list`. Usernames are normalized to lowercase, 332 chars `[a-z0-9._-]`.
**Key session endpoints on k_proxy:** `POST /session/login`, `POST /session/status`, `POST /session/logout`, `POST /resource/counter`.
### k_phone Flutter app (Phase 9 — replaces k_proxy)
**`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → CONNECT or plain-HTTP relay through Component 2; non-gated → direct to target. Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly.
**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), resource/counter endpoints, and CONNECT tunnels. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally.
**`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests).
**`k_phone/lib/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).

View File

@ -1,6 +1,6 @@
# Setup # Setup
Last updated: 2026-04-27 Last updated: 2026-04-29
This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`. This is a living setup/status file for the local ChromeCard workspace at `/home/user/chromecard`.
Update this file whenever environment status or verified behavior changes. Update this file whenever environment status or verified behavior changes.
@ -668,6 +668,49 @@ Session note (2026-04-27, card emulator and bug fixes):
sign-count monotonicity, wrong RP rejection, empty allow-list rejection sign-count monotonicity, wrong RP rejection, empty allow-list rejection
- total test count is now 122, all passing locally without card or VMs - total test count is now 122, all passing locally without card or VMs
Session note (2026-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.

View File

@ -1,6 +1,6 @@
# Workplan # Workplan
Last updated: 2026-04-27 Last updated: 2026-04-29
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine. This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
@ -527,57 +527,148 @@ 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 (Future) ## Phase 9: Migrate to Phone-Mediated Wireless Validation
1. Auth transport abstraction in `k_proxy`. Status (2026-05-02): **ACTIVE — Component 1 + Component 2 CONNECT handler complete**
- Introduce/keep a transport interface for authenticator operations.
- Implement at least two backends:
- USB-direct backend (current).
- Phone-wireless backend (future).
2. Wireless phone integration. ### Target architecture
- 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.
3. Functional equivalence tests. Four physical devices: optional client computer, phone, chromecard, server.
- Verify login/enrollment behavior is unchanged at API level for `k_client`.
- Verify session reuse still works and card prompts are not increased unexpectedly.
Exit criteria: **Phone components:**
- `k_proxy` can validate via wireless phone path with no client-facing API changes. - **Component 1 — Proxy + gating filter:** Listens on a local port. Per-request binary decision: host is gated → forward to Component 2 via TLS; host is not gated → forward directly to internet on port 80 (no TLS, bypasses auth entirely).
- **Component 2 — FIDO2 client + URL recognition:** Detects registration URL → admin registration flow (admin fingerprint + PIN); other gated URLs → FIDO2 assertion flow (user fingerprint → token → server via TLS).
- **Registration page:** Local web app on phone; admin fingerprint access control enforced by card.
**Three flows:**
- **Flow A:** Browser → phone (comp 1 + 2) → card (user biometric) → server WebAuthn → resource
- **Flow B:** Browser → phone (comp 1 + 2, registration URL) → card (admin biometric) → enroll/delete user
- **Flow C:** Non-gated host → comp 1 → internet port 80 (no TLS, no card)
**Open decisions (from architecture doc):** PIN on card; user DB on-card vs. external; network-level access control on registration page.
Development chain (Qubes): `k_client browser → k_phone (Flutter Android) → USB HID → ChromeCard → k_server`
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
```
### Next action
1. **Add `_handleConnect` to `proxy_service.dart`** — CONNECT handler for gated HTTPS tunnels; checks `hasAnyActiveSession()`, connects to upstream, detaches socket, pipes bytes. Tests needed.
2. Deploy to a real Android phone with physical ChromeCard via USB
3. Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
4. Run `phase5_chain_regression.sh` against `k_phone` on Android with k_server running
### 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-27): Status (2026-04-29):
- fido2-direct mode confirmed working end-to-end with real card via browser on k_client. - Phase 9 emulator milestone complete: makeCredential + getAssertion verified via CardEmulator bridge.
- Full register → login → counter → logout flow verified with physical card button presses. - Next blocking step: deploy to real Android phone with ChromeCard over USB.
- Bug fixed: ClientState.enroll() now calls /session/logout on k_proxy before re-enrolling. - k_server is not running in the Mac test environment; counter endpoint will work once running in Qubes.
- All three service files refactored and re-deployed.
- Added CardEmulator: software emulator of the ChromeCard FIDO2 authenticator for use in tests.
- real P-256 crypto; auth_data layout mirrors firmware exactly
- user_confirms=True/False simulates card Yes/No; refusing() wrapper for integration test paths
- forget_user() simulates card-side key removal
- module docstring in tests/card_emulator.py is the usage guide
- Fixed two silent fido2-direct bugs: RegistrationResponse and AuthenticationResponse were both
constructed with id= instead of raw_id=; all direct-mode register/authenticate calls were failing.
- Test suite now at 122 tests (was 100), all passing locally without card or VMs.
Phase status (2026-04-27): Phase status (2026-04-29):
- Phase 6.5 (concurrency): deferred. Ceiling (~10 in-flight) is acceptable until multi-card use cases arrive. - Phase 6.5 (concurrency): deferred. ~10 in-flight ceiling is acceptable.
- Phase 7 (firmware build/flash): blocked on Chrome Roads (card vendor). No local action until that discussion concludes. - Phase 7 (firmware build/flash): blocked on Chrome Roads (card vendor).
- Phase 9 (phone integration): awaiting go-ahead. When approved: Flutter app (iOS + Android) replaces k_proxy; FIDO2 over WiFi to card; depends on Phase 7 firmware capability. - Phase 9 (phone integration): **emulator FIDO2 verified; physical phone + USB HID path is next.**
No active engineering work is unblocked at this time. Resume when Chrome Roads responds or Phase 9 is approved.
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

View File

@ -0,0 +1,57 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader("UTF-8") { reader ->
localProperties.load(reader)
}
}
android {
namespace "com.chromecard.kphone"
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
coreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
}
defaultConfig {
applicationId "com.chromecard.kphone"
minSdk = 26
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
signingConfig = signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
}
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.4"
}

View File

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

View File

@ -0,0 +1,44 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new id.flutter.flutter_background_service.FlutterBackgroundServicePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_background_service_android, id.flutter.flutter_background_service.FlutterBackgroundServicePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
}
}
}

View File

@ -0,0 +1,225 @@
package com.chromecard.kphone
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.os.Bundle
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
// ChromeCard USB identifiers (must match udev rule and ctaphid_channel.dart)
private const val VENDOR_ID = 0x1209
private const val PRODUCT_ID = 0x0005
private const val CHANNEL = "com.chromecard.kphone/usb_hid"
private const val ACTION_USB_PERMISSION = "com.chromecard.kphone.USB_PERMISSION"
private const val HID_PACKET_SIZE = 64
private const val TRANSFER_TIMEOUT_MS = 3000
class MainActivity : FlutterActivity() {
private val usbManager: UsbManager by lazy {
getSystemService(Context.USB_SERVICE) as UsbManager
}
private var usbDevice: UsbDevice? = null
private var usbConnection: UsbDeviceConnection? = null
private var usbInterface: UsbInterface? = null
private var endpointIn: UsbEndpoint? = null
private var endpointOut: UsbEndpoint? = null
// Pending permission result callback
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_USB_PERMISSION) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
permissionCallback?.invoke(granted)
permissionCallback = null
}
}
}
override fun onCreate(savedInstanceState: android.os.Bundle?) {
super.onCreate(savedInstanceState)
createNotificationChannel()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
"kphone_proxy",
"k_phone proxy service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows when the ChromeCard proxy is running"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val filter = IntentFilter(ACTION_USB_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(permissionReceiver, filter)
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"openCard" -> handleOpenCard(result)
"closeCard" -> { closeCard(); result.success(null) }
"isCardAttached" -> result.success(usbConnection != null)
"sendCtaphid" -> {
val packet = call.arguments as? ByteArray
if (packet == null || packet.size != HID_PACKET_SIZE) {
result.error("INVALID_PACKET", "Expected $HID_PACKET_SIZE bytes", null)
} else {
handleSendCtaphid(packet, result)
}
}
else -> result.notImplemented()
}
}
}
override fun onDestroy() {
super.onDestroy()
closeCard()
try { unregisterReceiver(permissionReceiver) } catch (_: Exception) {}
}
// -------------------------------------------------------------------------
// openCard: find ChromeCard, request permission, claim HID interface
// -------------------------------------------------------------------------
private fun handleOpenCard(result: MethodChannel.Result) {
// Already open?
if (usbConnection != null) { result.success(true); return }
val device = findChromeCard()
if (device == null) {
result.success(false)
return
}
if (usbManager.hasPermission(device)) {
result.success(claimDevice(device))
} else {
requestPermission(device) { granted ->
runOnUiThread {
result.success(if (granted) claimDevice(device) else false)
}
}
}
}
private fun findChromeCard(): UsbDevice? {
return usbManager.deviceList.values.firstOrNull { dev ->
dev.vendorId == VENDOR_ID && dev.productId == PRODUCT_ID
}
}
private fun requestPermission(device: UsbDevice, callback: (Boolean) -> Unit) {
permissionCallback = callback
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
PendingIntent.FLAG_MUTABLE else 0
val permIntent = PendingIntent.getBroadcast(this, 0,
Intent(ACTION_USB_PERMISSION), flags)
usbManager.requestPermission(device, permIntent)
}
private fun claimDevice(device: UsbDevice): Boolean {
// Find the HID interface (class 3)
val hidIface = (0 until device.interfaceCount)
.map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_HID }
?: return false
// Find IN and OUT interrupt endpoints
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (i in 0 until hidIface.endpointCount) {
val ep = hidIface.getEndpoint(i)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT) {
if (ep.direction == UsbConstants.USB_DIR_IN) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT) outEp = ep
}
}
if (inEp == null || outEp == null) return false
val conn = usbManager.openDevice(device) ?: return false
if (!conn.claimInterface(hidIface, true)) {
conn.close(); return false
}
usbDevice = device
usbConnection = conn
usbInterface = hidIface
endpointIn = inEp
endpointOut = outEp
return true
}
// -------------------------------------------------------------------------
// closeCard: release interface and close connection
// -------------------------------------------------------------------------
private fun closeCard() {
usbInterface?.let { usbConnection?.releaseInterface(it) }
usbConnection?.close()
usbDevice = null
usbConnection = null
usbInterface = null
endpointIn = null
endpointOut = null
}
// -------------------------------------------------------------------------
// sendCtaphid: write one HID packet, read one HID packet
// -------------------------------------------------------------------------
private fun handleSendCtaphid(packet: ByteArray, result: MethodChannel.Result) {
val conn = usbConnection
val outEp = endpointOut
val inEp = endpointIn
if (conn == null || outEp == null || inEp == null) {
result.error("NOT_OPEN", "Card not open", null)
return
}
// Send
val sent = conn.bulkTransfer(outEp, packet, packet.size, TRANSFER_TIMEOUT_MS)
if (sent < 0) {
result.error("SEND_FAILED", "bulkTransfer OUT returned $sent", null)
return
}
// Receive
val buf = ByteArray(HID_PACKET_SIZE)
val received = conn.bulkTransfer(inEp, buf, buf.size, TRANSFER_TIMEOUT_MS)
if (received < 0) {
result.error("RECV_FAILED", "bulkTransfer IN returned $received", null)
return
}
result.success(buf)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

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

Binary file not shown.

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip

160
k_phone/android/gradlew vendored Executable file
View File

@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

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

@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.3" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

View File

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

View File

@ -0,0 +1,362 @@
// Dart side of the USB HID platform channel + TCP emulator transport.
//
// Two transport modes:
// USB mode (default): calls into Kotlin MainActivity via MethodChannel.
// Emulator mode: TCP socket to card_emulator_bridge.py on port 8772.
//
// Call useEmulator() before openCard() to switch to emulator mode.
// All CTAPHID framing, fragmentation, and reassembly lives here in Dart.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/services.dart';
const _channel = MethodChannel('com.chromecard.kphone/usb_hid');
// ChromeCard USB IDs (matches udev rule 70-chromecard-fido.rules)
const int kVendorId = 0x1209;
const int kProductId = 0x0005;
// CTAPHID constants
const int kCtaphidBroadcastChannel = 0xFFFFFFFF;
const int kCtaphidInit = 0x06;
const int kCtaphidMsg = 0x03;
const int kCtaphidCbor = 0x10;
const int kCtaphidCancel = 0x11;
const int kCtaphidError = 0x3F;
const int kCtaphidKeepalive = 0x3B;
const int kHidPacketSize = 64;
// ---------------------------------------------------------------------------
// Transport selection
// ---------------------------------------------------------------------------
bool _emulatorMode = false;
String _emulatorHost = '127.0.0.1';
int _emulatorPort = 8772;
Socket? _emulatorSocket;
// Persistent read state for the emulator TCP socket.
// Socket is a single-subscription stream we must subscribe exactly once
// and accumulate all incoming bytes into a buffer.
// _emulatorRxWaiter is replaced on each call to _receivePacket so that
// concurrent waiters don't share a Completer and accidentally wake each other.
StreamSubscription<List<int>>? _emulatorSub;
final _emulatorRxBuf = <int>[];
Completer<void>? _emulatorRxWaiter;
bool _emulatorSocketOpen = false;
void _emulatorStartReading(Socket sock) {
_emulatorRxBuf.clear();
_emulatorRxWaiter = null;
_emulatorSocketOpen = true;
_emulatorSub?.cancel();
_emulatorSub = sock.listen(
(chunk) {
_emulatorRxBuf.addAll(chunk);
final w = _emulatorRxWaiter;
if (w != null && !w.isCompleted) w.complete();
},
onDone: () {
_emulatorSocketOpen = false;
final w = _emulatorRxWaiter;
if (w != null && !w.isCompleted) w.completeError(const SocketException('Emulator socket closed'));
},
onError: (Object e) {
_emulatorSocketOpen = false;
final w = _emulatorRxWaiter;
if (w != null && !w.isCompleted) w.completeError(e);
},
);
}
/// Switch to emulator mode connects to card_emulator_bridge.py.
/// Must be called before openCard().
void useEmulator({String host = '127.0.0.1', int port = 8772}) {
_emulatorMode = true;
_emulatorHost = host;
_emulatorPort = port;
}
/// Switch back to USB mode.
void useUsb() {
_emulatorMode = false;
_emulatorSocketOpen = false;
_emulatorSub?.cancel();
_emulatorSub = null;
_emulatorRxBuf.clear();
_emulatorSocket?.destroy();
_emulatorSocket = null;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Opens the ChromeCard USB device (or emulator TCP connection).
Future<bool> openCard() async {
if (_emulatorMode) {
try {
_emulatorSub?.cancel();
_emulatorSocket?.destroy();
_emulatorSocket = await Socket.connect(_emulatorHost, _emulatorPort);
_emulatorStartReading(_emulatorSocket!);
return true;
} catch (e) {
return false;
}
}
try {
return await _channel.invokeMethod<bool>('openCard') ?? false;
} on MissingPluginException {
return false;
} on PlatformException catch (e) {
throw CtapHidException('openCard failed: ${e.message}');
}
}
/// Closes the card handle / TCP connection.
Future<void> closeCard() async {
if (_emulatorMode) {
_emulatorSub?.cancel();
_emulatorSub = null;
_emulatorRxBuf.clear();
_emulatorSocket?.destroy();
_emulatorSocket = null;
return;
}
try {
await _channel.invokeMethod<void>('closeCard');
} on MissingPluginException {
return;
} on PlatformException catch (e) {
throw CtapHidException('closeCard failed: ${e.message}');
}
}
/// Returns true if a card (or emulator) is currently open.
Future<bool> isCardAttached() async {
if (_emulatorMode) return _emulatorSub != null;
try {
return await _channel.invokeMethod<bool>('isCardAttached') ?? false;
} on MissingPluginException {
return false;
} on PlatformException {
return false;
}
}
/// Sends a CTAPHID INIT to allocate a channel, returns the allocated CID.
Future<int> ctaphidInit() async {
final nonce = Uint8List(8);
final rng = Random.secure();
for (var i = 0; i < 8; i++) nonce[i] = rng.nextInt(256);
final responsePayload = await _ctaphidRoundtrip(
kCtaphidBroadcastChannel,
kCtaphidInit,
nonce,
);
if (responsePayload.length < 12) {
throw CtapHidException('INIT response too short: ${responsePayload.length}');
}
// Response payload: nonce(8) + CID(4) + ...
final cid = (responsePayload[8] << 24)
| (responsePayload[9] << 16)
| (responsePayload[10] << 8)
| responsePayload[11];
return cid;
}
/// Sends a CTAP2 CBOR command and returns the response payload.
Future<Uint8List> ctap2Cbor(int cid, Uint8List cbor) async {
return _ctaphidRoundtrip(cid, kCtaphidCbor, cbor);
}
/// Sends a CTAP1/U2F message and returns the response payload.
Future<Uint8List> ctap1Msg(int cid, Uint8List apdu) async {
return _ctaphidRoundtrip(cid, kCtaphidMsg, apdu);
}
// ---------------------------------------------------------------------------
// Internal: request/response
// ---------------------------------------------------------------------------
/// Full CTAPHID round-trip: fragment request, send, receive, reassemble.
Future<Uint8List> _ctaphidRoundtrip(int cid, int cmd, Uint8List data) async {
final requestPackets = _buildPackets(cid: cid, cmd: cmd, data: data);
if (_emulatorMode) {
// Emulator: send all request packets at once, then read response.
// The bridge buffers all request packets and sends keepalives as needed,
// but since we write everything before reading, we just send and drain.
for (final pkt in requestPackets) {
await _sendPacketOnly(pkt);
}
// Read the response init packet (bridge may have sent keepalives first).
var first = await _receivePacket();
while (_isKeepalive(first)) {
first = await _receivePacket();
}
return await _reassembleResponse(first, cid);
}
// USB: platform channel returns one response per send.
// Limitation: keepalives and continuation packets after the last request
// packet call _receivePacket(), which only works in emulator mode.
// In practice this is safe because CTAP2 responses for typical credential
// sizes fit in a single init packet and the card does not send keepalives
// synchronously before the response to the last request packet.
Uint8List lastReceived = Uint8List(kHidPacketSize);
for (final pkt in requestPackets) {
lastReceived = await _sendPacket(pkt);
}
while (_isKeepalive(lastReceived)) {
lastReceived = await _receivePacket();
}
return await _reassembleResponse(lastReceived, cid);
}
/// Send one 64-byte packet (emulator mode writes to socket; USB invokes platform channel).
Future<void> _sendPacketOnly(Uint8List packet) async {
assert(packet.length == kHidPacketSize);
if (_emulatorMode) {
if (!_emulatorSocketOpen) throw CtapHidException('Emulator socket closed');
final sock = _emulatorSocket;
if (sock == null) throw CtapHidException('Emulator socket not open');
sock.add(packet);
await sock.flush();
return;
}
// USB: sendCtaphid returns the response; handled by the USB round-trip path.
throw CtapHidException('_sendPacketOnly not used for USB');
}
/// Send one 64-byte packet and receive one response (USB mode).
Future<Uint8List> _sendPacket(Uint8List packet) async {
assert(packet.length == kHidPacketSize);
try {
final r = await _channel.invokeMethod<Uint8List>('sendCtaphid', packet);
return r ?? Uint8List(kHidPacketSize);
} on MissingPluginException {
throw CtapHidException('USB plugin not available');
} on PlatformException catch (e) {
throw CtapHidException('USB transfer failed: ${e.message}');
}
}
/// Receive one 64-byte packet from the emulator buffer.
/// Waits until the persistent socket listener has buffered enough bytes.
Future<Uint8List> _receivePacket() async {
if (_emulatorSub == null) throw CtapHidException('Emulator socket not open');
while (_emulatorRxBuf.length < kHidPacketSize) {
_emulatorRxWaiter = Completer<void>();
await _emulatorRxWaiter!.future;
}
final pkt = Uint8List.fromList(_emulatorRxBuf.take(kHidPacketSize).toList());
_emulatorRxBuf.removeRange(0, kHidPacketSize);
return pkt;
}
/// Reassemble a full CTAPHID response from an init packet + any continuations.
Future<Uint8List> _reassembleResponse(Uint8List initPacket, int expectedCid) async {
_checkCid(initPacket, expectedCid);
final cmd = initPacket[4] & 0x7F;
final payloadLen = (initPacket[5] << 8) | initPacket[6];
final firstChunk = min(payloadLen, kHidPacketSize - 7);
final result = BytesBuilder();
result.add(initPacket.sublist(7, 7 + firstChunk));
var received = firstChunk;
while (received < payloadLen) {
final contPacket = await _receivePacket(); // USB continuation unimplemented see _ctaphidRoundtrip
if (_isKeepalive(contPacket)) continue;
_checkCid(contPacket, expectedCid);
final chunk = min(payloadLen - received, kHidPacketSize - 5);
result.add(contPacket.sublist(5, 5 + chunk));
received += chunk;
}
final payload = result.toBytes();
if (cmd == kCtaphidError) {
throw CtapHidException(
'CTAPHID error: 0x${payload.isNotEmpty ? payload[0].toRadixString(16) : "??"}');
}
return payload;
}
bool _isKeepalive(Uint8List pkt) =>
pkt.length >= 5 && (pkt[4] & 0x7F) == kCtaphidKeepalive;
void _checkCid(Uint8List pkt, int expected) {
if (pkt.length < 4) return;
final got = (pkt[0] << 24) | (pkt[1] << 16) | (pkt[2] << 8) | pkt[3];
if (got != expected && expected != kCtaphidBroadcastChannel) {
throw CtapHidException(
'CID mismatch: got 0x${got.toRadixString(16)}, '
'expected 0x${expected.toRadixString(16)}');
}
}
// ---------------------------------------------------------------------------
// Packet building
// ---------------------------------------------------------------------------
List<Uint8List> _buildPackets({
required int cid,
required int cmd,
required Uint8List data,
}) {
final packets = <Uint8List>[];
const initPayload = kHidPacketSize - 7;
const contPayload = kHidPacketSize - 5;
final init = Uint8List(kHidPacketSize);
init[0] = (cid >> 24) & 0xFF;
init[1] = (cid >> 16) & 0xFF;
init[2] = (cid >> 8) & 0xFF;
init[3] = cid & 0xFF;
init[4] = (cmd & 0x7F) | 0x80;
init[5] = (data.length >> 8) & 0xFF;
init[6] = data.length & 0xFF;
final firstChunk = min(data.length, initPayload);
init.setRange(7, 7 + firstChunk, data);
packets.add(init);
var offset = firstChunk;
var seq = 0;
while (offset < data.length) {
final cont = Uint8List(kHidPacketSize);
cont[0] = (cid >> 24) & 0xFF;
cont[1] = (cid >> 16) & 0xFF;
cont[2] = (cid >> 8) & 0xFF;
cont[3] = cid & 0xFF;
cont[4] = seq & 0x7F;
final chunk = min(data.length - offset, contPayload);
cont.setRange(5, 5 + chunk, data, offset);
packets.add(cont);
offset += chunk;
seq++;
}
return packets;
}
// ---------------------------------------------------------------------------
// Exception
// ---------------------------------------------------------------------------
class CtapHidException implements Exception {
final String message;
CtapHidException(this.message);
@override
String toString() => 'CtapHidException: $message';
}

View File

@ -0,0 +1,259 @@
// Enrollment storage mirrors k_proxy_app.py ProxyState enrollment logic.
// Persists to a JSON file with the same schema so snapshots are portable.
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
// ---------------------------------------------------------------------------
// Username validation
// ---------------------------------------------------------------------------
final _usernamePattern = RegExp(r'^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$');
String normalizeUsername(String raw) {
final s = raw.trim().toLowerCase();
if (!_usernamePattern.hasMatch(s)) {
throw ArgumentError(
'username must be 332 chars of lowercase letters, digits, dot, underscore, or dash');
}
return s;
}
String? normalizeDisplayName(String? raw) {
final s = (raw ?? '').trim();
if (s.isEmpty) return null;
if (s.length > 64) throw ArgumentError('display_name must be 64 characters or fewer');
return s;
}
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
class Enrollment {
final String username;
final String? displayName;
final int createdAt;
final int updatedAt;
final String? userIdB64;
final String? credentialDataB64;
const Enrollment({
required this.username,
this.displayName,
required this.createdAt,
required this.updatedAt,
this.userIdB64,
this.credentialDataB64,
});
bool get hasCredential => credentialDataB64 != null;
Enrollment copyWith({
String? displayName,
int? updatedAt,
String? userIdB64,
String? credentialDataB64,
}) =>
Enrollment(
username: username,
displayName: displayName ?? this.displayName,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
userIdB64: userIdB64 ?? this.userIdB64,
credentialDataB64: credentialDataB64 ?? this.credentialDataB64,
);
Map<String, dynamic> toJson() => {
'username': username,
'display_name': displayName,
'created_at': createdAt,
'updated_at': updatedAt,
'user_id_b64': userIdB64,
'credential_data_b64': credentialDataB64,
};
factory Enrollment.fromJson(Map<String, dynamic> m) {
final username = (m['username'] as String? ?? '').trim();
// 'enrolled_at' was the field name in the Python k_proxy JSON schema; accept both for portability.
final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs();
return Enrollment(
username: username,
displayName: normalizeDisplayName(m['display_name'] as String?),
createdAt: createdAt,
updatedAt: m['updated_at'] as int? ?? createdAt,
userIdB64: m['user_id_b64'] as String?,
credentialDataB64: m['credential_data_b64'] as String?,
);
}
}
int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
// ---------------------------------------------------------------------------
// Database
// ---------------------------------------------------------------------------
class EnrollmentDb {
// [baseDir] can be injected in tests to bypass path_provider.
EnrollmentDb({Directory? baseDir}) : _baseDir = baseDir;
final Directory? _baseDir;
final Map<String, Enrollment> _entries = {};
bool _loaded = false;
// Dart isolates are single-threaded so there is no data race on _entries.
// We still serialize async disk I/O: each _serialize call chains its op
// onto _pending so concurrent callers queue up rather than interleave.
Future<void>? _pending;
Future<void> _serialize(Future<void> Function() op) async {
final prev = _pending;
final next = _doAfter(prev, op);
_pending = next;
await next;
}
static Future<void> _doAfter(Future<void>? prev, Future<void> Function() op) async {
if (prev != null) {
try {
await prev;
} catch (_) {} // previous op error must not block the queue
}
await op();
}
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
Future<File> _dbFile() async {
final dir = _baseDir ?? await getApplicationSupportDirectory();
return File('${dir.path}/k_phone_enrollments.json');
}
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
try {
final f = await _dbFile();
if (!f.existsSync()) return; // no file = fresh install; start with empty DB
final raw = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final users = raw['users'] as List? ?? [];
for (final item in users) {
final e = Enrollment.fromJson(item as Map<String, dynamic>);
if (e.username.isNotEmpty) _entries[e.username] = e;
}
} catch (_) {
_entries.clear(); // treat a corrupt/unreadable DB as empty; next save overwrites it
}
}
Future<void> _save() async {
final f = await _dbFile();
final users = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
await f.writeAsString(
const JsonEncoder.withIndent(' ').convert({'users': users.map((e) => e.toJson()).toList()}) + '\n',
);
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
Future<void> ensureLoaded() async {
await _serialize(_load);
}
/// Register a new user. Throws [StateError] if already enrolled.
Future<Enrollment> register({
required String username,
String? displayName,
String? userIdB64,
String? credentialDataB64,
}) async {
final canonical = normalizeUsername(username);
final pretty = normalizeDisplayName(displayName);
final now = _nowSecs();
Enrollment? result;
await _serialize(() async {
await _load();
if (_entries.containsKey(canonical)) throw StateError('user already enrolled');
final e = Enrollment(
username: canonical,
displayName: pretty,
createdAt: now,
updatedAt: now,
userIdB64: userIdB64,
credentialDataB64: credentialDataB64,
);
_entries[canonical] = e;
result = e;
await _save();
});
return result!;
}
/// Update display_name (and optionally credential data) for an existing user.
/// Throws [StateError] if not found.
Future<Enrollment> update({
required String username,
String? displayName,
String? userIdB64,
String? credentialDataB64,
}) async {
final canonical = normalizeUsername(username);
final pretty = normalizeDisplayName(displayName);
final now = _nowSecs();
Enrollment? result;
await _serialize(() async {
await _load();
final existing = _entries[canonical];
if (existing == null) throw StateError('user not enrolled');
final updated = existing.copyWith(
displayName: pretty,
updatedAt: now,
userIdB64: userIdB64 ?? existing.userIdB64,
credentialDataB64: credentialDataB64 ?? existing.credentialDataB64,
);
_entries[canonical] = updated;
result = updated;
await _save();
});
return result!;
}
/// Delete a user. Throws [StateError] if not found. Returns deleted entry.
Future<Enrollment> delete(String username) async {
final canonical = normalizeUsername(username);
Enrollment? result;
await _serialize(() async {
await _load();
final existing = _entries.remove(canonical);
if (existing == null) throw StateError('user not enrolled');
result = existing;
await _save();
});
return result!;
}
/// Get a single enrollment or null.
Future<Enrollment?> get(String username) async {
String canonical;
try {
canonical = normalizeUsername(username);
} catch (_) {
return null;
}
await ensureLoaded();
return _entries[canonical];
}
/// List all enrollments sorted by username.
Future<List<Enrollment>> list() async {
await ensureLoaded();
final result = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
return result;
}
}

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

@ -0,0 +1,342 @@
// CTAP2 FIDO2 operations makeCredential, getAssertion, verifyAssertion.
// Mirrors the direct-CTAP2 path in k_proxy_app.py.
//
// Wire format: first byte of ctap2Cbor response is a CTAP status code (0x00 = OK),
// remaining bytes are a CBOR map. Request is a CBOR map with no status prefix.
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:cbor/cbor.dart';
import 'package:crypto/crypto.dart';
import 'package:pointycastle/export.dart';
import 'ctaphid_channel.dart';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const String kRpId = 'localhost';
const String kOrigin = 'https://localhost';
const String kRpName = 'ChromeCard Proxy';
// ---------------------------------------------------------------------------
// Public result types
// ---------------------------------------------------------------------------
class MakeCredentialResult {
/// Raw AttestedCredentialData bytes: aaguid(16) + credIdLen(2) + credId + coseKey
final Uint8List credentialData;
/// base64url of credentialData store this in EnrollmentDb
String get credentialDataB64 => _b64uEncode(credentialData);
/// The 32-byte user handle used during registration
final Uint8List userId;
/// base64url of userId store this in EnrollmentDb
String get userIdB64 => _b64uEncode(userId);
MakeCredentialResult({required this.credentialData, required this.userId});
}
class GetAssertionResult {
final Uint8List authData;
final Uint8List signature;
final Uint8List clientDataHash;
GetAssertionResult({
required this.authData,
required this.signature,
required this.clientDataHash,
});
}
// ---------------------------------------------------------------------------
// makeCredential
// ---------------------------------------------------------------------------
/// Runs CTAP2 authenticatorMakeCredential against the card on [cid].
/// Returns credential data that should be persisted in the enrollment store.
Future<MakeCredentialResult> makeCredential(
int cid,
String username, {
String? displayName,
Uint8List? userId,
}) async {
final uid = userId ?? _randomBytes(32);
final challenge = _randomBytes(32);
final clientDataJson = _buildClientDataJson('webauthn.create', challenge);
final clientDataHash = _sha256(utf8.encode(clientDataJson));
// CBOR map: authenticatorMakeCredential (CTAP2 spec integer keys throughout)
final requestMap = CborMap({
CborSmallInt(1): CborBytes(clientDataHash),
CborSmallInt(2): CborMap({
CborString('id'): CborString(kRpId),
CborString('name'): CborString(kRpName),
}),
CborSmallInt(3): CborMap({
CborString('id'): CborBytes(uid),
CborString('name'): CborString(username),
CborString('displayName'): CborString(displayName ?? username),
}),
CborSmallInt(4): CborList([
CborMap({
CborString('type'): CborString('public-key'),
CborString('alg'): CborSmallInt(-7),
}),
]),
CborSmallInt(7): CborMap({
// 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.
Future<GetAssertionResult> getAssertion(
int cid,
String credentialDataB64,
) async {
final credData = _b64uDecode(credentialDataB64);
final credId = _extractCredentialId(credData);
final challenge = _randomBytes(32);
final clientDataJson = _buildClientDataJson('webauthn.get', challenge);
final clientDataHash = _sha256(utf8.encode(clientDataJson));
final requestMap = CborMap({
CborSmallInt(1): CborString(kRpId),
CborSmallInt(2): CborBytes(clientDataHash),
CborSmallInt(3): CborList([
CborMap({
CborString('type'): CborString('public-key'),
CborString('id'): CborBytes(credId),
}),
]),
CborSmallInt(5): CborMap({
CborString('up'): CborBool(true), // 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,
);
}
// ---------------------------------------------------------------------------
// verifyAssertion
// ---------------------------------------------------------------------------
/// Verifies the ECDSA-P256 assertion signature.
/// [credentialDataB64] is the stored base64url AttestedCredentialData.
/// Returns true if the signature is valid.
bool verifyAssertion(
String credentialDataB64,
Uint8List authData,
Uint8List signature,
Uint8List clientDataHash,
) {
final credData = _b64uDecode(credentialDataB64);
final coseKey = _extractCoseKey(credData);
final pubKey = _coseKeyToEcPublicKey(coseKey);
// CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON).
final message = Uint8List(authData.length + clientDataHash.length)
..setRange(0, authData.length, authData)
..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash);
final (r, s) = _decodeDerSignature(signature);
final verifier = ECDSASigner(SHA256Digest())
..init(false, PublicKeyParameter<ECPublicKey>(pubKey));
try {
return verifier.verifySignature(message, ECSignature(r, s));
} catch (_) {
return false;
}
}
// ---------------------------------------------------------------------------
// AuthData parsing helpers
// ---------------------------------------------------------------------------
/// Extracts AttestedCredentialData from a full authData blob.
/// authData layout:
/// [0:32] rpIdHash
/// [32] flags
/// [33:37] signCount (uint32 BE)
/// [37:53] aaguid (16 bytes) attested cred data starts here
/// [53:55] credIdLen (uint16 BE)
/// [55:55+n] credId
/// [55+n:] COSE key (CBOR)
Uint8List _extractAttestedCredentialData(Uint8List authData) {
if (authData.length < 55) {
throw FormatException('authData too short for attested credential data: ${authData.length}');
}
// The attested credential data is everything from offset 37 onward.
return Uint8List.fromList(authData.sublist(37));
}
/// Extracts the credential ID from AttestedCredentialData bytes.
/// Layout: aaguid(16) + credIdLen(2) + credId(n) + coseKey
Uint8List _extractCredentialId(Uint8List credData) {
if (credData.length < 18) {
throw FormatException('credentialData too short: ${credData.length}');
}
final credIdLen = (credData[16] << 8) | credData[17];
if (credData.length < 18 + credIdLen) {
throw FormatException('credentialData truncated before credId end');
}
return Uint8List.fromList(credData.sublist(18, 18 + credIdLen));
}
/// Extracts the COSE key bytes from AttestedCredentialData.
Uint8List _extractCoseKey(Uint8List credData) {
if (credData.length < 18) {
throw FormatException('credentialData too short for COSE key');
}
final credIdLen = (credData[16] << 8) | credData[17];
final coseStart = 18 + credIdLen;
if (credData.length <= coseStart) {
throw FormatException('credentialData has no COSE key bytes');
}
return Uint8List.fromList(credData.sublist(coseStart));
}
/// Parses a COSE EC2 key and returns an ECPublicKey for pointycastle.
ECPublicKey _coseKeyToEcPublicKey(Uint8List coseKeyBytes) {
final decoded = cbor.decode(coseKeyBytes);
if (decoded is! CborMap) throw FormatException('COSE key is not a CBOR map');
Uint8List? x, y;
for (final entry in decoded.entries) {
final k = entry.key;
final v = entry.value;
// COSE key -2 = x, -3 = y (represented as CborSmallInt or CborInt)
final ki = _cborInt(k);
if (ki == -2 && v is CborBytes) x = Uint8List.fromList(v.bytes);
if (ki == -3 && v is CborBytes) y = Uint8List.fromList(v.bytes);
}
if (x == null || y == null) throw FormatException('COSE key missing x or y coordinate');
final domainParams = ECDomainParameters('prime256v1');
final point = domainParams.curve.createPoint(
BigInt.parse(x.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16),
BigInt.parse(y.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16),
);
return ECPublicKey(point, domainParams);
}
int _cborInt(CborValue v) {
if (v is CborSmallInt) return v.value;
if (v is CborInt) return v.toInt();
throw FormatException('expected CBOR int, got ${v.runtimeType}');
}
/// DER-decode an ECDSA signature into (r, s) BigInts.
(BigInt, BigInt) _decodeDerSignature(Uint8List der) {
// SEQUENCE { INTEGER r, INTEGER s }
if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag');
// P-256 signatures are always 72 bytes, so the SEQUENCE length fits in one byte.
// Multi-byte BER length encoding (0x81/0x82 prefix) is not handled here.
var offset = 2; // skip 0x30 + one-byte length
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r');
final rLen = der[offset + 1];
final rBytes = der.sublist(offset + 2, offset + 2 + rLen);
offset += 2 + rLen;
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for s');
final sLen = der[offset + 1];
final sBytes = der.sublist(offset + 2, offset + 2 + sLen);
return (_bigIntFromBytes(rBytes), _bigIntFromBytes(sBytes));
}
BigInt _bigIntFromBytes(Uint8List bytes) {
var result = BigInt.zero;
for (final b in bytes) {
result = (result << 8) | BigInt.from(b);
}
return result;
}
// ---------------------------------------------------------------------------
// CTAP response parsing
// ---------------------------------------------------------------------------
CborMap _parseCtapResponse(Uint8List response) {
if (response.isEmpty) throw FormatException('empty CTAP response');
final status = response[0];
if (status != 0x00) throw FormatException('CTAP error: 0x${status.toRadixString(16)}');
final decoded = cbor.decode(response.sublist(1));
if (decoded is! CborMap) throw FormatException('CTAP response body is not a CBOR map');
return decoded;
}
Uint8List _requireBytes(CborMap map, int key, String field) {
final v = map[CborSmallInt(key)];
if (v == null) throw FormatException('$field: missing key $key in CTAP response');
if (v is! CborBytes) throw FormatException('$field: expected bytes, got ${v.runtimeType}');
return Uint8List.fromList(v.bytes);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
String _buildClientDataJson(String type, Uint8List challenge) {
final challengeB64 = _b64uEncode(challenge);
return '{"type":"$type","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}';
}
Uint8List _sha256(List<int> data) {
return Uint8List.fromList(sha256.convert(data).bytes);
}
Uint8List _randomBytes(int n) {
final rng = Random.secure();
return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
}
String _b64uEncode(Uint8List data) {
return base64Url.encode(data).replaceAll('=', '');
}
Uint8List _b64uDecode(String s) {
// base64url strips trailing '='; restore padding to the nearest multiple of 4.
final padded = s + '=' * ((4 - s.length % 4) % 4);
return Uint8List.fromList(base64Url.decode(padded));
}

View File

@ -0,0 +1,484 @@
// Component 1 HTTP proxy with URL gating filter.
//
// All browser traffic enters here. The routing rule is a single binary decision:
// gated host relay through Component 2 on localhost:_component2Port
// other host forward directly to the target host:port
//
// "Gated hosts" are resources that require FIDO2 card authentication before
// they can be accessed. Traffic to them is relayed through Component 2, which
// checks for an active session before forwarding.
//
// Gated hosts file (gated_hosts.txt in the app documents directory): one entry
// per line, either "host" or "host:port". Lines starting with "#" and blank
// lines are ignored.
//
// Example gated_hosts.txt:
// # External test resource (requires card login)
// httpbin.org
//
// For HTTPS (CONNECT) traffic to gated hosts this proxy sends a CONNECT request
// to Component 2 and waits for its 200/4xx response before responding to the
// browser. This lets Component 2 enforce the session check before the TLS
// tunnel is established; the raw TLS bytes are never exposed to Component 2.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '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 (both gated and non-gated use the same handler here
// gating for plain HTTP is enforced by Component 2 when it receives the
// forwarded request and checks the Host header)
// ---------------------------------------------------------------------------
Future<void> _handleHttp(
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;
final path = _relativePath(uri);
int contentLength = 0;
for (final h in headerLines) {
if (h.toLowerCase().startsWith('content-length:')) {
contentLength = int.tryParse(h.split(':').last.trim()) ?? 0;
break;
}
}
// For gated plain-HTTP hosts, route through Component 2; for others, direct.
final Socket upstream;
try {
if (_isGated(host, port)) {
upstream = await Socket.connect('127.0.0.1', _component2Port)
.timeout(const Duration(seconds: 5));
} else {
upstream = await Socket.connect(host, port)
.timeout(const Duration(seconds: 10));
}
} catch (e) {
_deny(client, sub, 502, 'Bad Gateway');
return;
}
final out = StringBuffer()
..write('$method $path HTTP/1.1\r\n')
..write('Host: ${uri.host}${uri.hasPort ? ':${uri.port}' : ''}\r\n');
for (final h in headerLines) {
if (h.isEmpty) continue;
final lower = h.toLowerCase();
if (lower.startsWith('host:') ||
lower.startsWith('proxy-connection:') ||
lower.startsWith('proxy-authorization:')) continue;
out.write('$h\r\n');
}
out.write('Connection: close\r\n\r\n');
upstream.add(utf8.encode(out.toString()));
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;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
void _deny(Socket client, StreamSubscription<List<int>> sub, int code, String reason) {
sub.cancel();
client.add(utf8.encode(
'HTTP/1.1 $code $reason\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
));
client.flush().then((_) => client.destroy()).catchError((_) => client.destroy());
}
// Parses "host:port" strings (e.g. CONNECT target). Uses [defaultPort] when no port present.
({String host, int port}) _parseHostPort(String target, {int defaultPort = 443}) {
final colon = target.lastIndexOf(':');
if (colon < 0) return (host: target, port: defaultPort);
return (
host: target.substring(0, colon),
port: int.tryParse(target.substring(colon + 1)) ?? defaultPort,
);
}
// Converts a proxy-style absolute URI to a relative path+query string.
String _relativePath(Uri uri) {
final base = uri.path.isEmpty ? '/' : uri.path;
return uri.hasQuery ? '$base?${uri.query}' : base;
}
void _log(String msg) => onLog?.call('[FilterProxy] $msg');
}

View File

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

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

@ -0,0 +1,259 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'enrollment_db.dart';
import 'filter_proxy.dart';
import 'proxy_service.dart';
import 'session_manager.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ProxyService.initialize();
runApp(const KPhoneApp());
}
class KPhoneApp extends StatelessWidget {
const KPhoneApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'k_phone',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ProxyStatusScreen(),
);
}
}
class ProxyStatusScreen extends StatefulWidget {
const ProxyStatusScreen({super.key});
@override
State<ProxyStatusScreen> createState() => _ProxyStatusScreenState();
}
class _ProxyStatusScreenState extends State<ProxyStatusScreen> {
bool _serviceRunning = false;
bool _cardAttached = false;
String _statusMessage = 'Stopped';
final List<String> _log = [];
// Debug-only test state stripped in release builds via kDebugMode.
bool _testRunning = false;
final _db = EnrollmentDb();
final _session = SessionManager();
@override
void initState() {
super.initState();
_subscribeToService();
}
void _subscribeToService() {
final service = FlutterBackgroundService();
// Sync initial running state
service.isRunning().then((running) {
if (mounted) setState(() => _serviceRunning = running);
});
service.on('status').listen((event) {
if (event == null) return;
if (mounted) {
setState(() {
_serviceRunning = event['running'] as bool? ?? false;
_cardAttached = event['cardAttached'] as bool? ?? false;
_statusMessage = event['message'] as String? ?? '';
final log = event['log'] as String?;
if (log != null) {
_log.insert(0, log);
if (_log.length > 200) _log.removeLast();
}
});
}
});
}
void _addLog(String msg) {
setState(() {
_log.insert(0, msg);
if (_log.length > 200) _log.removeLast();
});
}
Future<void> _runRegistrationLoginTest() async {
if (_testRunning) return;
setState(() => _testRunning = true);
try {
// --- Registrering ---
_addLog('[REGISTRATION] Starter: enrolling "testbruger"');
try {
final enrollment = await _db.register(
username: 'testbruger',
displayName: 'Test Bruger',
);
_addLog('[REGISTRATION] OK: bruger="${enrollment.username}" '
'displayName="${enrollment.displayName}"');
} on StateError {
_addLog('[REGISTRATION] INFO: "testbruger" allerede enrollet — sletter og prøver igen');
await _db.delete('testbruger');
final enrollment = await _db.register(
username: 'testbruger',
displayName: 'Test Bruger',
);
_addLog('[REGISTRATION] OK: bruger="${enrollment.username}" genregistreret');
}
final list = await _db.list();
_addLog('[REGISTRATION] Enrollment-liste: ${list.map((e) => e.username).join(', ')}');
_addLog('[REGISTRATION] Test duplikat afvisning...');
try {
await _db.register(username: 'testbruger');
_addLog('[REGISTRATION] FEJL: duplikat burde være afvist!');
} on StateError catch (e) {
_addLog('[REGISTRATION] OK: duplikat afvist — ${e.message}');
}
// --- Login ---
_addLog('[LOGIN] Udsteder session for "testbruger"...');
final token = _session.issue('testbruger');
_addLog('[LOGIN] Token: ${token.substring(0, 8)}... (${token.length} tegn)');
final valid = _session.isValid(token);
_addLog('[LOGIN] Session gyldig: $valid');
final entry = _session.getSession(token);
_addLog('[LOGIN] Udløber: ${entry?.expires.toLocal().toString().substring(0, 19)}');
_addLog('[LOGIN] Tilbagekalder session...');
_session.revoke(token);
_addLog('[LOGIN] Session gyldig efter revoke: ${_session.isValid(token)}');
_addLog('[LOGIN] FÆRDIG — alle flows OK');
} catch (e) {
_addLog('[FEJL] $e');
} finally {
setState(() => _testRunning = false);
}
}
Future<void> _toggleService() async {
final service = FlutterBackgroundService();
final running = await service.isRunning();
if (running) {
service.invoke('stop');
setState(() {
_serviceRunning = false;
_cardAttached = false;
_statusMessage = 'Stopped';
});
} else {
await service.startService();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('k_phone — ChromeCard proxy'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_StatusTile(
label: 'Filter proxy (Comp 1)',
ok: _serviceRunning,
value: _serviceRunning ? 'Running on :$kFilterProxyPort' : 'Stopped',
),
const SizedBox(height: 8),
_StatusTile(
label: 'Auth proxy (Comp 2)',
ok: _serviceRunning,
value: _serviceRunning ? 'Running on :$kProxyPort' : 'Stopped',
),
const SizedBox(height: 8),
_StatusTile(
label: 'ChromeCard (USB)',
ok: _cardAttached,
value: _cardAttached ? 'Attached' : 'Not detected',
),
const SizedBox(height: 8),
Text(
_statusMessage,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
Row(
children: [
FilledButton(
onPressed: _toggleService,
child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'),
),
if (kDebugMode) ...[
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: _testRunning ? null : _runRegistrationLoginTest,
child: _testRunning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Kør registrering & login'),
),
],
],
),
const Divider(height: 32),
const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: ListView.builder(
itemCount: _log.length,
itemBuilder: (_, i) => Text(
_log[i],
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
),
),
],
),
),
);
}
}
class _StatusTile extends StatelessWidget {
final String label;
final bool ok;
final String value;
const _StatusTile({
required this.label,
required this.ok,
required this.value,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(
ok ? Icons.check_circle : Icons.radio_button_unchecked,
color: ok ? Colors.green : Colors.grey,
size: 18,
),
const SizedBox(width: 8),
Text('$label: ', style: const TextStyle(fontWeight: FontWeight.w600)),
Text(value),
],
);
}
}

View File

@ -0,0 +1,885 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'ctaphid_channel.dart';
import 'enrollment_db.dart';
import 'filter_proxy.dart';
import 'fido2_ops.dart';
import 'k_server_client.dart';
import 'session_manager.dart';
const int kProxyPort = 8771;
// Must match SessionManager._ttl; used only in the API response payload.
const int _kSessionTtlSeconds = 300;
const String kNotificationChannelId = 'kphone_proxy';
const String kNotificationChannelName = 'k_phone proxy service';
// ---------------------------------------------------------------------------
// 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();
final KServerClient _kserver = KServerClient();
int? _cardCid;
bool _cardAttached = false;
bool _running = false;
_ProxyServer(this._service);
void _emit(String msg) {
_service.invoke('status', {
'running': _running,
'cardAttached': _cardAttached,
'message': msg,
'log': '[${DateTime.now().toIso8601String()}] $msg',
});
}
Future<void> start() async {
_running = true;
_emit('Starting proxy on :$kProxyPort');
_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()]);
SecurityContext? tlsCtx;
try {
tlsCtx = await _loadTlsContext();
} catch (_) {
_emit('No TLS certs found — running plain HTTP (dev mode)');
}
try {
if (tlsCtx != null) {
_server = await HttpServer.bindSecure(InternetAddress.anyIPv4, kProxyPort, tlsCtx);
} else {
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
}
_emit('Listening on :$kProxyPort');
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
} catch (e) {
_emit('FATAL: Could not bind :$kProxyPort$e');
_running = false;
}
}
Future<void> stop() async {
_running = false;
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 _serveHtml(req);
case '/enroll':
await _serveEnrollHtml(req);
case '/health':
await _handleHealth(req);
case '/enroll/list':
await _handleEnrollList(req);
default:
if (path.startsWith('/enroll/status')) {
await _handleEnrollStatus(req);
} else {
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
}
}
} else if (req.method == 'POST') {
switch (path) {
case '/enroll/register':
await _handleEnrollRegister(req);
case '/enroll/update':
await _handleEnrollUpdate(req);
case '/enroll/delete':
await _handleEnrollDelete(req);
case '/session/login':
await _handleSessionLogin(req);
case '/session/status':
await _handleSessionStatus(req);
case '/session/logout':
await _handleSessionLogout(req);
case '/resource/counter':
await _handleResourceCounter(req);
default:
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
}
} else 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 rawUsername = body['username'] as String? ?? '';
final rawDisplay = body['display_name'] as String?;
String canonical;
String? pretty;
try {
canonical = normalizeUsername(rawUsername);
pretty = normalizeDisplayName(rawDisplay);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
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 rawUsername = body['username'] as String? ?? '';
final rawDisplay = body['display_name'] as String?;
String canonical;
String? pretty;
try {
canonical = normalizeUsername(rawUsername);
pretty = normalizeDisplayName(rawDisplay);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
try {
final enrollment = await _db.update(username: canonical, displayName: pretty);
await _send(req.response, 200, _enrollmentPayload(enrollment));
} on StateError {
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
}
}
Future<void> _handleEnrollDelete(HttpRequest req) async {
final body = await _readJson(req);
if (body == null) return;
final rawUsername = body['username'] as String? ?? '';
String canonical;
try {
canonical = normalizeUsername(rawUsername);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
try {
final enrollment = await _db.delete(canonical);
_sessions.revokeAll(canonical);
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 rawUsername = body['username'] as String? ?? '';
String canonical;
try {
canonical = normalizeUsername(rawUsername);
} on ArgumentError catch (e) {
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
return;
}
final enrollment = await _db.get(canonical);
if (enrollment == null) {
await _send(req.response, 403, {'ok': false, 'error': 'user not enrolled', 'username': canonical});
return;
}
if (enrollment.hasCredential && _cardCid != null) {
// FIDO2-direct: getAssertion + verify
GetAssertionResult assertionResult;
try {
assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!);
} catch (e) {
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': e.toString()});
return;
}
final ok = verifyAssertion(
enrollment.credentialDataB64!,
assertionResult.authData,
assertionResult.signature,
assertionResult.clientDataHash,
);
if (!ok) {
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'signature verification failed'});
return;
}
} else if (!_cardAttached) {
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'});
return;
}
// else: probe-mode enrollment (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': _kSessionTtlSeconds,
'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,
);
}
// -------------------------------------------------------------------------
// Resource forwarding
// -------------------------------------------------------------------------
Future<void> _handleResourceCounter(HttpRequest req) async {
await _drainBody(req);
final token = _extractBearerToken(req);
if (token == null) {
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
return;
}
final session = _sessions.getSession(token);
if (session == null) {
await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'});
return;
}
final result = await _kserver.forward(
method: 'POST',
path: '/resource/counter',
headers: req.headers,
body: Uint8List(0),
);
if (result.statusCode != 200) {
await _send(req.response, result.statusCode, {'ok': false, 'error': 'upstream failed'});
return;
}
Map<String, dynamic> upstream;
try {
upstream = jsonDecode(utf8.decode(result.body)) as Map<String, dynamic>;
} catch (_) {
upstream = {};
}
await _send(req.response, 200, {
'ok': true,
'username': session.username,
'session_reused': true,
'upstream': upstream,
});
}
// -------------------------------------------------------------------------
// Health + HTML
// -------------------------------------------------------------------------
Future<void> _handleHealth(HttpRequest req) async {
await _send(req.response, 200, {
'ok': true,
'service': 'k_phone',
'card': _cardAttached,
'active_sessions': 0,
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
});
}
Future<void> _serveHtml(HttpRequest req) async {
req.response.statusCode = 200;
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
req.response.headers.contentLength = _kPortalHtmlBytes.length;
req.response.add(_kPortalHtmlBytes);
await req.response.close();
}
Future<void> _serveEnrollHtml(HttpRequest req) async {
req.response.statusCode = 200;
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
req.response.headers.contentLength = _kEnrollHtmlBytes.length;
req.response.add(_kEnrollHtmlBytes);
await req.response.close();
}
// -------------------------------------------------------------------------
// Card management
// -------------------------------------------------------------------------
Future<void> _tryOpenCard() async {
try {
_cardAttached = await openCard();
if (!_cardAttached) {
_emit('No USB card — trying emulator bridge on 10.0.2.2:8772');
useEmulator(host: '10.0.2.2');
_cardAttached = await openCard();
}
if (_cardAttached) {
_cardCid = await ctaphidInit();
_emit('Card open, CID=0x${_cardCid!.toRadixString(16)}');
} else {
_emit('No card and no emulator bridge — card operations unavailable');
}
} catch (e) {
_emit('Card open failed: $e');
_cardAttached = false;
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
String? _extractBearerToken(HttpRequest req) {
final auth = req.headers.value('authorization') ?? '';
if (!auth.startsWith('Bearer ')) return null;
final token = auth.substring(7).trim();
return token.isEmpty ? null : token;
}
Future<Map<String, dynamic>?> _readJson(HttpRequest req) async {
try {
final 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<SecurityContext> _loadTlsContext() async {
throw UnimplementedError('TLS cert loading not yet wired up');
}
}
// ---------------------------------------------------------------------------
// Portal HTML (mirrors k_proxy_app.py HTML)
// ---------------------------------------------------------------------------
final _kPortalHtmlBytes = utf8.encode(_kPortalHtml);
final _kEnrollHtmlBytes = utf8.encode(_kEnrollHtml);
const String _kPortalHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard k_phone Portal</title>
<style>
:root {
--bg: #f1eee8; --panel: #fffdf8; --ink: #171615; --muted: #645f56;
--line: #d6cbb9; --accent: #0c6a60; --accent-2: #8e5b2d;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
}
main { max-width: 900px; margin: 0 auto; padding: 32px 20px 56px; }
.hero, .card { background: var(--panel); border: 1px solid var(--line); box-shadow: 0 16px 34px rgba(49,38,21,0.08); }
.hero { padding: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 10px; font-size: clamp(2rem,4vw,3.5rem); line-height: 0.95; letter-spacing: -0.04em; }
.subtitle { margin: 0; color: var(--muted); max-width: 64ch; }
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.card { padding: 18px; }
.card h2 { margin: 0 0 12px; font-size: 1.15rem; }
label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--line); background: #fff; font: inherit; color: var(--ink); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button { border: 0; padding: 10px 14px; font: inherit; color: #fff; background: var(--accent); cursor: pointer; }
button.secondary { background: var(--accent-2); }
.status { display: grid; gap: 8px; margin-top: 14px; color: var(--muted); }
pre { margin: 18px 0 0; min-height: 300px; padding: 16px; overflow: auto; border: 1px solid var(--line); background: #141210; color: #efe6d8; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.9rem; line-height: 1.45; }
</style>
</head>
<body>
<main>
<section class="hero">
<h1>ChromeCard k_phone Portal</h1>
<p class="subtitle">Phone-mediated FIDO2 proxy. Registration and assertion happen on the Android app via USB HID or emulator bridge.</p>
</section>
<section class="grid">
<div class="card">
<h2>Enrollment</h2>
<label for="username">Username</label>
<input id="username" placeholder="alice" autocomplete="off">
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="Alice Example" autocomplete="off">
<div class="actions">
<button id="enrollBtn">Enroll User</button>
<button id="updateBtn" class="secondary">Update User</button>
<button id="deleteBtn" class="secondary">Delete User</button>
<button id="checkBtn" class="secondary">Check Enrollment</button>
<button id="listBtn" class="secondary">List Users</button>
</div>
<div class="status">
<div>Stored username: <strong id="storedUser">none</strong></div>
<div>Session active: <strong id="sessionActive">no</strong></div>
</div>
</div>
<div class="card">
<h2>Session Flow</h2>
<div class="actions">
<button id="loginBtn">Login</button>
<button id="statusBtn" class="secondary">Status</button>
<button id="counterBtn">Counter</button>
<button id="logoutBtn" class="secondary">Logout</button>
</div>
</div>
</section>
<pre id="log"></pre>
</main>
<script>
const USER_KEY="chromecard.proxy.username", TOKEN_KEY="chromecard.proxy.session_token", EXP_KEY="chromecard.proxy.expires_at";
const logNode=document.getElementById("log"), usernameNode=document.getElementById("username"),
displayNameNode=document.getElementById("displayName"), storedUserNode=document.getElementById("storedUser"),
sessionActiveNode=document.getElementById("sessionActive");
function getStoredUser(){return localStorage.getItem(USER_KEY)||"";}
function getStoredToken(){return localStorage.getItem(TOKEN_KEY)||"";}
function syncState(){const u=getStoredUser();storedUserNode.textContent=u||"none";sessionActiveNode.textContent=getStoredToken()?"yes":"no";if(u&&!usernameNode.value)usernameNode.value=u;}
function log(msg,payload){const stamp=new Date().toLocaleTimeString();let line=`[\${stamp}] \${msg}`;if(payload!==undefined)line+="\\n"+JSON.stringify(payload,null,2);logNode.textContent=line+"\\n\\n"+logNode.textContent;}
async function jsonRequest(method,path,payload,withToken=false){const headers={"Content-Type":"application/json"};if(withToken&&getStoredToken())headers["Authorization"]="Bearer "+getStoredToken();const resp=await fetch(path,{method,headers,body:payload===undefined?undefined:JSON.stringify(payload)});const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));return data;}
document.getElementById("enrollBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/register",{username:usernameNode.value.trim(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,usernameNode.value.trim());syncState();log("Enrolled",data);}catch(err){log("Enroll failed",{error:err.message});}});
document.getElementById("checkBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const resp=await fetch("/enroll/status?username="+encodeURIComponent(u));const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Enrollment status",data);if(data.display_name)displayNameNode.value=data.display_name;}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("updateBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/enroll/update",{username:usernameNode.value.trim()||getStoredUser(),display_name:displayNameNode.value.trim()});localStorage.setItem(USER_KEY,data.username);syncState();log("Updated",data);}catch(err){log("Update failed",{error:err.message});}});
document.getElementById("deleteBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/enroll/delete",{username:u});if(getStoredUser()===u){localStorage.removeItem(USER_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);}displayNameNode.value="";syncState();log("Deleted",data);}catch(err){log("Delete failed",{error:err.message});}});
document.getElementById("listBtn").addEventListener("click",async()=>{try{const resp=await fetch("/enroll/list");const data=await resp.json();if(!resp.ok)throw new Error(JSON.stringify(data));log("Users",data);}catch(err){log("List failed",{error:err.message});}});
document.getElementById("loginBtn").addEventListener("click",async()=>{try{const u=usernameNode.value.trim()||getStoredUser();const data=await jsonRequest("POST","/session/login",{username:u});localStorage.setItem(USER_KEY,u);localStorage.setItem(TOKEN_KEY,data.session_token||"");localStorage.setItem(EXP_KEY,String(data.expires_at||""));syncState();log("Login ok",data);}catch(err){log("Login failed",{error:err.message});}});
document.getElementById("statusBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/status",{},true);log("Session status",data);}catch(err){log("Status failed",{error:err.message});}});
document.getElementById("counterBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/resource/counter",{},true);log("Counter",data);}catch(err){log("Counter failed",{error:err.message});}});
document.getElementById("logoutBtn").addEventListener("click",async()=>{try{const data=await jsonRequest("POST","/session/logout",{},true);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(EXP_KEY);syncState();log("Logout",data);}catch(err){log("Logout failed",{error:err.message});}});
syncState();
</script>
</body>
</html>''';
// ---------------------------------------------------------------------------
// Enrollment / Registration HTML (GET /enroll)
// ---------------------------------------------------------------------------
const String _kEnrollHtml = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChromeCard Registration</title>
<style>
:root{--g:#0c6a60;--r:#dc2626;--bg:#f5f4f1;--panel:#fff;--line:#e0dbd3;--muted:#6b6560}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:#181614;padding:2rem 1rem}
main{max-width:520px;margin:0 auto;display:grid;gap:2rem}
h1{font-size:1.25rem;font-weight:700}
h2{font-size:.75rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.6rem}
/* user list */
#userList{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
#userList table{width:100%;border-collapse:collapse}
#userList td{padding:.65rem 1rem;border-bottom:1px solid var(--line);vertical-align:middle}
#userList tr:last-child td{border-bottom:none}
.uname{font-weight:600;font-size:.95rem}
.udisp{display:block;font-size:.78rem;color:var(--muted);margin-top:1px}
.badge{font-size:.68rem;font-weight:700;letter-spacing:.04em;padding:2px 7px;border-radius:3px;white-space:nowrap}
.fido2{background:#d1fae5;color:#065f46}
.probe{background:#fef3c7;color:#92400e}
.btn-del{background:none;border:1px solid var(--r);color:var(--r);padding:3px 10px;border-radius:4px;cursor:pointer;font:.82rem system-ui,sans-serif}
.btn-del:hover{background:var(--r);color:#fff}
.empty{padding:1.2rem 1rem;color:var(--muted);font-size:.9rem}
/* form */
form{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1rem;display:grid;gap:.55rem}
label{font-size:.8rem;color:var(--muted)}
input{width:100%;padding:.5rem .7rem;border:1px solid var(--line);border-radius:4px;font:inherit}
input:focus{outline:2px solid var(--g);border-color:transparent}
#regBtn{padding:.55rem 1rem;background:var(--g);color:#fff;border:none;border-radius:4px;cursor:pointer;font:inherit;font-weight:600;justify-self:start;margin-top:.2rem}
#regBtn:disabled{opacity:.5;cursor:default}
/* status */
#msg{font-size:.85rem;min-height:1.3em;padding:.25rem 0}
#msg.ok{color:#065f46}
#msg.err{color:var(--r)}
</style>
</head>
<body>
<main>
<h1>ChromeCard User Registration</h1>
<section>
<h2>Registered users</h2>
<div id="userList"><div class="empty">Loading</div></div>
</section>
<section>
<h2>Register new user</h2>
<form id="regForm">
<label for="uname">Username</label>
<input id="uname" placeholder="alice" autocomplete="off" required>
<label for="dname">Display name (optional)</label>
<input id="dname" placeholder="Alice Example" autocomplete="off">
<button type="submit" id="regBtn">Register touch card fingerprint</button>
</form>
<div id="msg"></div>
</section>
</main>
<script>
var listEl=document.getElementById("userList"),
regForm=document.getElementById("regForm"),
unameEl=document.getElementById("uname"),
dnameEl=document.getElementById("dname"),
regBtn=document.getElementById("regBtn"),
msgEl=document.getElementById("msg");
function setMsg(t,ok){msgEl.textContent=t;msgEl.className=ok?"ok":"err";}
function clearMsg(){msgEl.textContent="";msgEl.className="";}
function renderUsers(users){
if(!users||!users.length){listEl.innerHTML='<div class="empty">No users registered yet</div>';return;}
var rows=users.map(function(u){
var disp=u.display_name?('<span class="udisp">'+u.display_name+'</span>'):'';
var mode=u.has_credential?'fido2':'probe';
var label=u.has_credential?'FIDO2':'probe';
return '<tr>'
+'<td><span class="uname">'+u.username+'</span>'+disp+'</td>'
+'<td><span class="badge '+mode+'">'+label+'</span></td>'
+'<td><button class="btn-del" data-u="'+u.username+'">Delete</button></td>'
+'</tr>';
}).join("");
listEl.innerHTML="<table><tbody>"+rows+"</tbody></table>";
listEl.querySelectorAll(".btn-del").forEach(function(b){
b.addEventListener("click",function(){del(b.dataset.u);});
});
}
async function loadUsers(){
try{
var r=await fetch("/enroll/list"),d=await r.json();
renderUsers(d.users||[]);
}catch(e){listEl.innerHTML='<div class="empty">Could not load users</div>';}
}
async function del(username){
if(!confirm('Delete user "'+username+'"?'))return;
clearMsg();
try{
var r=await fetch("/enroll/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Delete failed");
renderUsers(d.users||[]);
setMsg('"'+username+'" deleted.',true);
}catch(e){setMsg(e.message,false);}
}
regForm.addEventListener("submit",async function(e){
e.preventDefault();clearMsg();
var username=unameEl.value.trim();
var display_name=dnameEl.value.trim()||undefined;
regBtn.disabled=true;regBtn.textContent="Waiting for card fingerprint…";
try{
var r=await fetch("/enroll/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:username,display_name:display_name})});
var d=await r.json();
if(!r.ok)throw new Error(d.error||"Registration failed");
renderUsers(d.users||[]);
setMsg('"'+d.username+'" registered ('+(d.has_credential?"FIDO2":"probe mode")+').',true);
unameEl.value="";dnameEl.value="";
}catch(e){setMsg(e.message,false);}
finally{regBtn.disabled=false;regBtn.textContent="Register — touch card fingerprint";}
});
loadUsers();
</script>
</body>
</html>''';

View File

@ -0,0 +1,69 @@
// Session token management mirrors k_proxy_app.py session logic.
// Tokens are 32-byte hex strings; stored in memory only.
import 'dart:math';
class SessionEntry {
final String username;
final DateTime expires;
SessionEntry({required this.username, required this.expires});
}
class SessionManager {
final Map<String, SessionEntry> _sessions = {};
static const Duration _ttl = Duration(seconds: 300);
/// Issue a new session token for [username].
/// _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;
}
/// 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();
}
}

620
k_phone/pubspec.lock Normal file
View File

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

29
k_phone/pubspec.yaml Normal file
View File

@ -0,0 +1,29 @@
name: k_phone
description: ChromeCard FIDO2 proxy phone app — replaces k_proxy in the auth chain.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_background_service: ^5.0.5
flutter_background_service_android: ^6.2.0
flutter_local_notifications: ^18.0.0
uuid: ^4.4.0
cbor: ^6.3.0
pointycastle: ^3.7.3
path_provider: ^2.1.4
crypto: ^3.0.5
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View File

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

View File

@ -0,0 +1,167 @@
// FIDO2 Dart unit tests.
//
// Tests 1-2 run without any external dependency.
// Tests 3-6 require card_emulator_bridge.py on TCP port 8772:
//
// uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
// tests/card_emulator_bridge.py
//
// Then run: flutter test test/fido2_test.dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:cbor/cbor.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import '../lib/ctaphid_channel.dart';
import '../lib/fido2_ops.dart';
void main() {
// -------------------------------------------------------------------------
// Test 1: CBOR encode/decode round-trip
// -------------------------------------------------------------------------
test('CBOR makeCredential request map encodes and decodes correctly', () {
final clientDataHash = Uint8List(32)..fillRange(0, 32, 0xAB);
final requestMap = CborMap({
CborSmallInt(1): CborBytes(clientDataHash),
CborSmallInt(2): CborMap({
CborString('id'): CborString(kRpId),
CborString('name'): CborString(kRpName),
}),
CborSmallInt(4): CborList([
CborMap({
CborString('type'): CborString('public-key'),
CborString('alg'): CborSmallInt(-7),
}),
]),
CborSmallInt(7): CborMap({
CborString('rk'): CborBool(false),
CborString('uv'): CborBool(false),
}),
});
final encoded = Uint8List.fromList(cbor.encode(requestMap));
expect(encoded, isNotEmpty);
final decoded = cbor.decode(encoded);
expect(decoded, isA<CborMap>());
final m = decoded as CborMap;
final hash = m[CborSmallInt(1)];
expect(hash, isA<CborBytes>());
expect((hash as CborBytes).bytes, equals(clientDataHash));
final rp = m[CborSmallInt(2)] as CborMap;
expect((rp[CborString('id')] as CborString).toString(), equals(kRpId));
});
// -------------------------------------------------------------------------
// Test 2: clientDataHash is SHA256 of known JSON
// -------------------------------------------------------------------------
test('clientDataHash matches SHA256 of known clientDataJSON', () {
// Fixed challenge for deterministic test
final challenge = Uint8List.fromList(List.generate(32, (i) => i));
final challengeB64 = base64Url.encode(challenge).replaceAll('=', '');
final clientDataJson =
'{"type":"webauthn.create","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}';
final expected = Uint8List.fromList(sha256.convert(utf8.encode(clientDataJson)).bytes);
expect(expected.length, equals(32));
expect(expected, isNot(equals(Uint8List(32))));
});
// -------------------------------------------------------------------------
// Tests 3-6: require card_emulator_bridge.py on 127.0.0.1:8772
// -------------------------------------------------------------------------
group('emulator bridge', () {
late int cid;
late String credentialDataB64;
setUpAll(() async {
useEmulator(host: '127.0.0.1', port: 8772);
final connected = await openCard();
if (!connected) {
markTestSkipped('card_emulator_bridge.py not reachable on :8772');
return;
}
cid = await ctaphidInit();
});
tearDownAll(() async {
await closeCard();
});
// -----------------------------------------------------------------------
// Test 3: makeCredential returns valid AttestedCredentialData
// -----------------------------------------------------------------------
test('makeCredential returns non-empty credentialData with valid structure', () async {
final result = await makeCredential(cid, 'testuser', displayName: 'Test User');
expect(result.credentialData, isNotEmpty);
expect(result.userId.length, equals(32));
// AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey
final cd = result.credentialData;
expect(cd.length, greaterThan(18));
final credIdLen = (cd[16] << 8) | cd[17];
expect(credIdLen, greaterThan(0));
expect(cd.length, greaterThanOrEqualTo(18 + credIdLen + 1)); // at least 1 byte of COSE key
credentialDataB64 = result.credentialDataB64;
expect(credentialDataB64, isNotEmpty);
});
// -----------------------------------------------------------------------
// Test 4: getAssertion returns non-empty authData and signature
// -----------------------------------------------------------------------
test('getAssertion returns authData and signature bytes', () async {
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
final result = await getAssertion(cid, credentialDataB64);
expect(result.authData, isNotEmpty);
expect(result.signature, isNotEmpty);
expect(result.clientDataHash.length, equals(32));
});
// -----------------------------------------------------------------------
// Test 5: verifyAssertion accepts valid signature
// -----------------------------------------------------------------------
test('verifyAssertion accepts valid assertion signature', () async {
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
final assertion = await getAssertion(cid, credentialDataB64);
final ok = verifyAssertion(
credentialDataB64,
assertion.authData,
assertion.signature,
assertion.clientDataHash,
);
expect(ok, isTrue);
});
// -----------------------------------------------------------------------
// Test 6: verifyAssertion rejects tampered authData
// -----------------------------------------------------------------------
test('verifyAssertion rejects tampered authData', () async {
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
final assertion = await getAssertion(cid, credentialDataB64);
// Flip one byte in authData (byte 32 = flags byte)
final tampered = Uint8List.fromList(assertion.authData);
tampered[32] ^= 0xFF;
final ok = verifyAssertion(
credentialDataB64,
tampered,
assertion.signature,
assertion.clientDataHash,
);
expect(ok, isFalse);
});
});
}

View File

@ -0,0 +1,453 @@
// 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 accepts one request, records it, and replies 200 OK.
// Returns the server and a Completer (use .future to await; .isCompleted to check).
Future<({HttpServer server, Completer<HttpRequest> completer})> _mockHttp() async {
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);
}
// 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
// -------------------------------------------------------------------------
group('HTTP routing', () {
late FilterProxy proxy;
late HttpServer comp2;
late Completer<HttpRequest> comp2Req;
late HttpServer direct;
late Completer<HttpRequest> directReq;
setUp(() async {
final c2 = await _mockHttp();
comp2 = c2.server;
comp2Req = c2.completer;
final d = await _mockHttp();
direct = d.server;
directReq = d.completer;
proxy = FilterProxy(
listenPort: 0,
component2Port: comp2.port,
);
// 'auth.local' is gated; '127.0.0.1' is not.
proxy.setGatedEntries(['auth.local']);
await proxy.start();
});
tearDown(() async {
await proxy.stop();
await comp2.close(force: true);
await direct.close(force: true);
});
test('gated host is forwarded to component2', () async {
final response = await _round(
proxy.port,
'GET http://auth.local/api HTTP/1.1\r\nHost: auth.local\r\n\r\n',
);
final req = await comp2Req.future.timeout(_kTimeout);
expect(req.method, 'GET');
expect(req.uri.path, '/api');
expect(response, contains('200 OK'));
});
test('non-gated host is forwarded directly', () async {
final response = await _round(
proxy.port,
'GET http://127.0.0.1:${direct.port}/page HTTP/1.1\r\n'
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
);
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:${direct.port}/page HTTP/1.1\r\n'
'Host: 127.0.0.1:${direct.port}\r\n\r\n',
);
await directReq.future.timeout(_kTimeout);
// comp2 should never have received anything
expect(comp2Req.isCompleted, isFalse);
});
test('request line is rewritten from absolute URL to relative path', () async {
await _round(
proxy.port,
'GET http://auth.local/session/login?foo=bar HTTP/1.1\r\n'
'Host: auth.local\r\n\r\n',
);
final req = await comp2Req.future.timeout(_kTimeout);
// The mock HttpServer parses the rewritten request.
expect(req.uri.path, '/session/login');
expect(req.uri.query, 'foo=bar');
});
test('Proxy-Connection header is stripped', () async {
await _round(
proxy.port,
'GET http://auth.local/health HTTP/1.1\r\n'
'Host: auth.local\r\n'
'Proxy-Connection: keep-alive\r\n\r\n',
);
final req = await comp2Req.future.timeout(_kTimeout);
expect(req.headers.value('proxy-connection'), isNull);
});
test('Proxy-Authorization header is stripped', () async {
await _round(
proxy.port,
'GET http://auth.local/health HTTP/1.1\r\n'
'Host: auth.local\r\n'
'Proxy-Authorization: Basic dXNlcjpwYXNz\r\n\r\n',
);
final req = await comp2Req.future.timeout(_kTimeout);
expect(req.headers.value('proxy-authorization'), isNull);
});
test('custom header is preserved', () async {
await _round(
proxy.port,
'GET http://auth.local/health HTTP/1.1\r\n'
'Host: auth.local\r\n'
'X-Custom: hello\r\n\r\n',
);
final req = await comp2Req.future.timeout(_kTimeout);
expect(req.headers.value('x-custom'), 'hello');
});
test('POST body is forwarded to component2', () async {
const body = '{"username":"alice"}';
await _round(
proxy.port,
'POST http://auth.local/session/login HTTP/1.1\r\n'
'Host: auth.local\r\n'
'Content-Type: application/json\r\n'
'Content-Length: ${body.length}\r\n\r\n'
'$body',
);
final req = await comp2Req.future.timeout(_kTimeout);
expect(req.method, 'POST');
expect(req.uri.path, '/session/login');
});
});
// -------------------------------------------------------------------------
// Group 3: CONNECT tunnel routing
// -------------------------------------------------------------------------
group('CONNECT routing', () {
late FilterProxy proxy;
late ServerSocket comp2Tcp;
late Future<({Socket socket, StreamSubscription<List<int>> sub})> comp2Conn;
late ServerSocket directTcp;
late Future<Socket> directConn;
setUp(() async {
final c2 = await _mockComp2Tcp();
comp2Tcp = c2.server;
comp2Conn = c2.conn;
final d = await _mockTcp();
directTcp = d.server;
directConn = d.socket;
proxy = FilterProxy(
listenPort: 0,
component2Port: comp2Tcp.port,
);
proxy.setGatedEntries(['auth.local:443']);
await proxy.start();
});
tearDown(() async {
await proxy.stop();
await comp2Tcp.close();
await directTcp.close();
});
test('gated CONNECT returns 200 and tunnels to component2', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n');
await client.flush();
final response = await _waitForConnectResponse(client);
expect(response, contains('200 Connection Established'));
// Verify the tunnel endpoint is component2
final conn = await comp2Conn.timeout(_kTimeout);
conn.sub.cancel();
client.destroy();
});
test('non-gated CONNECT returns 200 and tunnels to direct target', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
// Use 127.0.0.1:${directTcp.port} as the CONNECT target (not gated)
client.write(
'CONNECT 127.0.0.1:${directTcp.port} HTTP/1.1\r\n'
'Host: 127.0.0.1:${directTcp.port}\r\n\r\n');
await client.flush();
final response = await _waitForConnectResponse(client);
expect(response, contains('200 Connection Established'));
await directConn.timeout(_kTimeout);
client.destroy();
});
test('data flows through CONNECT tunnel in both directions', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
// Single listener + broadcast controller so multiple await-for loops can
// consume the stream without "already listened" errors.
final clientBuf = <int>[];
final clientCtrl = StreamController<List<int>>.broadcast();
client.listen((d) { clientBuf.addAll(d); clientCtrl.add(d); });
// Wait until clientBuf contains [expected]. Checks buffer first so data
// that arrived before the await-for subscription is never lost.
Future<void> waitClient(String expected) async {
if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return;
await for (final _ in clientCtrl.stream) {
if (utf8.decode(clientBuf, allowMalformed: true).contains(expected)) return;
}
}
client.write('CONNECT auth.local:443 HTTP/1.1\r\nHost: auth.local:443\r\n\r\n');
await client.flush();
await waitClient('\r\n\r\n').timeout(_kTimeout);
expect(utf8.decode(clientBuf), contains('200 Connection Established'));
// Component2 side of the tunnel: mock already consumed the CONNECT
// request and sent 200; sub is paused so we can install our handler
// before any tunnel data arrives.
final conn = await comp2Conn.timeout(_kTimeout);
final serverSide = conn.socket;
final serverSub = conn.sub;
// Client component2
final serverBuf = <int>[];
final serverGotPing = Completer<void>();
serverSub.onData((d) {
serverBuf.addAll(d);
if (!serverGotPing.isCompleted &&
utf8.decode(serverBuf, allowMalformed: true).contains('PING')) {
serverGotPing.complete();
}
});
serverSub.resume();
client.write('PING');
await client.flush();
await serverGotPing.future.timeout(_kTimeout);
expect(utf8.decode(serverBuf), 'PING');
// Component2 client
final prevLen = clientBuf.length;
serverSide.write('PONG');
await serverSide.flush();
await waitClient('PONG').timeout(_kTimeout);
expect(utf8.decode(clientBuf.sublist(prevLen)), 'PONG');
await clientCtrl.close();
client.destroy();
serverSide.destroy();
});
});
// -------------------------------------------------------------------------
// Group 4: edge cases
// -------------------------------------------------------------------------
group('edge cases', () {
late FilterProxy proxy;
setUp(() async {
proxy = FilterProxy(listenPort: 0, component2Port: 0);
proxy.setGatedEntries([]);
await proxy.start();
});
tearDown(() => proxy.stop());
test('connection with no headers is closed cleanly', () async {
final client = await Socket.connect('127.0.0.1', proxy.port)
.timeout(_kTimeout);
// Send nothing proxy should close when client closes
await client.close();
// No exception means the proxy handled it gracefully
});
test('malformed request line is closed cleanly', () async {
final response = await _round(proxy.port, 'NOT-HTTP\r\n\r\n');
// Proxy should close the connection without crashing
expect(response, isEmpty);
});
test('request with only spaces in request line is closed cleanly', () async {
final response = await _round(proxy.port, ' \r\n\r\n');
expect(response, isEmpty);
});
});
}

View File

@ -0,0 +1,6 @@
// Placeholder real tests will cover proxy logic and CTAPHID framing.
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () => expect(1 + 1, 2));
}

View File

@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""
card_emulator_bridge.py CTAPHID TCP bridge for Android emulator testing.
The Dart ctaphid_channel.dart speaks raw 64-byte CTAPHID packets over TCP.
This bridge listens on :8772 (Android emulator reaches the Mac host at
10.0.2.2), translates CTAPHID frames into CardEmulator calls, and sends
framed CTAPHID responses back.
Run:
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \\
tests/card_emulator_bridge.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import struct
import sys
from typing import Any
import cbor2
from fido2.ctap import CtapError
sys.path.insert(0, os.path.dirname(__file__))
from card_emulator import CardEmulator
LOG = logging.getLogger("bridge")
# CTAPHID constants
_BROADCAST_CID = 0xFFFFFFFF
_CMD_INIT = 0x06
_CMD_CBOR = 0x10
_CMD_ERROR = 0x3F
_CMD_KEEPALIVE = 0x3B
_HID_SIZE = 64
_INIT_PAYLOAD = _HID_SIZE - 7 # 57 bytes usable in an init packet
_CONT_PAYLOAD = _HID_SIZE - 5 # 59 bytes usable in a continuation packet
# CTAP2 authenticator command codes (first byte of CTAPHID_CBOR payload)
_CTAP2_MAKE_CREDENTIAL = 0x01
_CTAP2_GET_ASSERTION = 0x02
_CTAP2_GET_INFO = 0x04
# CTAP error codes
_ERR_INVALID_CMD = 0x01
_ERR_INVALID_LEN = 0x03
# ---------------------------------------------------------------------------
# Packet helpers
# ---------------------------------------------------------------------------
def _pack(cid: int, cmd: int, payload: bytes) -> list[bytes]:
"""Fragment payload into CTAPHID init + continuation packets."""
packets: list[bytes] = []
cid_b = struct.pack(">I", cid)
init = bytearray(_HID_SIZE)
init[:4] = cid_b
init[4] = (cmd & 0x7F) | 0x80
init[5] = (len(payload) >> 8) & 0xFF
init[6] = len(payload) & 0xFF
first_chunk = payload[:_INIT_PAYLOAD]
init[7: 7 + len(first_chunk)] = first_chunk
packets.append(bytes(init))
offset, seq = len(first_chunk), 0
while offset < len(payload):
cont = bytearray(_HID_SIZE)
cont[:4] = cid_b
cont[4] = seq & 0x7F
chunk = payload[offset: offset + _CONT_PAYLOAD]
cont[5: 5 + len(chunk)] = chunk
packets.append(bytes(cont))
offset += len(chunk)
seq += 1
return packets
def _error_pkt(cid: int, code: int) -> bytes:
return _pack(cid, _CMD_ERROR, bytes([code]))[0]
def _keepalive_pkt(cid: int) -> bytes:
# Status 0x02 = TUP_NEEDED (card is processing)
return _pack(cid, _CMD_KEEPALIVE, bytes([0x02]))[0]
# ---------------------------------------------------------------------------
# Per-connection handler
# ---------------------------------------------------------------------------
class _Handler:
"""Handles one Android emulator TCP connection."""
def __init__(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
emulator: CardEmulator,
) -> None:
self._r = reader
self._w = writer
self._emulator = emulator
self._allocated_cid = 1 # fixed CID for single-connection bridge
async def run(self) -> None:
peer = self._w.get_extra_info("peername")
LOG.info("connect from %s", peer)
try:
while True:
cid, cmd, data = await self._recv_message()
await self._dispatch(cid, cmd, data)
except (asyncio.IncompleteReadError, ConnectionResetError, EOFError):
LOG.info("disconnect from %s", peer)
except Exception:
LOG.exception("handler error")
finally:
self._w.close()
try:
await self._w.wait_closed()
except Exception:
pass
# ---- I/O ----------------------------------------------------------------
async def _recv_pkt(self) -> bytes:
return await self._r.readexactly(_HID_SIZE)
async def _send(self, packets: list[bytes]) -> None:
for pkt in packets:
self._w.write(pkt)
await self._w.drain()
# ---- Message reassembly -------------------------------------------------
async def _recv_message(self) -> tuple[int, int, bytes]:
"""Read one full CTAPHID message (init + any continuations).
After every non-final packet the Dart client is blocked waiting for a
response. We send a keepalive so it resumes and sends the next packet.
"""
init_pkt = await self._recv_pkt()
cid = struct.unpack(">I", init_pkt[:4])[0]
cmd = init_pkt[4] & 0x7F
bcnt = (init_pkt[5] << 8) | init_pkt[6]
buf = bytearray(init_pkt[7: 7 + min(bcnt, _INIT_PAYLOAD)])
while len(buf) < bcnt:
# Unblock Dart's _sendPacket which is awaiting a response.
self._w.write(_keepalive_pkt(cid))
await self._w.drain()
cont = await self._recv_pkt()
remaining = bcnt - len(buf)
buf.extend(cont[5: 5 + min(remaining, _CONT_PAYLOAD)])
return cid, cmd, bytes(buf)
# ---- Dispatch -----------------------------------------------------------
async def _dispatch(self, cid: int, cmd: int, data: bytes) -> None:
if cmd == _CMD_INIT:
await self._handle_init(cid, data)
elif cmd == _CMD_CBOR:
await self._handle_cbor(cid, data)
else:
LOG.warning("unknown CTAPHID cmd=0x%02x cid=0x%08x", cmd, cid)
await self._send([_error_pkt(cid, _ERR_INVALID_CMD)])
# ---- CTAPHID INIT -------------------------------------------------------
async def _handle_init(self, cid: int, data: bytes) -> None:
if len(data) < 8:
await self._send([_error_pkt(cid, _ERR_INVALID_LEN)])
return
nonce = data[:8]
new_cid = self._allocated_cid
# Response payload: nonce(8) + new_cid(4) + CTAPHID_version(1)
# + major(1) + minor(1) + build(1) + capabilities(1)
payload = nonce + struct.pack(">I", new_cid) + bytes([2, 1, 0, 0, 0x04])
await self._send(_pack(_BROADCAST_CID, _CMD_INIT, payload))
LOG.info("INIT → CID=0x%08x", new_cid)
# ---- CTAPHID CBOR (CTAP2) -----------------------------------------------
async def _handle_cbor(self, cid: int, data: bytes) -> None:
if not data:
await self._send([_error_pkt(cid, _ERR_INVALID_LEN)])
return
ctap2_cmd = data[0]
body = data[1:] if len(data) > 1 else b""
LOG.info("CTAP2 cmd=0x%02x body=%d bytes", ctap2_cmd, len(body))
try:
if ctap2_cmd == _CTAP2_MAKE_CREDENTIAL:
resp_cbor = self._make_credential(body)
elif ctap2_cmd == _CTAP2_GET_ASSERTION:
resp_cbor = self._get_assertion(body)
elif ctap2_cmd == _CTAP2_GET_INFO:
resp_cbor = self._get_info()
else:
LOG.warning("unsupported CTAP2 cmd=0x%02x", ctap2_cmd)
await self._send([_error_pkt(cid, _ERR_INVALID_CMD)])
return
except CtapError as exc:
code = exc.code.value if hasattr(exc.code, "value") else int(exc.code)
LOG.warning("CtapError 0x%02x: %s", code, exc)
await self._send(_pack(cid, _CMD_CBOR, bytes([code])))
return
except Exception:
LOG.exception("CTAP2 processing error")
await self._send(_pack(cid, _CMD_CBOR, bytes([0x01])))
return
# Success: status 0x00 + CBOR-encoded response map
await self._send(_pack(cid, _CMD_CBOR, bytes([0x00]) + resp_cbor))
# ---- CTAP2 operations ---------------------------------------------------
def _make_credential(self, body: bytes) -> bytes:
params: dict[Any, Any] = cbor2.loads(body)
# CTAP2 spec integer keys: 1=clientDataHash, 2=rp, 3=user,
# 4=pubKeyCredParams, 7=options
client_data_hash: bytes = bytes(params[1])
rp: dict = dict(params[2])
user: dict = dict(params[3])
key_params: list = [dict(kp) for kp in params[4]]
options: dict = dict(params.get(7, {}))
LOG.info("makeCredential rp_id=%r user=%r", rp.get("id"), user.get("name"))
resp = self._emulator.make_credential(
client_data_hash=client_data_hash,
rp=rp,
user=user,
key_params=key_params,
options=options,
)
auth_data_bytes = bytes(resp.auth_data)
LOG.info("makeCredential OK auth_data=%d bytes", len(auth_data_bytes))
# CTAP2 makeCredential response map: 1=fmt, 2=authData, 3=attStmt
return cbor2.dumps({
1: resp.fmt,
2: auth_data_bytes,
3: resp.att_stmt or {},
})
def _get_assertion(self, body: bytes) -> bytes:
params: dict[Any, Any] = cbor2.loads(body)
# CTAP2 spec integer keys: 1=rpId, 2=clientDataHash, 3=allowList, 5=options
rp_id: str = params[1]
client_data_hash: bytes = bytes(params[2])
allow_list_raw: list = list(params.get(3, []))
options: dict = dict(params.get(5, {}))
allow_list = [dict(item) for item in allow_list_raw] or None
LOG.info("getAssertion rp_id=%r allow_list_len=%s",
rp_id, len(allow_list) if allow_list else 0)
resp = self._emulator.get_assertion(
rp_id=rp_id,
client_data_hash=client_data_hash,
allow_list=allow_list,
options=options,
)
auth_data_bytes = bytes(resp.auth_data)
signature = bytes(resp.signature)
# Build credential descriptor for key 1
cred = resp.credential
if hasattr(cred, "id"):
cred_map: dict = {"type": "public-key", "id": bytes(cred.id)}
else:
cred_map = {
k: (bytes(v) if isinstance(v, (bytes, bytearray, memoryview)) else v)
for k, v in (cred or {}).items()
}
LOG.info("getAssertion OK auth_data=%d bytes sig=%d bytes",
len(auth_data_bytes), len(signature))
# CTAP2 getAssertion response map: 1=credential, 2=authData, 3=signature
resp_map: dict[int, Any] = {1: cred_map, 2: auth_data_bytes, 3: signature}
if resp.user:
u = resp.user
resp_map[4] = dict(u) if hasattr(u, "items") else u
return cbor2.dumps(resp_map)
def _get_info(self) -> bytes:
# Minimal getInfo response
return cbor2.dumps({
1: ["FIDO_2_0"], # versions
3: b"\x00" * 16, # aaguid
})
# ---------------------------------------------------------------------------
# Server bootstrap
# ---------------------------------------------------------------------------
async def _serve(host: str, port: int) -> None:
emulator = CardEmulator()
LOG.info("card_emulator_bridge listening on %s:%d — ctrl-C to stop", host, port)
async def _on_connect(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
await _Handler(reader, writer, emulator).run()
server = await asyncio.start_server(_on_connect, host, port)
async with server:
await server.serve_forever()
def _main() -> None:
ap = argparse.ArgumentParser(
description="CTAPHID TCP bridge for Android emulator ↔ CardEmulator"
)
ap.add_argument("--host", default="0.0.0.0", help="Listen host (default 0.0.0.0)")
ap.add_argument("--port", type=int, default=8772, help="Listen port (default 8772)")
args = ap.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
asyncio.run(_serve(args.host, args.port))
if __name__ == "__main__":
_main()