Initial commit: chromecard workspace snapshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
83a6382270
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Local agent and transient artifacts
|
||||||
|
.codex
|
||||||
|
.codex/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
tls/
|
||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Keep firmware SDK tree out of this workspace-tracking repo
|
||||||
|
CR_SDK_CK-main/
|
||||||
|
|
||||||
|
# Flutter/Dart build artifacts
|
||||||
|
k_phone/.dart_tool/
|
||||||
|
k_phone/build/
|
||||||
|
k_phone/.flutter-plugins
|
||||||
|
k_phone/.flutter-plugins-dependencies
|
||||||
|
k_phone/android/.gradle/
|
||||||
|
k_phone/android/local.properties
|
||||||
|
k_phone/android/app/build/
|
||||||
|
k_phone/ios/.symlinks/
|
||||||
|
k_phone/ios/Pods/
|
||||||
|
k_phone/ios/Flutter/App.framework
|
||||||
|
k_phone/ios/Flutter/Flutter.framework
|
||||||
|
k_phone/ios/Flutter/Generated.xcconfig
|
||||||
|
*.g.dart
|
||||||
|
*.freezed.dart
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
**Qubes 3-VM topology:** `k_client` → `k_proxy` → `k_server`, each a Debian 13 AppVM.
|
||||||
|
|
||||||
|
Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP routing). Validated chain:
|
||||||
|
- `k_client localhost:9771` → `k_proxy:8771`
|
||||||
|
- `k_proxy localhost:9780` → `k_server:8780`
|
||||||
|
|
||||||
|
**k_proxy_app.py** — session gateway and FIDO2 auth bridge. Two auth modes:
|
||||||
|
- `probe` (default): validates card presence by subprocess-calling `fido2_probe.py --json`
|
||||||
|
- `fido2-direct`: performs real CTAP2 `makeCredential`/`getAssertion` against the physical card via `python-fido2`; auto-detects the FIDO hidraw device
|
||||||
|
|
||||||
|
`ProxyState` holds all server-side state: in-memory session store (guarded by one lock), enrollment DB (JSON file), and an `UpstreamPool` of persistent TLS connections to k_server. Sessions are lost on restart.
|
||||||
|
|
||||||
|
**k_server_app.py** — protected resource backend. Exposes a monotonic counter behind `X-Proxy-Token` auth. Counter state is in-memory only; resets on restart. Lock guards counter increments.
|
||||||
|
|
||||||
|
**k_client_portal.py** — thin browser-facing portal in k_client. Delegates all auth and resource calls to k_proxy. Holds only a local preferred username; enrollment and session state live in k_proxy.
|
||||||
|
|
||||||
|
**FIDO2 transport:** Card communicates over USB HID (CTAPHID) on `/dev/hidraw0` (FIDO interface, usage page `0xF1D0`). `/dev/hidraw1` is a separate vendor HID interface. If the card re-enumerates, k_proxy auto-detects the correct node. If CTAPHID stops responding, a full USB power cycle is the recovery path.
|
||||||
|
|
||||||
|
**CardEmulator** (`tests/card_emulator.py`) — software emulator of the card for unit tests. Implements `make_credential`/`get_assertion` with real P-256 crypto; `user_confirms=False` simulates card rejection. Wire it into tests by patching `_with_direct_ctap2` and `_drop_direct_device` on `ProxyState`. See the module docstring for the exact patch pattern.
|
||||||
|
|
||||||
|
**Key enrollment endpoints on k_proxy:** `POST /enroll/register`, `GET /enroll/status`, `POST /enroll/update`, `POST /enroll/delete`, `GET /enroll/list`. Usernames are normalized to lowercase, 3–32 chars `[a-z0-9._-]`.
|
||||||
|
|
||||||
|
**Key session endpoints on k_proxy:** `POST /session/login`, `POST /session/status`, `POST /session/logout`, `POST /resource/counter`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Phases 7 (firmware build) and 9 (phone-wireless transport) are externally gated.
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Phase 5 Runbook (Session Reuse Prototype)
|
||||||
|
|
||||||
|
This runbook starts a minimal `k_server` + `k_proxy` prototype for session reuse testing.
|
||||||
|
|
||||||
|
Last updated: 2026-04-26
|
||||||
|
|
||||||
|
Related browser demo:
|
||||||
|
|
||||||
|
- `k_client_portal.py` can now be used in `k_client` at `http://127.0.0.1:8766` to show:
|
||||||
|
- registration
|
||||||
|
- current registered-user list from `k_proxy`
|
||||||
|
- unregister from the browser page
|
||||||
|
- login with card approval/denial
|
||||||
|
- protected `k_server` counter access
|
||||||
|
- logout
|
||||||
|
- explicit "k_server was not called" behavior when login is denied
|
||||||
|
|
||||||
|
## What This Prototype Covers
|
||||||
|
|
||||||
|
- `k_proxy` creates short-lived sessions.
|
||||||
|
- Session creation uses a card-presence check (`fido2_probe.py --json`) as the current auth gate.
|
||||||
|
- Valid sessions can repeatedly access a protected `k_server` counter endpoint without re-running card auth each request.
|
||||||
|
- Session status and logout/invalidation paths are implemented.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
There are two useful ways to run this prototype:
|
||||||
|
|
||||||
|
- Same-VM quickstart: `k_proxy` and `k_server` run on one VM for app-local testing.
|
||||||
|
- Split-VM chain: `k_proxy` runs in `k_proxy`, `k_server` runs in `k_server`, and the Qubes forwarding layer must permit the chain.
|
||||||
|
|
||||||
|
## Start Services
|
||||||
|
|
||||||
|
### Same-VM quickstart
|
||||||
|
|
||||||
|
This matches the code defaults and is useful for basic app behavior only.
|
||||||
|
|
||||||
|
In the chosen VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /home/user/chromecard/k_server_app.py --host 127.0.0.1 --port 8780 --proxy-token dev-proxy-token
|
||||||
|
```
|
||||||
|
|
||||||
|
In the same VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /home/user/chromecard/k_proxy_app.py \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port 8770 \
|
||||||
|
--session-ttl 300 \
|
||||||
|
--server-base-url http://127.0.0.1:8780 \
|
||||||
|
--proxy-token dev-proxy-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Split-VM chain
|
||||||
|
|
||||||
|
This is the current Qubes target shape.
|
||||||
|
|
||||||
|
In `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
|
||||||
|
```
|
||||||
|
|
||||||
|
In `k_proxy` VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qvm-connect-tcp 9780:k_server:8780
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
In `k_client` VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qvm-connect-tcp 9771:k_proxy:8771
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Current validated split-VM path is `k_client localhost:9771 -> k_proxy localhost:8771 -> k_proxy localhost:9780 forward -> k_server localhost:8780`.
|
||||||
|
- Use `--cacert /home/user/chromecard/tls/phase2/ca.crt` for TLS verification in `curl`-based checks.
|
||||||
|
- Raw VM-IP routing is not the validated path for the current prototype.
|
||||||
|
|
||||||
|
## Ownership And Concurrency
|
||||||
|
|
||||||
|
- `k_proxy` is authoritative for session state.
|
||||||
|
- `k_server` is authoritative for the protected counter state.
|
||||||
|
- Sessions are in-memory only in `k_proxy` and are lost on proxy restart.
|
||||||
|
- The protected counter is in-memory only in `k_server` and resets on server restart.
|
||||||
|
- Both services use `ThreadingHTTPServer`.
|
||||||
|
- `k_proxy` guards its session store with a single process-local lock.
|
||||||
|
- `k_server` guards counter increments with a single process-local lock.
|
||||||
|
- Qubes localhost forwarders are transport plumbing only; they are not a source of state authority.
|
||||||
|
|
||||||
|
## Test Flow
|
||||||
|
|
||||||
|
Use the proxy port that matches the mode you started:
|
||||||
|
|
||||||
|
- Same-VM quickstart: `8770`
|
||||||
|
- Split-VM chain: `9771` from `k_client`, `8771` inside `k_proxy`
|
||||||
|
|
||||||
|
Create a session (runs auth gate once):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/session/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"alice"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `session_token` from response, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN='<paste-token>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Check session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/session/status \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Call protected resource multiple times (should not require new login):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/resource/counter \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/resource/counter \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Logout/invalidate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/session/logout \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-check after logout (should fail with 401):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i --cacert /home/user/chromecard/tls/phase2/ca.crt -X POST https://127.0.0.1:<proxy-port>/resource/counter \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regression Script
|
||||||
|
|
||||||
|
For the split-VM chain, use the host-side regression helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/user/chromecard/phase5_chain_regression.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
|
||||||
|
- Drives the test from `k_client` over SSH.
|
||||||
|
- Uses `https://127.0.0.1:9771` and `/home/user/chromecard/tls/phase2/ca.crt` inside `k_client`.
|
||||||
|
- Logs in as `alice`.
|
||||||
|
- Runs `20` counter requests at parallelism `8`.
|
||||||
|
- Verifies that returned counter values are unique and gap-free, then logs out and checks for `401` after logout.
|
||||||
|
|
||||||
|
Useful overrides:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REQUESTS=50 PARALLELISM=12 /home/user/chromecard/phase5_chain_regression.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/user/chromecard/phase5_chain_regression.sh --username alice --client-host k_client
|
||||||
|
```
|
||||||
|
|
||||||
|
For the browser-facing `k_client` page, use the Playwright regression spec:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx playwright install
|
||||||
|
npm run test:k-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- default target is `http://127.0.0.1:8766`
|
||||||
|
- override with `PORTAL_BASE_URL=http://127.0.0.1:8766`
|
||||||
|
- the spec expects manual card confirmation during register and login
|
||||||
|
- timeouts can be tuned with `CARD_REGISTRATION_TIMEOUT_MS` and `CARD_LOGIN_TIMEOUT_MS`
|
||||||
|
- from this host, a forwarded portal URL was used successfully:
|
||||||
|
- `PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client`
|
||||||
|
|
||||||
|
Verified result on 2026-04-25:
|
||||||
|
|
||||||
|
- Live split-VM chain passed end-to-end.
|
||||||
|
- Login, session status, counter reuse, and logout all worked from `k_client`.
|
||||||
|
- A `20` request / `8` worker concurrency burst returned unique, gap-free counter values `23..42`.
|
||||||
|
- The Playwright browser regression for `k_client_portal.py` also passed end-to-end:
|
||||||
|
- register
|
||||||
|
- login
|
||||||
|
- protected counter
|
||||||
|
- logout
|
||||||
|
- unregister
|
||||||
|
|
||||||
|
## Current Limitation
|
||||||
|
|
||||||
|
- The stable deployed baseline still uses card-presence probing, not full assertion verification, for the default auth gate.
|
||||||
|
- Session and counter state are still process-local only; restart loses state.
|
||||||
|
- Upstream trust still relies on a shared static `X-Proxy-Token`.
|
||||||
|
- Experimental direct FIDO2 mode exists in `k_proxy_app.py` behind `--auth-mode fido2-direct`:
|
||||||
|
- direct `/enroll/register` now succeeds
|
||||||
|
- direct `/session/login` now succeeds and returns `auth_mode: "fido2_assertion"`
|
||||||
|
- direct `/session/status`, `/resource/counter`, and `/session/logout` also succeed end-to-end
|
||||||
|
- the mode remains optional for now; the deployed service was returned to default `probe` mode so the validated Phase 5 baseline stays reproducible
|
||||||
|
- Raw CTAP debugging helper exists at `/home/user/chromecard/raw_ctap_probe.py`:
|
||||||
|
- use it on `k_proxy` to exercise low-level `makeCredential` / `getAssertion`
|
||||||
|
- it logs keepalive callbacks and transport exceptions
|
||||||
|
- `phase5_chain_regression.sh` now supports card-interactive direct auth via:
|
||||||
|
- `--interactive-card`
|
||||||
|
- `--expect-auth-mode fido2_assertion`
|
||||||
|
|
||||||
|
## Current Focus
|
||||||
|
|
||||||
|
- Keep the HTTPS split-VM chain reproducible in default `probe` mode.
|
||||||
|
- Decide whether `fido2-direct` is ready to become the default deployed auth path.
|
||||||
|
- Continue Phase 6.5 concurrency work; the active system limit is still higher-fan-out Qubes forwarding on the browser-facing path rather than basic Phase 5 functionality.
|
||||||
|
|
@ -0,0 +1,784 @@
|
||||||
|
# Setup
|
||||||
|
|
||||||
|
Last updated: 2026-04-29
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Repository Policy
|
||||||
|
|
||||||
|
- Treat `/home/user/chromecard/CR_SDK_CK-main` as read-only in this workflow.
|
||||||
|
- Do not add or modify helper/test scripts inside `CR_SDK_CK-main`.
|
||||||
|
- Keep host-side helper scripts at workspace root (`/home/user/chromecard`).
|
||||||
|
|
||||||
|
## Documentation Maintenance
|
||||||
|
|
||||||
|
- Canonical living status docs for this workspace are:
|
||||||
|
- `/home/user/chromecard/Setup.md`
|
||||||
|
- `/home/user/chromecard/Workplan.md`
|
||||||
|
- After each meaningful execution step, update at least:
|
||||||
|
- `Setup.md` for observed environment/runtime state
|
||||||
|
- `Workplan.md` for phase progress and next blocking action
|
||||||
|
- Keep helper script paths consistent in docs:
|
||||||
|
- `/home/user/chromecard/fido2_probe.py`
|
||||||
|
- `/home/user/chromecard/webauthn_local_demo.py`
|
||||||
|
- Treat `CR_SDK_CK-main/README_HOST.md` as historical reference unless its script paths are aligned with this workspace policy.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Experimental ChromeCard connected over USB.
|
||||||
|
- Firmware source tree: `/home/user/chromecard/CR_SDK_CK-main`.
|
||||||
|
- Host-side FIDO2 demo tools:
|
||||||
|
- `/home/user/chromecard/fido2_probe.py`
|
||||||
|
- `/home/user/chromecard/webauthn_local_demo.py`
|
||||||
|
- Target runtime platform: Qubes OS with 3 AppVMs:
|
||||||
|
- `k_client` (browser + enrollment process)
|
||||||
|
- `k_proxy` (card-connected proxy/auth client)
|
||||||
|
- `k_server` (protected resource/backend)
|
||||||
|
|
||||||
|
## Planned Transport Evolution
|
||||||
|
|
||||||
|
- Current phase assumption: card is connected directly to `k_proxy` (USB).
|
||||||
|
- Future target: card is connected to a phone, and `k_proxy` performs validation through a wireless link to that phone.
|
||||||
|
- Design implication: keep authenticator transport behind an abstraction in `k_proxy` so USB-direct and phone-wireless backends can be swapped without changing client/server API contracts.
|
||||||
|
|
||||||
|
## Target Qubes Topology
|
||||||
|
|
||||||
|
- Base template for all AppVMs: `debian-13-xfce`.
|
||||||
|
- Allowed network paths:
|
||||||
|
- `k_client` -> `k_proxy` over TLS
|
||||||
|
- `k_proxy` -> `k_server` over TLS
|
||||||
|
- Response traffic returns on those established connections.
|
||||||
|
- Disallowed direct path:
|
||||||
|
- `k_client` -> `k_server` (direct access should be blocked).
|
||||||
|
|
||||||
|
Functional roles:
|
||||||
|
- `k_client`:
|
||||||
|
- Browser-only traffic client.
|
||||||
|
- Runs a user enrollment process.
|
||||||
|
- `k_proxy`:
|
||||||
|
- Current: connected to the ChromeCard over USB.
|
||||||
|
- Future: connects wirelessly to phone-attached card for validation.
|
||||||
|
- Accepts TLS requests from `k_client`.
|
||||||
|
- Uses card-backed FIDO2/WebAuthn operations to authenticate user/session.
|
||||||
|
- Calls `k_server` over TLS after successful authorization.
|
||||||
|
- Returns proxied data and session information to `k_client`.
|
||||||
|
- `k_server`:
|
||||||
|
- Hosts resource(s) requiring login via the proxy-mediated flow.
|
||||||
|
- Provides a dummy protected resource for early integration testing (monotonic increasing number/counter).
|
||||||
|
- May hold user/session state logic needed for authorization decisions.
|
||||||
|
|
||||||
|
UI baseline for each AppVM (start-menu visible apps):
|
||||||
|
- Firefox
|
||||||
|
- XFCE Terminal
|
||||||
|
- File Manager
|
||||||
|
|
||||||
|
## Target Request Flow
|
||||||
|
|
||||||
|
1. `k_client` sends HTTPS request to `k_proxy`.
|
||||||
|
2. `k_proxy` validates/authenticates user via card-backed flow.
|
||||||
|
3. If allowed, `k_proxy` opens HTTPS request to `k_server` resource.
|
||||||
|
4. `k_server` responds to `k_proxy`.
|
||||||
|
5. `k_proxy` returns response payload to `k_client` plus session state.
|
||||||
|
6. Subsequent requests reuse session state so card auth is not required every request.
|
||||||
|
|
||||||
|
Implementation note:
|
||||||
|
- `k_proxy` does not need a full web server stack; a minimal TLS API service is sufficient.
|
||||||
|
- Session state should be integrity-protected (signed/encrypted token or server-side session ID) with TTL and revocation behavior defined.
|
||||||
|
- `k_proxy` and `k_server` must be safe under concurrent access (thread-safe state handling).
|
||||||
|
|
||||||
|
## Minimum Service Behavior (Current Target)
|
||||||
|
|
||||||
|
- `k_server`:
|
||||||
|
- Expose protected endpoint returning an increasing integer value (dummy resource).
|
||||||
|
- Increment behavior must remain correct under concurrent requests.
|
||||||
|
- Optionally expose/maintain user/session validation logic.
|
||||||
|
- `k_proxy`:
|
||||||
|
- Accept concurrent HTTPS requests from one or more `k_client` instances.
|
||||||
|
- Perform card-backed auth when no valid session is present.
|
||||||
|
- Cache and validate session state so repeated requests avoid card access until expiry.
|
||||||
|
- Forward authorized requests to `k_server` and return upstream data plus session info.
|
||||||
|
|
||||||
|
Thread-safety expectation:
|
||||||
|
- Shared mutable state (counter, session store, user state) must be protected against races.
|
||||||
|
- Parallel requests must not corrupt session records or return duplicate/skipped counter values caused by unsafe updates.
|
||||||
|
|
||||||
|
## Test Topology Requirement
|
||||||
|
|
||||||
|
- Support concurrency testing from multiple simultaneous clients:
|
||||||
|
- multiple browser tabs/processes in one `k_client`, and/or
|
||||||
|
- multiple `k_client` AppVM instances if available.
|
||||||
|
- Validate both correctness and stability under load:
|
||||||
|
- session reuse works as intended
|
||||||
|
- unauthorized access stays blocked
|
||||||
|
- protected counter/resource remains consistent.
|
||||||
|
|
||||||
|
## Current Status Snapshot (2026-04-24)
|
||||||
|
|
||||||
|
- AppVM OS version is confirmed: Debian `13.4` (`k_server`, and same on `k_client`/`k_proxy`).
|
||||||
|
- Python in AppVMs is available: `Python 3.13.5`.
|
||||||
|
- `python3 /home/user/chromecard/fido2_probe.py --list` in `k_proxy` now detects ChromeCard on `/dev/hidraw0` (`vid:pid=4617:5`).
|
||||||
|
- HID raw device nodes are now visible in `k_proxy`:
|
||||||
|
- `/dev/hidraw0` -> `crw-rw----+`
|
||||||
|
- `/dev/hidraw1` -> `crw-------`
|
||||||
|
- `python3 /home/user/chromecard/fido2_probe.py --json` succeeds and returns CTAP2 `getInfo`:
|
||||||
|
- versions: `["FIDO_2_0"]`
|
||||||
|
- aaguid: `1234567890abcdef0123456789abcdef`
|
||||||
|
- options: `rk=false`, `up=true`, `uv=true`
|
||||||
|
- max_msg_size: `1024`
|
||||||
|
- Local WebAuthn demo (`http://localhost:8765` in `k_proxy`) succeeded:
|
||||||
|
- register: `ok=true`, `username=alice`, `credential_count=1`
|
||||||
|
- login/auth: `ok=true`, `username=alice`, `authenticated=true`
|
||||||
|
- Phase 5 prototype services are now available:
|
||||||
|
- `/home/user/chromecard/k_proxy_app.py`
|
||||||
|
- `/home/user/chromecard/k_server_app.py`
|
||||||
|
- `/home/user/chromecard/PHASE5_RUNBOOK.md`
|
||||||
|
- Remote VM access is now available via SSH/SCP aliases:
|
||||||
|
- command execution: `ssh <host> <cmd>`
|
||||||
|
- file copy to VM home: `scp <file> <host>:~`
|
||||||
|
- validated hosts: `k_client`, `k_proxy`, `k_server`
|
||||||
|
- `west` is not currently installed/in PATH: `west not found`.
|
||||||
|
- The checked-out `CR_SDK_CK-main` tree appears incomplete for documented sysbuild role layout:
|
||||||
|
- missing: `mvp`, `setup`, `components`, `samples`
|
||||||
|
- `CR_SDK_CK-main/scripts/build_flash_mvp.sh` exists, but it expects the above role directories.
|
||||||
|
- Python helper scripts were intentionally moved out of `CR_SDK_CK-main/scripts` and are now maintained at workspace root.
|
||||||
|
- Qubes AppVM baseline is now up: `k_client`, `k_proxy`, `k_server` can start and have terminals running.
|
||||||
|
|
||||||
|
Implication:
|
||||||
|
- Live FIDO2 connectivity from `k_proxy` to ChromeCard is confirmed over USB HID/CTAPHID.
|
||||||
|
- Local browser WebAuthn register/login flow is confirmed working in `k_proxy`.
|
||||||
|
- We cannot currently run the documented firmware build/flash flow.
|
||||||
|
|
||||||
|
Session note (2026-04-24):
|
||||||
|
- Markdown tracking was reviewed and normalized around `Setup.md` + `Workplan.md` as the active, continuously updated execution record.
|
||||||
|
- AppVM template decision recorded: use `debian-13-xfce` for `k_client`, `k_proxy`, and `k_server`.
|
||||||
|
- VM start attempt failed with Xen toolstack error: `libxenlight have failed to create new domain 'k_client'`.
|
||||||
|
- VM start blocker was resolved by reducing VM memory to `400` MiB; all three AppVMs now start.
|
||||||
|
- Runtime check from VMs: Debian `13.4` and Python `3.13.5`; `k_proxy` still shows `no hidraw devices`.
|
||||||
|
- After USB assignment to `k_proxy`, `/dev/hidraw0` and `/dev/hidraw1` appeared.
|
||||||
|
- CTAP probe re-run succeeded with detected ChromeCard device and valid CTAP2 `getInfo` response.
|
||||||
|
- Local WebAuthn demo completed successfully for user `alice` (register + login).
|
||||||
|
- Phase 5 starter implementation added with session TTL, logout/invalidation, and proxy->server protected counter forwarding.
|
||||||
|
|
||||||
|
Session note (2026-04-24, doc maintenance):
|
||||||
|
- Top-level Markdown files were re-scanned: `PHASE5_RUNBOOK.md`, `Setup.md`, `Workplan.md`.
|
||||||
|
- `PHASE5_RUNBOOK.md` remains consistent with the current Phase 5 prototype paths and flow.
|
||||||
|
- No plan/setup drift was found requiring behavioral changes; docs remain aligned.
|
||||||
|
- SSH-based VM operation was validated for `k_client`, `k_proxy`, `k_server` (Debian `13.4` confirmed remotely).
|
||||||
|
- SCP file transfer to `k_proxy` home directory was validated with read-back.
|
||||||
|
|
||||||
|
Session note (2026-04-24, remote flow diagnostics):
|
||||||
|
- VM script staging gap found: `/home/user/chromecard/k_proxy_app.py`, `k_server_app.py`, and helper files were missing on AppVMs and were copied via `scp`.
|
||||||
|
- Services were started in VMs and verified locally:
|
||||||
|
- `k_proxy` local health OK on `127.0.0.1:8770` and `127.0.0.1:8771`
|
||||||
|
- `k_server` local health OK on `127.0.0.1:8780`
|
||||||
|
- Verified VM IPs during this run:
|
||||||
|
- `k_proxy`: `10.137.0.12`
|
||||||
|
- `k_server`: `10.137.0.13`
|
||||||
|
- `k_client`: `10.137.0.16`
|
||||||
|
- Current chain failure is network pathing/firewall:
|
||||||
|
- `k_client -> k_proxy` (`10.137.0.12:8771`) times out.
|
||||||
|
- `k_proxy -> k_server` (`10.137.0.13:8780`) times out.
|
||||||
|
- Proxy returns upstream error payload: `server unavailable: timed out`.
|
||||||
|
|
||||||
|
Session note (2026-04-24, markdown re-scan):
|
||||||
|
- Re-read top-level workspace Markdown files: `Setup.md`, `Workplan.md`, `PHASE5_RUNBOOK.md`.
|
||||||
|
- Re-skimmed source-tree reference docs in `CR_SDK_CK-main`, including `BUILD.md`, `README.md`, `README_HOST.md`, `RELEASE.md`, and `distribute_bundle.md`.
|
||||||
|
- Current workspace docs remain aligned with the verified execution record.
|
||||||
|
- Source-tree doc drift remains unchanged:
|
||||||
|
- `README_HOST.md` still points to `./scripts/fido2_probe.py` and `./scripts/webauthn_local_demo.py`.
|
||||||
|
- Active workspace policy continues to treat those paths as historical; maintained helper paths remain `/home/user/chromecard/fido2_probe.py` and `/home/user/chromecard/webauthn_local_demo.py`.
|
||||||
|
- Source-tree build docs continue to describe a full SDK layout with `mvp`, `setup`, `components`, and `samples`, which is still not present in the current local checkout snapshot.
|
||||||
|
|
||||||
|
Session note (2026-04-24, policy retry):
|
||||||
|
- Markdown re-scan was retried after local policy changes.
|
||||||
|
- Re-running the workspace doc scan with a non-login shell completed cleanly, without the earlier SSH/socat startup noise in command output.
|
||||||
|
|
||||||
|
Session note (2026-04-24, chain probe retry):
|
||||||
|
- Re-probed the Qubes access path for `k_client -> k_proxy -> k_server`.
|
||||||
|
- Local forwarded SSH listener ports still exist on the host:
|
||||||
|
- `0.0.0.0:2222` -> `qrexec-client-vm 'k_client' qubes.ConnectTCP+22`
|
||||||
|
- `0.0.0.0:2223` -> `qrexec-client-vm 'k_proxy' qubes.ConnectTCP+22`
|
||||||
|
- `0.0.0.0:2224` -> `qrexec-client-vm 'k_server' qubes.ConnectTCP+22`
|
||||||
|
- These forwarded SSH ports currently fail immediately:
|
||||||
|
- `ssh k_client` / `ssh k_proxy` / `ssh k_server` close immediately on localhost forwarded ports.
|
||||||
|
- Direct `qrexec-client-vm <target> qubes.ConnectTCP+22` returns `Request refused`.
|
||||||
|
- Chain ports are currently blocked at the same qrexec layer:
|
||||||
|
- `qrexec-client-vm k_proxy qubes.ConnectTCP+8770` -> `Request refused`
|
||||||
|
- `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
|
||||||
|
- This means the current blocker is active qrexec policy/service refusal for `qubes.ConnectTCP`, not the Python service code in `k_proxy_app.py` or `k_server_app.py`.
|
||||||
|
- Separate SSH config issue remains on the host:
|
||||||
|
- `/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` is still owned `root:root` but mode `777`, which causes OpenSSH to reject it as insecure on the normal login-shell path.
|
||||||
|
|
||||||
|
Session note (2026-04-25, post-restart probe):
|
||||||
|
- Correct client-facing proxy port is `8771` for the current split-VM chain checks.
|
||||||
|
- SSH to `k_proxy` is working again.
|
||||||
|
- `k_proxy` card visibility is restored after VM restart and card reconnect:
|
||||||
|
- `/dev/hidraw0` and `/dev/hidraw1` are present in `k_proxy`
|
||||||
|
- Current service state after restart:
|
||||||
|
- `k_proxy` has no listener on `127.0.0.1:8771`
|
||||||
|
- `k_server` has no listener on `127.0.0.1:8780`
|
||||||
|
- Current qrexec chain state after restart:
|
||||||
|
- `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
|
||||||
|
- `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
|
||||||
|
- Practical meaning:
|
||||||
|
- SSH and card attachment recovered
|
||||||
|
- phase-5 app services are not currently running in the VMs
|
||||||
|
- qrexec forwarding for the chain ports is still being refused
|
||||||
|
|
||||||
|
Session note (2026-04-25, service restart):
|
||||||
|
- `k_server_app.py` was restarted successfully in `k_server`:
|
||||||
|
- PID `1320`
|
||||||
|
- listening on `127.0.0.1:8780`
|
||||||
|
- `/health` returns `{"ok": true, "service": "k_server", ...}`
|
||||||
|
- `k_proxy_app.py` was restarted successfully in `k_proxy`:
|
||||||
|
- PID `2774`
|
||||||
|
- listening on `127.0.0.1:8771`
|
||||||
|
- `/health` returns `{"ok": true, "service": "k_proxy", "active_sessions": 0, ...}`
|
||||||
|
- Despite local service recovery, qrexec forwarding is still denied:
|
||||||
|
- `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
|
||||||
|
- `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
|
||||||
|
|
||||||
|
Session note (2026-04-25, markdown refresh):
|
||||||
|
- Re-read the active workspace markdown files:
|
||||||
|
- `Setup.md`
|
||||||
|
- `Workplan.md`
|
||||||
|
- `PHASE5_RUNBOOK.md`
|
||||||
|
- Corrected the Phase 5 runbook to distinguish the old same-VM quickstart from the current split-VM chain usage.
|
||||||
|
- Current documented client-facing proxy port for split-VM tests is `8771`.
|
||||||
|
- Current documented blocker remains unchanged:
|
||||||
|
- local service health inside `k_proxy` and `k_server` is good
|
||||||
|
- inter-VM forwarding via `qubes.ConnectTCP` is still refused
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 2 HTTPS bring-up):
|
||||||
|
- Added direct TLS support to:
|
||||||
|
- `/home/user/chromecard/k_proxy_app.py`
|
||||||
|
- `/home/user/chromecard/k_server_app.py`
|
||||||
|
- Added local certificate generator:
|
||||||
|
- `/home/user/chromecard/generate_phase2_certs.py`
|
||||||
|
- Generated local CA and service certs at:
|
||||||
|
- `/home/user/chromecard/tls/phase2/ca.crt`
|
||||||
|
- `/home/user/chromecard/tls/phase2/k_proxy.crt`
|
||||||
|
- `/home/user/chromecard/tls/phase2/k_server.crt`
|
||||||
|
- Certificate generation was corrected to include subject key identifier and authority key identifier so Python TLS verification succeeds.
|
||||||
|
- Current validated HTTPS shape is Qubes-localhost forwarding, not raw VM-IP routing:
|
||||||
|
- in `k_client`: `qvm-connect-tcp 9771:k_proxy:8771`
|
||||||
|
- in `k_proxy`: `qvm-connect-tcp 9780:k_server:8780`
|
||||||
|
- `k_proxy` listens on `https://127.0.0.1:8771`
|
||||||
|
- `k_server` listens on `https://127.0.0.1:8780`
|
||||||
|
- `k_proxy` upstream is `https://127.0.0.1:9780`
|
||||||
|
- Verified HTTPS checks:
|
||||||
|
- `k_client -> k_proxy` `/health` over TLS succeeds with `--cacert /home/user/chromecard/tls/phase2/ca.crt`
|
||||||
|
- `k_proxy -> k_server` `/health` and `/resource/counter` over TLS succeed through the `9780` forwarder
|
||||||
|
- end-to-end `k_client -> k_proxy -> k_server` login + session reuse succeeded over HTTPS
|
||||||
|
- End-to-end verified results:
|
||||||
|
- login returned `ok=true` for `alice`
|
||||||
|
- first protected counter call returned value `1`
|
||||||
|
- second protected counter call returned value `2`
|
||||||
|
- session status remained valid after reuse
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 2.5 ownership and concurrency):
|
||||||
|
- Current prototype state ownership is now explicit:
|
||||||
|
- `k_proxy` is authoritative for session state
|
||||||
|
- `k_server` is authoritative for protected resource state
|
||||||
|
- `k_client` is not authoritative for either session validity or counter/resource state
|
||||||
|
- Current session model in `k_proxy`:
|
||||||
|
- server-side in-memory session store only
|
||||||
|
- opaque bearer token generated by `secrets.token_urlsafe(32)`
|
||||||
|
- per-session fields are `username` and `expires_at`
|
||||||
|
- expiry is enforced in `k_proxy`; `k_server` does not validate client sessions directly
|
||||||
|
- Current resource model in `k_server`:
|
||||||
|
- in-memory monotonic counter guarded by a lock
|
||||||
|
- access allowed only when request arrives from `k_proxy` with the expected `X-Proxy-Token`
|
||||||
|
- Current concurrency model in code:
|
||||||
|
- both services use `ThreadingHTTPServer`
|
||||||
|
- `k_proxy` protects session-map mutations and garbage collection with a single lock
|
||||||
|
- `k_server` protects counter increments with a single lock
|
||||||
|
- TLS verification and upstream fetches happen outside the session lock in `k_proxy`
|
||||||
|
- Current runtime assumptions and limits:
|
||||||
|
- Qubes localhost forwarders are treated as transport plumbing, not as state authorities
|
||||||
|
- if `k_proxy` restarts, in-memory sessions are lost
|
||||||
|
- if `k_server` restarts, the in-memory counter resets
|
||||||
|
- the current shared `X-Proxy-Token` is a prototype trust mechanism, not a final authorization design
|
||||||
|
- Practical meaning:
|
||||||
|
- race-free behavior is currently defined for session CRUD and counter increments inside one process per VM
|
||||||
|
- persistence, distributed session authority, and multi-proxy/multi-server coordination are not implemented yet
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 6 client portal prototype):
|
||||||
|
- Added browser-facing client process:
|
||||||
|
- `/home/user/chromecard/k_client_portal.py`
|
||||||
|
- Current Phase 6 prototype shape:
|
||||||
|
- portal runs in `k_client` on `http://127.0.0.1:8766`
|
||||||
|
- portal keeps local enrolled username state in `k_client`
|
||||||
|
- portal calls `k_proxy` over the validated TLS forward `https://127.0.0.1:9771`
|
||||||
|
- Current local enrollment model:
|
||||||
|
- enrollment is a client-local username selection stored by the portal
|
||||||
|
- no dedicated server-side enrollment API exists yet
|
||||||
|
- Verified portal API flow in `k_client`:
|
||||||
|
- `GET /health` returns `ok=true`
|
||||||
|
- `POST /api/enroll` with `alice` succeeds
|
||||||
|
- `POST /api/login` succeeds and returns a proxy session token
|
||||||
|
- `POST /api/status` succeeds
|
||||||
|
- `POST /api/resource/counter` succeeds twice with upstream values `3` and `4`
|
||||||
|
- `POST /api/logout` succeeds
|
||||||
|
- Current implication:
|
||||||
|
- `k_client` now has a concrete client-side process instead of only runbook curls
|
||||||
|
- browser-facing flow is now available through the local portal
|
||||||
|
- next hardening step is to replace client-local enrollment with the intended enrollment contract and decide whether browser traffic should eventually talk to `k_proxy` directly or continue through a local client portal
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 6 enrollment contract):
|
||||||
|
- Added proxy-side enrollment API and storage:
|
||||||
|
- `POST /enroll/register`
|
||||||
|
- `GET /enroll/status?username=<name>`
|
||||||
|
- persisted prototype store at `/home/user/chromecard/k_proxy_enrollments.json` in `k_proxy`
|
||||||
|
- Current enrollment authority is now `k_proxy`, not the `k_client` portal.
|
||||||
|
- Current portal behavior:
|
||||||
|
- portal enrollment calls `k_proxy` over TLS
|
||||||
|
- portal keeps only a preferred local username for convenience
|
||||||
|
- portal login now depends on proxy-side enrollment existing
|
||||||
|
- Verified behavior:
|
||||||
|
- direct proxy login for unenrolled `bob` returns `{"ok": false, "error": "user not enrolled", ...}`
|
||||||
|
- portal enrollment of `alice` succeeds and persists in proxy-side enrollment storage
|
||||||
|
- proxy enrollment status for `alice` returns `ok=true`
|
||||||
|
- portal login and protected counter access still succeed after enrollment
|
||||||
|
- Practical meaning:
|
||||||
|
- Phase 6 now has a real `k_client -> k_proxy` enrollment request path
|
||||||
|
- the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly
|
||||||
|
|
||||||
|
Session note (2026-04-25, browser target moved to k_proxy):
|
||||||
|
- `k_proxy` now serves the browser-facing portal UI directly on `/` over `https://127.0.0.1:9771`.
|
||||||
|
- `k_client_portal.py` is now a temporary bridge page:
|
||||||
|
- it points users to `https://127.0.0.1:9771/`
|
||||||
|
- it is no longer the primary browser target
|
||||||
|
- Verified direct browser/API target behavior from `k_client`:
|
||||||
|
- `GET https://127.0.0.1:9771/` returns the proxy portal HTML
|
||||||
|
- `GET https://127.0.0.1:9771/health` returns `ok=true`
|
||||||
|
- direct `POST /enroll/register` for `carol` succeeds
|
||||||
|
- direct `POST /session/login` for `carol` succeeds
|
||||||
|
- Current implication:
|
||||||
|
- browser traffic is now intended to go straight to `k_proxy`
|
||||||
|
- the `k_client` portal remains only as a temporary bridge/compatibility layer
|
||||||
|
|
||||||
|
Session note (2026-04-25, k_client browser flow page):
|
||||||
|
- `k_client_portal.py` now also serves a local browser demo page again on `http://127.0.0.1:8766` inside `k_client`.
|
||||||
|
- The page is useful as an operator/demo surface:
|
||||||
|
- register user
|
||||||
|
- login with card approval or denial in `k_proxy`
|
||||||
|
- call the protected `k_server` counter
|
||||||
|
- logout
|
||||||
|
- The page now also exposes current proxy enrollment state:
|
||||||
|
- shows the registered users visible in `k_proxy`
|
||||||
|
- lets the operator select a listed user into the username field
|
||||||
|
- lets the operator unregister users from the browser page
|
||||||
|
- login now uses the current username field instead of only the portal's last remembered user
|
||||||
|
- Added a browser regression harness for the `k_client` page:
|
||||||
|
- `/home/user/chromecard/tests/k_client_portal.spec.js`
|
||||||
|
- `/home/user/chromecard/playwright.config.js`
|
||||||
|
- `/home/user/chromecard/package.json`
|
||||||
|
- intended flow: register, login, call `k_server`, logout, unregister
|
||||||
|
- verified passing live on 2026-04-25 from this host via forwarded portal URL:
|
||||||
|
- `PORTAL_BASE_URL=http://127.0.0.1:18766 npm run test:k-client`
|
||||||
|
- It also makes the negative path explicit:
|
||||||
|
- if login is denied on the card, the page reports that `k_server` was not called
|
||||||
|
- Primary browser-facing app logic still lives on `k_proxy`, but the `k_client` page is now a concrete demo/control surface rather than just a redirect.
|
||||||
|
|
||||||
|
Session note (2026-04-25, provisional enrollment hardening):
|
||||||
|
- The enrollment contract in `k_proxy` is now explicit but provisional.
|
||||||
|
- Current prototype enrollment rules:
|
||||||
|
- usernames are canonicalized to lowercase
|
||||||
|
- allowed username pattern is `3-32` chars using lowercase letters, digits, `.`, `_`, `-`
|
||||||
|
- optional `display_name` is allowed up to `64` chars
|
||||||
|
- enrollment create is create-only and duplicate create returns `user already enrolled`
|
||||||
|
- enrollment update is a separate operation
|
||||||
|
- enrollment delete is a separate operation and removes any active sessions for that username
|
||||||
|
- Current enrollment endpoints on `k_proxy`:
|
||||||
|
- `POST /enroll/register`
|
||||||
|
- `GET /enroll/status?username=<name>`
|
||||||
|
- `POST /enroll/update`
|
||||||
|
- `POST /enroll/delete`
|
||||||
|
- `GET /enroll/list`
|
||||||
|
- Verified behavior from `k_client` against `https://127.0.0.1:9771`:
|
||||||
|
- invalid username `A!` is rejected
|
||||||
|
- create for `dave` with `display_name` succeeds
|
||||||
|
- duplicate create for `dave` is rejected
|
||||||
|
- update for `dave` succeeds
|
||||||
|
- list returns enrolled users and metadata
|
||||||
|
- delete for `dave` succeeds
|
||||||
|
- login for deleted `dave` fails with `user not enrolled`
|
||||||
|
- Deliberate current limit:
|
||||||
|
- enrollment itself still does not require card presence; only login does
|
||||||
|
- this was kept lightweight because the enrollment semantics are expected to change later
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 6.5 concurrency probe):
|
||||||
|
- Added reproducible concurrency probe:
|
||||||
|
- `/home/user/chromecard/phase65_concurrency_probe.py`
|
||||||
|
- probe now supports `--max-workers` so client-side fan-out can be swept explicitly
|
||||||
|
- Successful baseline run from `k_client` against direct proxy path:
|
||||||
|
- `3` users
|
||||||
|
- `4` protected requests per user
|
||||||
|
- `12/12` requests succeeded
|
||||||
|
- counter values were unique and contiguous from `6` to `17`
|
||||||
|
- max observed latency was about `457 ms`
|
||||||
|
- Larger follow-up run exposed current limit:
|
||||||
|
- `5` users
|
||||||
|
- `5` protected requests per user
|
||||||
|
- `18/25` requests succeeded
|
||||||
|
- failures returned TLS EOF / upstream unavailable errors
|
||||||
|
- successful counter values were still unique and contiguous from `18` to `35`
|
||||||
|
- max observed latency was about `758 ms`
|
||||||
|
- Additional Phase 6.5 diagnosis:
|
||||||
|
- fixed a keep-alive/body-drain bug in the HTTP/1.1 experiment so `k_server` no longer misparses follow-on requests as `{}POST`
|
||||||
|
- added an upstream connection pool in `k_proxy`; current default/test setting clamps `k_proxy -> k_server` to one pooled TLS connection
|
||||||
|
- despite that change, a full fan-out run with `25` in-flight protected calls still fails on client-observed TLS EOFs
|
||||||
|
- a worker-limited run now passes cleanly:
|
||||||
|
- `5` users
|
||||||
|
- `5` protected requests per user
|
||||||
|
- `25/25` requests succeeded with `--max-workers 10`
|
||||||
|
- raising client-side fan-out still breaks:
|
||||||
|
- `22/25` requests succeeded with `--max-workers 15`
|
||||||
|
- `15/25` requests succeeded with fully unbounded `25` workers in the latest rerun
|
||||||
|
- Current diagnosis:
|
||||||
|
- the protected counter and session logic stay correct under load; successful values remain unique and contiguous
|
||||||
|
- `k_proxy` and `k_server` can complete the requests that actually reach them
|
||||||
|
- the primary collapse point in current testing is the client-facing Qubes forwarder on `9771`
|
||||||
|
- `qvm_connect_9771.log` shows `qrexec-agent-data` / data-vchan failures and repeated `xs_transaction_start: No space left on device`
|
||||||
|
- `qvm_connect_9780.log` also showed earlier qrexec failures, but the latest worker-threshold evidence points first to connection fan-out on `k_client -> k_proxy`
|
||||||
|
- Practical meaning:
|
||||||
|
- the application logic is good for moderate concurrent use in the current prototype
|
||||||
|
- the direct browser path appears stable around `10` in-flight protected calls in the current Qubes setup
|
||||||
|
- the current concurrency ceiling is being set by Qubes forwarding behavior rather than by the monotonic counter logic
|
||||||
|
|
||||||
|
Session note (2026-04-25, in-VM forwarding test):
|
||||||
|
- Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
|
||||||
|
- Forwarders start and bind locally:
|
||||||
|
- in `k_client`: `qvm-connect-tcp 8771:k_proxy:8771` binds `localhost:8771`
|
||||||
|
- in `k_proxy`: `qvm-connect-tcp 8780:k_server:8780` binds `localhost:8780`
|
||||||
|
- But the actual client->proxy connection is still refused when used:
|
||||||
|
- `k_client` forward log shows `Request refused`
|
||||||
|
- `socat` reports child exit status `126` and `Connection reset by peer`
|
||||||
|
- Local login on `k_proxy` reaches the app but fails on the auth dependency:
|
||||||
|
- `POST /session/login` to `http://127.0.0.1:8771` returns `401`
|
||||||
|
- details: `Missing dependency: python-fido2 ... No module named 'fido2'`
|
||||||
|
- `k_server` was not reached during this login test; current `k_server.log` only shows `/health`.
|
||||||
|
|
||||||
|
Session note (2026-04-25, after python3-fido2 install):
|
||||||
|
- `k_proxy` was restarted after `python3-fido2` installation and now listens again on `127.0.0.1:8771`.
|
||||||
|
- The previous Python import blocker is resolved; local login now reaches the CTAP probe path.
|
||||||
|
- Current local login result on `k_proxy`:
|
||||||
|
- `{"ok": false, "error": "card auth failed", "details": "No CTAP HID devices found."}`
|
||||||
|
- Current forwarded login result from `k_client` is still not completing:
|
||||||
|
- `curl http://127.0.0.1:8771/session/login` -> `Empty reply from server`
|
||||||
|
- `qvm_connect_8771.log` still shows repeated `Request refused` and child exit status `126`
|
||||||
|
- Practical meaning:
|
||||||
|
- Python dependency issue in `k_proxy` is fixed
|
||||||
|
- card access inside `k_proxy` is currently missing again at CTAP/HID level
|
||||||
|
- `k_client -> k_proxy` qrexec forwarding is still effectively denied/refused
|
||||||
|
|
||||||
|
Session note (2026-04-25, card reattached):
|
||||||
|
- Card visibility in `k_proxy` is restored again:
|
||||||
|
- `/dev/hidraw0` and `/dev/hidraw1` present
|
||||||
|
- `fido2_probe.py --list` detects ChromeCard on `/dev/hidraw0`
|
||||||
|
- Local login on `k_proxy` now succeeds again:
|
||||||
|
- `POST /session/login` on `127.0.0.1:8771` returns `200`
|
||||||
|
- session creation for user `alice` succeeded
|
||||||
|
- Remaining failure is isolated to the client-facing qrexec path:
|
||||||
|
- `k_client` -> `localhost:8771` through `qvm-connect-tcp` still returns `Empty reply from server`
|
||||||
|
- `qvm_connect_8771.log` still shows `Request refused`
|
||||||
|
|
||||||
|
Session note (2026-04-25, clean forward retest):
|
||||||
|
- Re-ran both forwards and exercised each hop immediately after local bind.
|
||||||
|
- `k_proxy -> k_server`:
|
||||||
|
- `qvm-connect-tcp 8780:k_server:8780` binds `localhost:8780` in `k_proxy`
|
||||||
|
- first real `POST /resource/counter` through that forward returns `Empty reply from server`
|
||||||
|
- `qvm_connect_8780.log` then records `Request refused` with child exit status `126`
|
||||||
|
- `k_client -> k_proxy`:
|
||||||
|
- `qvm-connect-tcp 8771:k_proxy:8771` binds `localhost:8771` in `k_client`
|
||||||
|
- first real `POST /session/login` through that forward returns `Empty reply from server`
|
||||||
|
- `qvm_connect_8771.log` records `Request refused` with child exit status `126`
|
||||||
|
- Conclusion from this retest:
|
||||||
|
- both forwards fail in the same way
|
||||||
|
- local bind succeeds, but the actual qrexec `qubes.ConnectTCP` request is refused when the first connection is attempted
|
||||||
|
|
||||||
|
Session note (2026-04-25, dom0 policy fix validated):
|
||||||
|
- After changing dom0 policy to use explicit destination VMs instead of `@default` for `qubes.ConnectTCP`, both forwards now work.
|
||||||
|
- Verified hop 1:
|
||||||
|
- in `k_proxy`, `POST http://127.0.0.1:8780/resource/counter` with `X-Proxy-Token: dev-proxy-token` succeeds
|
||||||
|
- response included counter value `1`
|
||||||
|
- Verified hop 2:
|
||||||
|
- in `k_client`, `POST http://127.0.0.1:8771/session/login` succeeds
|
||||||
|
- session token is returned through the `k_client -> k_proxy` forward
|
||||||
|
- Verified full end-to-end flow from `k_client`:
|
||||||
|
- login succeeded and returned session token
|
||||||
|
- `POST /session/status` succeeded
|
||||||
|
- `POST /resource/counter` succeeded twice with upstream values `2` and `3`
|
||||||
|
- `POST /session/logout` succeeded
|
||||||
|
- post-logout `POST /resource/counter` correctly returned `401 invalid or expired session`
|
||||||
|
- Current conclusion:
|
||||||
|
- `k_client -> k_proxy -> k_server` chain is operational
|
||||||
|
- session reuse and logout behavior are working in the current prototype
|
||||||
|
|
||||||
|
Session note (2026-04-25, live chain re-validation and regression helper):
|
||||||
|
- Re-validated the split-VM chain after restart using the current TLS/localhost-forward shape:
|
||||||
|
- `k_client` local `9771` -> `k_proxy:8771`
|
||||||
|
- `k_proxy` local `9780` -> `k_server:8780`
|
||||||
|
- Verified live service state during this run:
|
||||||
|
- `k_server` local `https://127.0.0.1:8780/health` returned `ok=true`
|
||||||
|
- `k_proxy` local `https://127.0.0.1:8771/health` returned `ok=true`
|
||||||
|
- `k_proxy` local `https://127.0.0.1:9780/health` reached `k_server`
|
||||||
|
- `k_client` local `https://127.0.0.1:9771/health` reached `k_proxy`
|
||||||
|
- Verified end-to-end behavior from `k_client`:
|
||||||
|
- login for `alice` succeeded
|
||||||
|
- session status succeeded
|
||||||
|
- protected counter calls succeeded with session reuse
|
||||||
|
- logout succeeded
|
||||||
|
- post-logout protected access returned `401 invalid or expired session`
|
||||||
|
- Added reproducible regression helper at:
|
||||||
|
- `/home/user/chromecard/phase5_chain_regression.sh`
|
||||||
|
- Verified the new helper end-to-end on 2026-04-25:
|
||||||
|
- default run uses `20` requests at parallelism `8`
|
||||||
|
- returned values were unique and gap-free
|
||||||
|
- latest verified counter range from the helper was `43..62`
|
||||||
|
- Practical meaning:
|
||||||
|
- the current blocker is no longer Qubes forwarding for the base Phase 5 chain
|
||||||
|
- the current next-step gap is auth semantics, not transport bring-up
|
||||||
|
|
||||||
|
Session note (2026-04-25, direct FIDO2 auth attempt):
|
||||||
|
- Added an experimental direct FIDO2 path in `/home/user/chromecard/k_proxy_app.py`:
|
||||||
|
- runtime switch: `--auth-mode fido2-direct`
|
||||||
|
- default runtime remains `probe`
|
||||||
|
- Added a low-level CTAP helper at `/home/user/chromecard/raw_ctap_probe.py`:
|
||||||
|
- purpose: bypass `Fido2Client` and exercise raw CTAP2 `makeCredential` / `getAssertion`
|
||||||
|
- logs keepalive callbacks and exact transport exceptions for host-side debugging
|
||||||
|
- Direct-mode intent:
|
||||||
|
- replace the legacy `fido2_probe.py --json` session gate
|
||||||
|
- perform real credential registration and real assertion verification locally in `k_proxy` with `python-fido2`
|
||||||
|
- Current observed blocker on `k_proxy`:
|
||||||
|
- direct `make_credential` fails with `No compatible PIN/UV protocols supported!`
|
||||||
|
- reproduces outside the app in a minimal VM-side probe, so this is not just a handler bug
|
||||||
|
- likely cause is the current card / `python-fido2` stack selecting a PIN/UV-dependent CTAP2 path for registration
|
||||||
|
- Additional probe:
|
||||||
|
- a forced CTAP1 fallback experiment did not fail immediately, but also did not complete quickly enough to treat as a usable working path in this turn
|
||||||
|
- Latest live blocker (2026-04-25, after refactor/deploy):
|
||||||
|
- direct probing is currently blocked before the card Yes/No UI stage because `k_proxy` no longer sees any CTAP HID device
|
||||||
|
- `ssh k_proxy "python3 /home/user/chromecard/fido2_probe.py --list"` now returns `No CTAP HID devices found.`
|
||||||
|
- `ssh k_proxy "ls -l /dev/hidraw*"` shows no `hidraw` nodes at the moment
|
||||||
|
- Follow-up after card reattach (2026-04-25):
|
||||||
|
- `k_proxy` again shows `/dev/hidraw0` and `/dev/hidraw1`
|
||||||
|
- direct node-open check confirms `/dev/hidraw0` is readable as the normal user
|
||||||
|
- `/dev/hidraw1` still returns `PermissionError: [Errno 13] Permission denied`
|
||||||
|
- raw `makeCredential` probe still produced no on-card registration prompt, so the host path is hanging before the firmware Yes/No UI
|
||||||
|
- hidraw mapping confirms `/dev/hidraw0` is the FIDO interface:
|
||||||
|
- report descriptor begins with usage page `0xF1D0`
|
||||||
|
- `get_descriptor('/dev/hidraw0')` returns `report_size_in=64`, `report_size_out=64`
|
||||||
|
- `/dev/hidraw1` is a separate vendor HID interface with usage page `0xFF00`
|
||||||
|
- stale Python probes holding `/dev/hidraw0` were cleared, but behavior did not change
|
||||||
|
- a manual CTAPHID `INIT` packet sent directly to `/dev/hidraw0` writes successfully and still gets no response within `3s`
|
||||||
|
- this places the current blocker below `python-fido2`: raw HID traffic is not getting a CTAPHID reply after the latest reattach
|
||||||
|
- `webauthn_local_demo.py` was re-run inside `k_proxy` after reattach and still produced no card prompt on register
|
||||||
|
- that confirms the current failure is below both the browser WebAuthn path and the direct `python-fido2` path
|
||||||
|
- after a full power cycle and reattach, manual CTAPHID `INIT` on `/dev/hidraw0` started replying again
|
||||||
|
- `webauthn_local_demo.py` register in `k_proxy` then succeeded again, confirming the card transport was recovered by the power cycle
|
||||||
|
- direct host-side registration via `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeded again after pressing `yes` on the card
|
||||||
|
- returned credential material included:
|
||||||
|
- `fmt="none"`
|
||||||
|
- credential id `7986cfcf45663f625eb7fc7b52640d83cf3d0e8a6627eeadaba3126406b1e0b8`
|
||||||
|
- this confirms the recovered direct path now reaches the real card confirmation UI and completes CTAP2 `makeCredential`
|
||||||
|
- `k_proxy_app.py --auth-mode fido2-direct` was then patched to:
|
||||||
|
- use low-level CTAP2 instead of the higher-level `Fido2Client` registration/assertion calls
|
||||||
|
- open the explicit FIDO node `/dev/hidraw0` instead of scanning devices
|
||||||
|
- cache the direct device handle instead of reopening it for each operation
|
||||||
|
- current remaining blocker:
|
||||||
|
- was narrowed through repeated retries to a mix of hidraw node disappearance, older `python-fido2` response-mapping requirements, and CTAP payload-shape mismatches
|
||||||
|
- latest verified state:
|
||||||
|
- after reattach with healthy CTAPHID `INIT`, real app registration through `k_proxy_app.py --auth-mode fido2-direct` now succeeds
|
||||||
|
- `/enroll/register` for `directtest` returned `ok=true` and `has_credential=true`
|
||||||
|
- real app login through `/session/login` for `directtest` also now succeeds after card confirmation
|
||||||
|
- returned `auth_mode` is `fido2_assertion`
|
||||||
|
- session status succeeds
|
||||||
|
- protected `/resource/counter` access succeeds again through `k_proxy -> k_server`
|
||||||
|
- logout succeeds
|
||||||
|
- post-logout protected access returns `401`
|
||||||
|
- direct mode no longer depends on a fixed `/dev/hidraw0` path
|
||||||
|
- after a later re-enumeration where the card appeared on `/dev/hidraw1`, `k_proxy_app.py` was patched to probe available `/dev/hidraw*` nodes and select the first working CTAPHID device automatically
|
||||||
|
- browser registration then worked again without changing the configured `--direct-device-path`
|
||||||
|
- temporary direct-mode hidraw lifetime logging has been removed again after diagnosis
|
||||||
|
- `/home/user/chromecard/phase5_chain_regression.sh` now supports the direct-auth baseline via:
|
||||||
|
- `--interactive-card`
|
||||||
|
- `--login-timeout`
|
||||||
|
- `--expect-auth-mode fido2_assertion`
|
||||||
|
- Practical outcome for this session:
|
||||||
|
- the experimental direct mode is kept in code for follow-up work
|
||||||
|
- the deployed `k_proxy` service was restored to default `probe` mode
|
||||||
|
- verified `alice` login still works afterward, so the validated Phase 5 baseline remains intact
|
||||||
|
|
||||||
|
Session note (2026-04-27, fido2-direct end-to-end browser validation):
|
||||||
|
- Deployed all three services (k_server, k_proxy, k_client_portal) in split-VM chain via SSH/SCP.
|
||||||
|
- k_proxy restarted with --auth-mode fido2-direct.
|
||||||
|
- Full browser flow verified from k_client at http://127.0.0.1:8766 with real card:
|
||||||
|
- Register: makeCredential triggered on card, button press confirmed.
|
||||||
|
- Login: getAssertion triggered on card, button press confirmed.
|
||||||
|
- Counter: k_server returned incremented value.
|
||||||
|
- Logout: session correctly invalidated.
|
||||||
|
- Confirmed: probe mode showed stale directtest enrollment (no credential_data_b64) from earlier session; that is expected.
|
||||||
|
- Bug found and fixed: clicking Register after Login cleared the client-side session token but left the server-side session alive; fix adds a best-effort /session/logout call to k_proxy before re-enrolling.
|
||||||
|
- Current deployed service state:
|
||||||
|
- k_server: https://127.0.0.1:8780, TLS, proxy-token dev-proxy-token
|
||||||
|
- k_proxy: https://127.0.0.1:8771, TLS, --auth-mode fido2-direct, upstream https://127.0.0.1:9780
|
||||||
|
- k_client: http://127.0.0.1:8766, proxy-base-url https://127.0.0.1:9771
|
||||||
|
- Forwards: k_proxy 9780->k_server:8780, k_client 9771->k_proxy:8771
|
||||||
|
- Unit test suite added: tests/test_k_proxy.py (100 tests, all passing, run locally with python3 -m unittest tests/test_k_proxy.py).
|
||||||
|
|
||||||
|
Session note (2026-04-26, markdown maintenance re-scan):
|
||||||
|
- Re-read the maintained workspace markdown set:
|
||||||
|
- `/home/user/chromecard/Setup.md`
|
||||||
|
- `/home/user/chromecard/Workplan.md`
|
||||||
|
- `/home/user/chromecard/PHASE5_RUNBOOK.md`
|
||||||
|
- Re-checked that the currently referenced runtime artifacts still exist in the workspace:
|
||||||
|
- `k_proxy_app.py`
|
||||||
|
- `k_server_app.py`
|
||||||
|
- `k_client_portal.py`
|
||||||
|
- `phase5_chain_regression.sh`
|
||||||
|
- `raw_ctap_probe.py`
|
||||||
|
- `generate_phase2_certs.py`
|
||||||
|
- `tls/phase2/ca.crt`
|
||||||
|
- `tls/phase2/k_proxy.crt`
|
||||||
|
- `tls/phase2/k_server.crt`
|
||||||
|
- Current documentation conclusion:
|
||||||
|
- the workspace still supports the HTTPS localhost-forwarded split-VM chain as the active baseline
|
||||||
|
- direct FIDO2 enrollment/login support exists in code and is documented as an optional follow-up path, not the default deployed runtime
|
||||||
|
- the main unresolved engineering limit is still the higher-fan-out Qubes forwarding ceiling on the browser-facing path, not basic chain bring-up
|
||||||
|
|
||||||
|
Session note (2026-04-27, card emulator and bug fixes):
|
||||||
|
- Added software emulator of the ChromeCard FIDO2 authenticator:
|
||||||
|
- `/home/user/chromecard/tests/card_emulator.py`
|
||||||
|
- implements `make_credential` and `get_assertion` with real P-256 cryptography
|
||||||
|
- in-memory credential store keyed by credential ID (matching firmware layout)
|
||||||
|
- auth_data byte layout and COSE key encoding mirror `fido_make_cred.c` / `fido_get_assertion.c` exactly
|
||||||
|
- `user_confirms=True/False` parameter simulates the card's Yes/No confirmation dialog
|
||||||
|
- `refusing()` method returns a wrapper that forces `user_confirms=False` for integration test paths
|
||||||
|
- `forget_user(username)` simulates card-side credential removal
|
||||||
|
- module docstring is the usage guide
|
||||||
|
- Fixed two bugs in `k_proxy_app.py` that were silently breaking fido2-direct mode:
|
||||||
|
- `RegistrationResponse(id=..., ...)` → `RegistrationResponse(raw_id=..., ...)` (fido2 2.2.0 API)
|
||||||
|
- `AuthenticationResponse(id=..., ...)` → `AuthenticationResponse(raw_id=..., ...)` (same)
|
||||||
|
- both calls raised `TypeError` at runtime, caught by the surrounding `except`, so register and
|
||||||
|
authenticate in fido2-direct mode always returned failure without any visible error
|
||||||
|
- Extended test suite: 22 new tests across `TestCardEmulatorUnit` and `TestCardEmulatorIntegration`
|
||||||
|
- covers: register, authenticate, user-says-no (register and auth), forget, two-user isolation,
|
||||||
|
sign-count monotonicity, wrong RP rejection, empty allow-list rejection
|
||||||
|
- total test count is now 122, all passing locally without card or VMs
|
||||||
|
|
||||||
|
Session note (2026-04-29, Phase 9 k_phone bring-up):
|
||||||
|
- Phase 9 approved and started: Flutter Android app (`k_phone`) replaces `k_proxy` in the auth chain.
|
||||||
|
- Development is happening on Mac (not Qubes) — Android emulator is incompatible with Qubes' Xen hypervisor.
|
||||||
|
- Mac environment:
|
||||||
|
- Flutter SDK installed (stable channel)
|
||||||
|
- Android Studio installed with API 37 emulator (`Pixel_7_Pro_API_37`)
|
||||||
|
- Python package manager: `brew install uv` used as workaround — macOS 26 beta broke `pip` on both Python 3.14 (Homebrew default) and Python 3.12 due to libexpat ABI mismatch
|
||||||
|
- `k_phone` Flutter project scaffolded at `/Users/mortenv.christiansen/Desktop/chromecard/k_phone/`
|
||||||
|
- Kotlin `MainActivity.kt` registers USB HID platform channel (`com.chromecard.kphone/usb_hid`)
|
||||||
|
- `lib/ctaphid_channel.dart`: CTAPHID framing/fragmentation + two transports (USB MethodChannel and emulator TCP socket)
|
||||||
|
- `lib/proxy_service.dart`: background service HTTP proxy (flutter_background_service v5)
|
||||||
|
- `lib/session_manager.dart`: in-memory bearer token sessions with TTL
|
||||||
|
- `lib/k_server_client.dart`: HTTP forwarder to k_server (:8780)
|
||||||
|
- `android/app/src/main/kotlin/com/chromecard/kphone/MainActivity.kt`: USB HID platform channel implementation
|
||||||
|
- Build issues resolved (10+ iterations):
|
||||||
|
- AGP bumped to 8.7.3, Gradle wrapper to 8.10.2, Kotlin to 2.1.0
|
||||||
|
- Foreground service type changed from `connectedDevice` to `dataSync` for emulator compatibility
|
||||||
|
- Notification channel created natively in `MainActivity.onCreate()` before service starts
|
||||||
|
- `MissingPluginException` caught in all USB channel calls (USB plugin not registered in background isolate)
|
||||||
|
- Core library desugaring enabled with `desugar_jdk_libs:2.1.4`
|
||||||
|
- Network security config added to allow cleartext to `10.0.2.2` (Mac host alias in Android emulator)
|
||||||
|
- Card emulator bridge added: `tests/card_emulator_bridge.py`
|
||||||
|
- asyncio TCP server on `127.0.0.1:8772`
|
||||||
|
- bridges CTAPHID packets from Android emulator to Python `CardEmulator`
|
||||||
|
- handles CTAPHID INIT (CID allocation), multi-packet reassembly, CBOR dispatch to `CardEmulator`
|
||||||
|
- run with: `uv run --python 3.12 --with fido2 --with cbor2 --with cryptography tests/card_emulator_bridge.py`
|
||||||
|
- End-to-end bridge verified: app reports `Card open, CID=0x1` — CTAPHID handshake with CardEmulator confirmed
|
||||||
|
- Current status (2026-04-29, emulator FIDO2 verified):
|
||||||
|
- App builds and runs on Android emulator
|
||||||
|
- Service auto-starts (`autoStart: true` for testing; revert to `false` for production)
|
||||||
|
- USB transport falls back to emulator TCP bridge on `10.0.2.2:8772`
|
||||||
|
- FIDO2 endpoints fully implemented (enrollment_db.dart, fido2_ops.dart, proxy_service.dart)
|
||||||
|
- Three bugs fixed during emulator integration:
|
||||||
|
1. CTAP2 command prefix bytes missing from CTAPHID CBOR payload (fido2_ops.dart)
|
||||||
|
2. Socket single-subscription stream bug — `await for ... break` cannot be reused (ctaphid_channel.dart)
|
||||||
|
3. `on StateError` catch masked socket write errors as "user already enrolled" (proxy_service.dart)
|
||||||
|
- Verified end-to-end on emulator with CardEmulator bridge:
|
||||||
|
- `/enroll/register` → makeCredential → `has_credential: true`
|
||||||
|
- `/session/login` → getAssertion + ECDSA verify → `auth_mode: fido2_assertion`
|
||||||
|
- `/session/status`, `/session/logout`, post-logout 401 — all correct
|
||||||
|
- `/resource/counter` fails (k_server not running in Mac test env — expected)
|
||||||
|
- Next step: deploy to real Android phone, test USB HID path with physical ChromeCard
|
||||||
|
|
||||||
|
## Known FIDO2 Transport Boundary
|
||||||
|
|
||||||
|
- FIDO2 on this firmware is handled via USB HID (CTAPHID), not Wi-Fi/BLE/MQTT.
|
||||||
|
- Key code points in `CR_SDK_CK-main`:
|
||||||
|
- `mgr_fido2.c`: `mgr_fido2_init()` registers `fido2_ctaphid_handle_packet`.
|
||||||
|
- `ctaphid.c`: `fido2_ctaphid_handle_packet(...)`.
|
||||||
|
- `cr_config.h`: FIDO2 HID report descriptor definitions.
|
||||||
|
|
||||||
|
## Host Bring-Up Steps (How To Get To A Working FIDO2 Check)
|
||||||
|
|
||||||
|
1. Confirm USB enumeration and HID visibility.
|
||||||
|
- Replug card with a known data-capable cable.
|
||||||
|
- Check: `ls -l /dev/hidraw*`
|
||||||
|
|
||||||
|
2. If needed, grant Linux HID access for this device.
|
||||||
|
- Add rule at `/etc/udev/rules.d/70-chromecard-fido.rules`:
|
||||||
|
```udev
|
||||||
|
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0005", MODE="0660", TAG+="uaccess"
|
||||||
|
```
|
||||||
|
- Reload/apply rules and replug the device.
|
||||||
|
|
||||||
|
3. Verify CTAP HID presence.
|
||||||
|
- `python3 /home/user/chromecard/fido2_probe.py --list`
|
||||||
|
- Then:
|
||||||
|
- `python3 /home/user/chromecard/fido2_probe.py --json`
|
||||||
|
- For raw CTAP debugging on `k_proxy`:
|
||||||
|
- `python3 /home/user/chromecard/raw_ctap_probe.py info`
|
||||||
|
- `python3 /home/user/chromecard/raw_ctap_probe.py make-credential --rp-id localhost`
|
||||||
|
|
||||||
|
4. Run local WebAuthn bring-up demo.
|
||||||
|
- `python3 /home/user/chromecard/webauthn_local_demo.py`
|
||||||
|
- Open `http://localhost:8765` (use `localhost`, not `127.0.0.1`).
|
||||||
|
|
||||||
|
5. Execute register/login test.
|
||||||
|
- Register a user.
|
||||||
|
- Login with the same user.
|
||||||
|
- Confirm no origin/challenge mismatch errors.
|
||||||
|
|
||||||
|
## Build/Flash Prerequisites (How To Get To Firmware Build)
|
||||||
|
|
||||||
|
1. Ensure full SDK checkout layout exists under `CR_SDK_CK-main`:
|
||||||
|
- `mvp`
|
||||||
|
- `setup`
|
||||||
|
- `components`
|
||||||
|
- `samples`
|
||||||
|
|
||||||
|
2. Ensure toolchain is available in shell:
|
||||||
|
- `west --version`
|
||||||
|
- `nrfjprog --version`
|
||||||
|
|
||||||
|
3. Once layout/tooling are in place, run:
|
||||||
|
- `cd /home/user/chromecard/CR_SDK_CK-main`
|
||||||
|
- `./scripts/build_flash_mvp.sh`
|
||||||
|
|
||||||
|
## Open Gaps To Resolve
|
||||||
|
|
||||||
|
- Whether a full `CR_SDK_CK-main` checkout (with role directories) is available locally.
|
||||||
|
- Whether server-side code should be pulled now for broader CIP/WebAuthn integration testing.
|
||||||
|
- Exact enrollment process interface running in `k_client` and how it reaches `k_proxy`.
|
||||||
|
- Upgrade Phase 5 auth gate from card-presence probe to full WebAuthn assertion verification for session creation.
|
||||||
|
- Determine the viable path for real credential registration on `k_proxy`:
|
||||||
|
- enable whatever PIN/UV support the card expects for direct CTAP2 registration, or
|
||||||
|
- adopt a different one-time enrollment path that can persist real credential material for later direct assertion verification.
|
||||||
|
- Restore card visibility inside `k_proxy` so direct probes can reach the card UI again:
|
||||||
|
- `/dev/hidraw*` must exist in `k_proxy`
|
||||||
|
- `fido2_probe.py --list` must detect the card before the raw Yes/No probe can continue
|
||||||
|
- Identify why the host probe hangs before card UI even with `/dev/hidraw0` readable:
|
||||||
|
- determine why CTAPHID `INIT` on the correct FIDO hidraw node receives no reply after reattach
|
||||||
|
- likely recovery targets are the Qubes USB mediation path, a fresh USB reassign, or a `k_proxy` VM/device reset
|
||||||
|
- Precise ownership split of session/user state between `k_proxy` and `k_server`.
|
||||||
|
- Concrete concurrency limits and acceptance criteria (requests/sec, parallel clients, latency/error thresholds).
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
# Workplan
|
||||||
|
|
||||||
|
Last updated: 2026-04-29
|
||||||
|
|
||||||
|
This is the execution plan for making ChromeCard FIDO2 development and validation reproducible on this machine.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Treat `/home/user/chromecard/CR_SDK_CK-main` as read-only.
|
||||||
|
- Keep helper scripts such as `fido2_probe.py` and `webauthn_local_demo.py` at `/home/user/chromecard`.
|
||||||
|
- Target deployment model is Qubes OS with 3 AppVMs based on `debian-13-xfce`: `k_client`, `k_proxy`, `k_server`.
|
||||||
|
- Current authenticator link is card->`k_proxy` (USB), but architecture must allow migration to wireless phone-mediated validation.
|
||||||
|
- VM execution path is SSH-first for experiments: `ssh <host> <cmd>` and `scp <file> <host>:~`.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Re-establish deterministic host-to-card FIDO2 communication over USB HID/CTAPHID.
|
||||||
|
- Restore a buildable/flashable firmware workspace for `CR_SDK_CK-main`.
|
||||||
|
- Turn ad-hoc demos into a repeatable verification flow.
|
||||||
|
- Stand up chained TLS communication in Qubes: `k_client -> k_proxy -> k_server`.
|
||||||
|
- Support both login flow (browser in `k_client`) and user enrollment flow (process in `k_client`).
|
||||||
|
- Minimize repeated card prompts by introducing secure session reuse after successful authentication.
|
||||||
|
- Implement a protected dummy resource on `k_server` (monotonic counter) for end-to-end validation.
|
||||||
|
- Ensure `k_proxy` and `k_server` are thread-safe and support concurrent access.
|
||||||
|
- Prepare `k_proxy` auth path for future transport shift: USB-direct -> wireless phone bridge.
|
||||||
|
|
||||||
|
## Phase 0: Qubes VM Baseline (Blocking)
|
||||||
|
|
||||||
|
1. Provision/verify AppVMs.
|
||||||
|
- Ensure `k_client`, `k_proxy`, `k_server` exist and are based on `debian-13-xfce`.
|
||||||
|
|
||||||
|
2. Assign functional responsibilities.
|
||||||
|
- `k_client`: browser client + enrollment process.
|
||||||
|
- `k_proxy`: USB card access + proxy/auth bridge.
|
||||||
|
- `k_server`: protected resource/service endpoint.
|
||||||
|
|
||||||
|
3. Define TLS endpoints and certificates.
|
||||||
|
- `k_proxy` presents TLS service to `k_client`.
|
||||||
|
- `k_server` presents TLS service to `k_proxy`.
|
||||||
|
- Trust roots and cert distribution model documented per VM.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- All 3 VMs exist, boot, and have clearly defined service ownership.
|
||||||
|
|
||||||
|
## Phase 1: Qubes Firewall Policy
|
||||||
|
|
||||||
|
1. Enforce allowed forward paths only.
|
||||||
|
- Allow `k_client` outbound TLS only to `k_proxy` service port(s).
|
||||||
|
- Allow `k_proxy` outbound TLS only to `k_server` service port(s).
|
||||||
|
- Deny direct `k_client` to `k_server` traffic.
|
||||||
|
|
||||||
|
2. Validate return path behavior.
|
||||||
|
- Confirm responses propagate back through established flows.
|
||||||
|
|
||||||
|
3. Verify with simple probes.
|
||||||
|
- TLS handshake and HTTP(S) checks from `k_client` to `k_proxy`.
|
||||||
|
- TLS handshake and HTTP(S) checks from `k_proxy` to `k_server`.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Policy matches intended chain and is test-verified.
|
||||||
|
|
||||||
|
Status (2026-04-24, remote diagnostics):
|
||||||
|
- Confirmed active blocker remains Phase 1 network policy/pathing.
|
||||||
|
- Evidence from live VM probes:
|
||||||
|
- `k_client (10.137.0.16) -> k_proxy (10.137.0.12:8771)`: TCP timeout.
|
||||||
|
- `k_proxy (10.137.0.12) -> k_server (10.137.0.13:8780)`: upstream timeout.
|
||||||
|
- Local service health inside each VM is good, so failure is inter-VM reachability, not local process startup.
|
||||||
|
|
||||||
|
Status (2026-04-25, after restart and service recovery):
|
||||||
|
- Refined blocker: this is currently a qrexec/`qubes.ConnectTCP` refusal problem, not an app-local listener problem.
|
||||||
|
- Current evidence:
|
||||||
|
- `k_proxy` local `/health` is up on `127.0.0.1:8771`
|
||||||
|
- `k_server` local `/health` is up on `127.0.0.1:8780`
|
||||||
|
- `qrexec-client-vm k_proxy qubes.ConnectTCP+8771` -> `Request refused`
|
||||||
|
- `qrexec-client-vm k_server qubes.ConnectTCP+8780` -> `Request refused`
|
||||||
|
- Immediate next action for Phase 1:
|
||||||
|
- verify and fix the dom0 policy/mechanism that should permit `qubes.ConnectTCP` forwarding for the chain ports
|
||||||
|
|
||||||
|
Status (2026-04-25, dom0 policy fix validated):
|
||||||
|
- The forwarding blocker is cleared for the current prototype shape.
|
||||||
|
- Verified working chain:
|
||||||
|
- `k_client` localhost `9771` -> `k_proxy:8771`
|
||||||
|
- `k_proxy` localhost `9780` -> `k_server:8780`
|
||||||
|
- Verified outcome:
|
||||||
|
- TLS health checks pass on both hops
|
||||||
|
- end-to-end login, session status, protected counter access, and logout all succeed from `k_client`
|
||||||
|
- Phase 1 is complete for the current localhost-forwarded `qubes.ConnectTCP` design.
|
||||||
|
|
||||||
|
## Phase 2: TLS Certificates and Service Endpoints
|
||||||
|
|
||||||
|
1. Certificate model.
|
||||||
|
- Create or import CA and issue certs for `k_proxy` and `k_server`.
|
||||||
|
- Install trust roots in client VM(s) that need validation.
|
||||||
|
|
||||||
|
2. Service shape.
|
||||||
|
- `k_server`: HTTPS service exposing protected resource endpoint(s), including a monotonic counter endpoint.
|
||||||
|
- `k_proxy`: minimal HTTPS API gateway service (full web server framework not required).
|
||||||
|
|
||||||
|
3. Endpoint contract.
|
||||||
|
- Define request/response schema between `k_client` and `k_proxy`.
|
||||||
|
- Define upstream request contract from `k_proxy` to `k_server`.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Mutual TLS trust decisions are documented and tested.
|
||||||
|
- HTTPS calls succeed on both links with expected cert validation.
|
||||||
|
|
||||||
|
Status (2026-04-25):
|
||||||
|
- Implemented HTTPS listeners in both prototype services.
|
||||||
|
- Added local CA + service certificate generation in `generate_phase2_certs.py`.
|
||||||
|
- Verified the working Qubes path is localhost forwarding plus TLS:
|
||||||
|
- `k_client` local `9771` forwards to `k_proxy:8771`
|
||||||
|
- `k_proxy` local `9780` forwards to `k_server:8780`
|
||||||
|
- Verified cert validation on both hops using the generated CA.
|
||||||
|
- Verified end-to-end HTTPS flow:
|
||||||
|
- `k_client -> k_proxy` login over TLS
|
||||||
|
- `k_proxy -> k_server` protected counter call over TLS
|
||||||
|
- session reuse still works across repeated protected requests
|
||||||
|
- Phase 2 is now effectively complete for the current prototype shape.
|
||||||
|
|
||||||
|
## Phase 2.5: Define State Ownership and Concurrency Model
|
||||||
|
|
||||||
|
1. State ownership.
|
||||||
|
- Decide where user/session state is authoritative (`k_proxy`, `k_server`, or split model).
|
||||||
|
- Define token/session format and validation boundary.
|
||||||
|
|
||||||
|
2. Concurrency controls.
|
||||||
|
- Define thread-safe strategy for session store and shared counters.
|
||||||
|
- Define locking/atomic/update semantics for counter increments and session updates.
|
||||||
|
|
||||||
|
3. Runtime model.
|
||||||
|
- Choose service runtime/config that supports simultaneous requests safely.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Architecture clearly documents state authority and race-free update rules.
|
||||||
|
|
||||||
|
Next action (2026-04-25):
|
||||||
|
- Move into Phase 2.5 and make the current prototype decisions explicit:
|
||||||
|
- authority for session state remains `k_proxy`
|
||||||
|
- `k_server` remains authority for the protected counter/resource state
|
||||||
|
- localhost Qubes forwarders are part of the active runtime model for the two TLS hops
|
||||||
|
- define concurrency assumptions and limits around session store, forwarders, and counter access
|
||||||
|
|
||||||
|
Status (2026-04-25):
|
||||||
|
- Current ownership model is now explicit:
|
||||||
|
- `k_proxy` is authoritative for session creation, expiry, lookup, and logout
|
||||||
|
- `k_server` is authoritative for the protected monotonic counter
|
||||||
|
- `k_client` is a client only; it holds bearer tokens but is not a state authority
|
||||||
|
- Current validation boundary is explicit:
|
||||||
|
- `k_proxy` validates bearer tokens against its in-memory session store
|
||||||
|
- `k_server` trusts only requests that arrive with the configured `X-Proxy-Token`
|
||||||
|
- `k_server` does not currently validate end-user session tokens directly
|
||||||
|
- Current concurrency strategy is explicit:
|
||||||
|
- `k_proxy` uses `ThreadingHTTPServer` plus one lock around the in-memory session map
|
||||||
|
- `k_server` uses `ThreadingHTTPServer` plus one lock around counter increments
|
||||||
|
- upstream HTTPS calls from `k_proxy` are made outside the session-store lock
|
||||||
|
- Current runtime limits are explicit:
|
||||||
|
- sessions are process-local and disappear on `k_proxy` restart
|
||||||
|
- counter state is process-local and resets on `k_server` restart
|
||||||
|
- transport relies on Qubes localhost forwarders `9771` and `9780`
|
||||||
|
- Phase 2.5 is complete for the current prototype shape.
|
||||||
|
|
||||||
|
## Phase 3: Recover Basic Device Visibility on `k_proxy` (Blocking)
|
||||||
|
|
||||||
|
1. Verify physical + USB enumeration path.
|
||||||
|
- Check cable/port and confirm device appears in USB listings.
|
||||||
|
- Confirm `/dev/hidraw*` nodes appear when card is connected.
|
||||||
|
|
||||||
|
2. Validate Linux permissions.
|
||||||
|
- Install/update udev rule for ChromeCard HID VID/PID.
|
||||||
|
- Reload udev and verify non-root read/write access to hidraw node.
|
||||||
|
|
||||||
|
3. Re-run host probe.
|
||||||
|
- Run `python3 /home/user/chromecard/fido2_probe.py --list`.
|
||||||
|
- Run `python3 /home/user/chromecard/fido2_probe.py --json`.
|
||||||
|
- Record VID/PID/path and CTAP2 `getInfo` output in `Setup.md`.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- At least one CTAP HID device is listed.
|
||||||
|
- `--json` returns valid `ctap2_info`.
|
||||||
|
|
||||||
|
## Phase 4: Re-validate Local WebAuthn Demo on `k_proxy`
|
||||||
|
|
||||||
|
1. Start local demo server.
|
||||||
|
- Run `python3 /home/user/chromecard/webauthn_local_demo.py`.
|
||||||
|
- Confirm URL is `http://localhost:8765`.
|
||||||
|
|
||||||
|
2. Exercise register/login.
|
||||||
|
- Register a test user.
|
||||||
|
- Authenticate with same user.
|
||||||
|
- Capture errors (if any) and update `Setup.md`.
|
||||||
|
|
||||||
|
3. Decide next demo hardening step.
|
||||||
|
- Keep bring-up-only mode, or
|
||||||
|
- add signature verification for attestation/assertion.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Register and login both complete with card interaction prompts.
|
||||||
|
|
||||||
|
Status (2026-04-24):
|
||||||
|
- Completed in `k_proxy` using `http://localhost:8765`.
|
||||||
|
- Registration result: `ok=true`, `username=alice`, `credential_count=1`.
|
||||||
|
- Authentication result: `ok=true`, `username=alice`, `authenticated=true`.
|
||||||
|
|
||||||
|
## Phase 5: Implement Proxy Auth + Session Reuse
|
||||||
|
|
||||||
|
1. Authenticate via card once per session window.
|
||||||
|
- `k_proxy` handles initial auth using connected card.
|
||||||
|
- On success, create session state for `k_client`.
|
||||||
|
|
||||||
|
2. Session model.
|
||||||
|
- Prefer server-side session store or signed session token.
|
||||||
|
- Include TTL/expiry, rotation, and explicit invalidation/logout path.
|
||||||
|
- Do not expose card secrets or long-lived auth material to `k_client`.
|
||||||
|
|
||||||
|
3. Proxying behavior.
|
||||||
|
- With valid session: `k_proxy` forwards request to `k_server` and returns result.
|
||||||
|
- Without valid session: require fresh card-backed auth flow.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Repeated authorized requests do not require card interaction until session expiry.
|
||||||
|
- Expired/invalid sessions are correctly rejected.
|
||||||
|
|
||||||
|
Status (2026-04-24):
|
||||||
|
- Started with a runnable prototype:
|
||||||
|
- `/home/user/chromecard/k_proxy_app.py`
|
||||||
|
- `/home/user/chromecard/k_server_app.py`
|
||||||
|
- `/home/user/chromecard/PHASE5_RUNBOOK.md`
|
||||||
|
- Implemented in prototype:
|
||||||
|
- session create/status/logout endpoints in `k_proxy`
|
||||||
|
- TTL-based server-side session store with expiry garbage collection
|
||||||
|
- protected monotonic counter endpoint in `k_server` with thread-safe increments
|
||||||
|
- proxy forwarding from `k_proxy` to `k_server` using a shared upstream token
|
||||||
|
- Current auth gate for session creation is card-presence probe (`fido2_probe.py --json`), pending upgrade to full assertion verification path.
|
||||||
|
|
||||||
|
Status (2026-04-25):
|
||||||
|
- Prototype services were re-started successfully after VM restart.
|
||||||
|
- Current split-VM test shape is:
|
||||||
|
- `k_proxy` listening on `127.0.0.1:8771`
|
||||||
|
- `k_server` listening on `127.0.0.1:8780`
|
||||||
|
- End-to-end validation is now passing through the live chain from `k_client`.
|
||||||
|
- Current verified behavior:
|
||||||
|
- login succeeds for `alice`
|
||||||
|
- session status succeeds
|
||||||
|
- repeated protected counter requests succeed with session reuse
|
||||||
|
- logout succeeds
|
||||||
|
- post-logout protected access returns `401`
|
||||||
|
- Added repeatable host-side regression helper:
|
||||||
|
- `/home/user/chromecard/phase5_chain_regression.sh`
|
||||||
|
- Phase 5 is complete for the current prototype semantics.
|
||||||
|
- Experimental follow-up in code:
|
||||||
|
- `k_proxy_app.py` now also has `--auth-mode fido2-direct`
|
||||||
|
- this mode attempts direct credential registration and direct assertion verification with `python-fido2`
|
||||||
|
- it is not the deployed default because direct registration currently fails on `k_proxy` with `No compatible PIN/UV protocols supported!`
|
||||||
|
- `/home/user/chromecard/raw_ctap_probe.py` now exists for lower-level CTAP2 probing with keepalive/error logging
|
||||||
|
- latest retry result: after reattaching the card, `k_proxy` again exposes `/dev/hidraw0` and `/dev/hidraw1`, but raw `makeCredential` still reaches no Yes/No card prompt
|
||||||
|
- `/dev/hidraw0` opens successfully as the normal user; `/dev/hidraw1` is still permission-denied
|
||||||
|
- manual CTAPHID testing now shows `/dev/hidraw0` is the correct FIDO interface and a direct `INIT` write gets no response at all
|
||||||
|
- rerunning `webauthn_local_demo.py` inside `k_proxy` also still gives no card prompt, so the current break is below both browser WebAuthn and direct host probes
|
||||||
|
- after a full power cycle and reattach, manual CTAPHID `INIT` replies again and browser registration in `webauthn_local_demo.py` succeeds again
|
||||||
|
- direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` now also succeeds again after card confirmation
|
||||||
|
- `k_proxy_app.py --auth-mode fido2-direct` has been moved onto low-level CTAP2 with hidraw auto-detection; it still accepts `--direct-device-path`, but no longer breaks if the card re-enumerates onto `/dev/hidraw1`
|
||||||
|
- after repeated fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, real app registration now succeeds for `directtest`
|
||||||
|
|
||||||
|
## Phase 5.5: Implement Dummy Resource + Access Policy on `k_server`
|
||||||
|
|
||||||
|
1. Protected dummy resource.
|
||||||
|
- Add endpoint returning increasing number.
|
||||||
|
- Require valid upstream auth/session context from `k_proxy`.
|
||||||
|
|
||||||
|
2. Optional user/session handling.
|
||||||
|
- Add minimal user/session checks if `k_server` is chosen as authority (or partial authority).
|
||||||
|
|
||||||
|
3. Correctness under concurrency.
|
||||||
|
- Ensure increments are monotonic and race-safe under parallel calls.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Authorized requests obtain consistent increasing values.
|
||||||
|
- Unauthorized requests are rejected.
|
||||||
|
|
||||||
|
Status (2026-04-25):
|
||||||
|
- The protected counter resource is implemented and validated in the live split-VM chain.
|
||||||
|
- Verified behavior:
|
||||||
|
- authorized requests from `k_proxy` obtain increasing values
|
||||||
|
- unauthorized post-logout requests from `k_client` are rejected with `401`
|
||||||
|
- `20` concurrent protected requests through the chain returned unique, gap-free values
|
||||||
|
- Phase 5.5 is complete for the current prototype shape.
|
||||||
|
|
||||||
|
## Phase 6: Integrate Client Enrollment + Proxy Login Flow
|
||||||
|
|
||||||
|
1. Enrollment process in `k_client`.
|
||||||
|
- Start process from `k_client` that captures new-user enrollment intent/data.
|
||||||
|
- Route enrollment requests to `k_proxy` over TLS.
|
||||||
|
|
||||||
|
2. Card-mediated login in `k_proxy`.
|
||||||
|
- `k_proxy` uses connected card for FIDO2/WebAuthn operations.
|
||||||
|
- `k_proxy` authenticates toward `k_server` over TLS.
|
||||||
|
|
||||||
|
3. Browser flow in `k_client`.
|
||||||
|
- Browser traffic goes only to `k_proxy`.
|
||||||
|
|
||||||
|
Immediate next action:
|
||||||
|
- Preserve the now-working direct auth path as a tested option while keeping the default deployed baseline stable.
|
||||||
|
- Verified end-to-end state:
|
||||||
|
- direct `/enroll/register` succeeds for `directtest`
|
||||||
|
- direct `/session/login` succeeds for `directtest`
|
||||||
|
- `/session/status` succeeds
|
||||||
|
- protected `/resource/counter` succeeds through `k_proxy -> k_server`
|
||||||
|
- `/session/logout` succeeds
|
||||||
|
- post-logout protected access returns `401`
|
||||||
|
- Next work should be cleanup/hardening:
|
||||||
|
- decide whether to keep `directtest` enrollment
|
||||||
|
- rerun `phase5_chain_regression.sh --interactive-card --expect-auth-mode fido2_assertion` against the current direct-auth baseline
|
||||||
|
- decide when `fido2-direct` should replace `probe` as the default deployed auth mode
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`.
|
||||||
|
|
||||||
|
Status (2026-04-25):
|
||||||
|
- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
|
||||||
|
- Current prototype flow:
|
||||||
|
- browser now targets `k_proxy` directly over `https://127.0.0.1:9771`
|
||||||
|
- `k_client_portal.py` also serves a local browser flow page on `http://127.0.0.1:8766`
|
||||||
|
- `k_proxy` continues to authenticate with the card and forward to `k_server`
|
||||||
|
- the `k_client` page now also lists registered users from `k_proxy`
|
||||||
|
- the `k_client` page can unregister users from the browser
|
||||||
|
- the portal login action now uses the current username field instead of only the remembered local user
|
||||||
|
- a Playwright regression spec now exists for the browser flow in `tests/k_client_portal.spec.js`
|
||||||
|
- the Playwright browser regression has now passed end-to-end once from this host against a forwarded portal URL
|
||||||
|
- Verified end-to-end through the portal:
|
||||||
|
- enroll `alice`
|
||||||
|
- login succeeds
|
||||||
|
- session status succeeds
|
||||||
|
- protected counter succeeds repeatedly with session reuse
|
||||||
|
- logout succeeds
|
||||||
|
- Enrollment contract progress:
|
||||||
|
- `k_proxy` now exposes prototype enrollment endpoints
|
||||||
|
- proxy-side enrollment storage exists and is checked before login is allowed
|
||||||
|
- direct browser/API traffic can now use those proxy endpoints without going through the local bridge
|
||||||
|
- Phase 6 is materially further along for the current prototype shape:
|
||||||
|
- direct browser target is on `k_proxy`
|
||||||
|
- login/resource flow is integrated on the direct proxy path
|
||||||
|
- enrollment now has a real client->proxy path
|
||||||
|
- the `k_client` page is now a usable demo/operator surface in addition to the direct proxy path
|
||||||
|
- final enrollment semantics are still provisional
|
||||||
|
|
||||||
|
Status (2026-04-25, enrollment hardening):
|
||||||
|
- Added a more explicit provisional enrollment contract in `k_proxy`:
|
||||||
|
- username normalization and validation
|
||||||
|
- optional `display_name`
|
||||||
|
- separate create, update, delete, status, and list operations
|
||||||
|
- delete invalidates existing sessions for that username
|
||||||
|
- Verified the hardened behaviors on the direct proxy path.
|
||||||
|
- Phase 6 is now strong enough to treat the browser/proxy flow as a stable prototype baseline.
|
||||||
|
- The remaining reason Phase 6 is not "final" is product semantics, not missing basic mechanics:
|
||||||
|
- whether enrollment should require card presence
|
||||||
|
- what user attributes belong in enrollment
|
||||||
|
- what re-enroll and recovery should mean
|
||||||
|
|
||||||
|
Status (2026-04-25, Phase 6.5 initial concurrency results):
|
||||||
|
- Added reproducible probe script at `/home/user/chromecard/phase65_concurrency_probe.py`.
|
||||||
|
- Probe now supports `--max-workers` so client-side fan-out can be tested separately from total request count.
|
||||||
|
- Moderate direct-path concurrency passes:
|
||||||
|
- `3 users x 4 requests`
|
||||||
|
- `12/12` successful protected calls
|
||||||
|
- counter values remained unique and contiguous
|
||||||
|
- Larger direct-path concurrency currently fails:
|
||||||
|
- `5 users x 5 requests`
|
||||||
|
- only `18/25` successful protected calls
|
||||||
|
- failed calls report TLS EOF / upstream unavailable errors
|
||||||
|
- Follow-up findings are more precise:
|
||||||
|
- body-drain handling was fixed for the HTTP/1.1 keep-alive experiment
|
||||||
|
- `k_proxy -> k_server` upstream concurrency is now clampable and currently tested at one pooled connection
|
||||||
|
- `5 users x 5 requests` passes at `25/25` when client fan-out is limited to `--max-workers 10`
|
||||||
|
- the same total load still fails at higher fan-out:
|
||||||
|
- `22/25` at `--max-workers 15`
|
||||||
|
- `15/25` at fully unbounded `25` workers in the latest rerun
|
||||||
|
- Current bottleneck is still not counter correctness:
|
||||||
|
- successful results still show unique, contiguous counter values
|
||||||
|
- `k_proxy` and `k_server` complete the requests that actually arrive
|
||||||
|
- Current likely bottleneck is the client-facing Qubes forwarding layer:
|
||||||
|
- `qvm_connect_9771.log` shows qrexec data-vchan failures
|
||||||
|
- observed message includes `xs_transaction_start: No space left on device`
|
||||||
|
- `qvm_connect_9780.log` showed earlier failures too, but the latest threshold test points first to connection fan-out on `k_client -> k_proxy`
|
||||||
|
- Phase 6.5 is therefore started but not complete:
|
||||||
|
- application-level concurrency looks acceptable at moderate load
|
||||||
|
- current working envelope is roughly `10` in-flight protected calls on the direct browser path
|
||||||
|
- higher-load failures still need Qubes forwarding diagnosis before the phase can be closed
|
||||||
|
|
||||||
|
Status (2026-04-25, Phase 5 regression helper):
|
||||||
|
- Added repeatable split-VM regression helper:
|
||||||
|
- `/home/user/chromecard/phase5_chain_regression.sh`
|
||||||
|
- Verified helper result on the live chain:
|
||||||
|
- `20` requests at parallelism `8`
|
||||||
|
- login/session-status/counter/logout sequence completed successfully
|
||||||
|
- returned counter values were unique and gap-free
|
||||||
|
- latest verified helper range was `43..62`
|
||||||
|
- Current implication:
|
||||||
|
- the Phase 5 baseline is now reproducible
|
||||||
|
- next work should target auth semantics rather than basic chain bring-up
|
||||||
|
|
||||||
|
## Phase 6.5: Concurrency and Multi-Client Test Setup
|
||||||
|
|
||||||
|
1. Single-VM concurrency tests.
|
||||||
|
- Generate parallel request bursts from `k_client` to `k_proxy`.
|
||||||
|
- Verify response integrity, session reuse behavior, and error rates.
|
||||||
|
|
||||||
|
2. Multi-client tests.
|
||||||
|
- Run requests from multiple `k_client` instances (or equivalent parallel clients) concurrently.
|
||||||
|
- Verify isolation between users/sessions.
|
||||||
|
|
||||||
|
3. Acceptance checks.
|
||||||
|
- No race-related crashes/corruption in `k_proxy` or `k_server`.
|
||||||
|
- Counter/resource behavior remains correct under load.
|
||||||
|
- Session reuse reduces card prompts while preserving authorization checks.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Test results demonstrate stable concurrent operation with documented limits.
|
||||||
|
|
||||||
|
## Phase 7: Restore Firmware Build/Flash Path
|
||||||
|
|
||||||
|
1. Validate SDK tree completeness.
|
||||||
|
- Confirm presence of `mvp`, `setup`, `components`, `samples` under `CR_SDK_CK-main`.
|
||||||
|
- If missing, obtain full repository/checkpoint and document source.
|
||||||
|
|
||||||
|
2. Install/enable build tools.
|
||||||
|
- Ensure `west` and `nrfjprog` are available in shell.
|
||||||
|
- Confirm target board/toolchain match (`nrf7002dk/nrf5340/cpuapp`, NCS `v2.9.2` baseline in docs).
|
||||||
|
|
||||||
|
3. Run baseline build+flash.
|
||||||
|
- From `CR_SDK_CK-main`, run `./scripts/build_flash_mvp.sh`.
|
||||||
|
- If flashing fails, run documented recovery and retry.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- Successful `west build` and `west flash`.
|
||||||
|
|
||||||
|
## Phase 8: Consolidate Documentation and Paths
|
||||||
|
|
||||||
|
1. Remove path drift between docs and actual files.
|
||||||
|
- Keep `fido2_probe.py` and `webauthn_local_demo.py` at workspace root.
|
||||||
|
- Ensure docs never instruct placing helper scripts under `CR_SDK_CK-main`.
|
||||||
|
- Update references consistently in all docs.
|
||||||
|
|
||||||
|
2. Keep `Setup.md` current.
|
||||||
|
- After each significant change, update status snapshot and outcomes.
|
||||||
|
|
||||||
|
3. Add minimal reproducibility checklist.
|
||||||
|
- One command list for probe + demo + build/flash prechecks.
|
||||||
|
|
||||||
|
4. Maintain Markdown execution records continuously.
|
||||||
|
- `Setup.md` and `Workplan.md` are the canonical living docs for this workspace.
|
||||||
|
- Re-scan relevant `.md` files before each new execution cycle and reconcile drift.
|
||||||
|
- Record date-stamped session notes when priorities or blockers change.
|
||||||
|
|
||||||
|
Status (2026-04-24, markdown maintenance):
|
||||||
|
- Re-scanned the active workspace Markdown set and the main source-tree reference docs.
|
||||||
|
- No workplan phase change was required from this pass.
|
||||||
|
- Ongoing documentation watch item remains path drift in `CR_SDK_CK-main/README_HOST.md`, which still uses historical `./scripts/...` helper locations instead of workspace-root helper paths.
|
||||||
|
- Operational note: the markdown scan path now runs cleanly after policy adjustment when invoked without a login shell.
|
||||||
|
|
||||||
|
Status (2026-04-24, chain probe retry):
|
||||||
|
- Phase 1 remains blocked, but the failure point is now narrowed further:
|
||||||
|
- current refusal occurs at Qubes `qubes.ConnectTCP` policy/service evaluation for ports `22`, `8770`, and `8780`
|
||||||
|
- this happens before any end-to-end app-level request can be retried
|
||||||
|
- Practical implication:
|
||||||
|
- do not spend time on `k_proxy_app.py` / `k_server_app.py` request handling until qrexec forwarding is permitting the intended hops again
|
||||||
|
- next recovery action is to fix/activate the relevant Qubes `qubes.ConnectTCP` policy and then re-run the qrexec bridge checks before testing HTTP flow
|
||||||
|
|
||||||
|
Status (2026-04-25, post-restart probe):
|
||||||
|
- Corrected the client-facing proxy port reference to `8771`.
|
||||||
|
- SSH access to `k_proxy` and card visibility recovered after VM restart.
|
||||||
|
- New immediate blockers are:
|
||||||
|
- `k_proxy` service not listening on `127.0.0.1:8771`
|
||||||
|
- `k_server` service not listening on `127.0.0.1:8780`
|
||||||
|
- qrexec forwarding for `8771` and `8780` still returns `Request refused`
|
||||||
|
- Next retry should start services first, then re-test qrexec forwarding and only then attempt end-to-end client flow.
|
||||||
|
|
||||||
|
Status (2026-04-25, service restart):
|
||||||
|
- Local VM services are running again on the intended loopback ports:
|
||||||
|
- `k_server`: `127.0.0.1:8780`
|
||||||
|
- `k_proxy`: `127.0.0.1:8771`
|
||||||
|
- Phase 1 remains blocked specifically by qrexec policy/forwarding refusal on those ports.
|
||||||
|
- Next action is no longer app startup; it is fixing the `qubes.ConnectTCP` allow path for `8771` and `8780`.
|
||||||
|
|
||||||
|
Status (2026-04-25, in-VM forwarding test):
|
||||||
|
- Verified that using `qvm-connect-tcp` inside the source VMs still does not complete the client->proxy hop:
|
||||||
|
- bind succeeds locally, but first real connection gets `Request refused`
|
||||||
|
- Independent app-layer blocker also found in `k_proxy`:
|
||||||
|
- `python-fido2` is missing there, so local `/session/login` currently fails before card auth can succeed
|
||||||
|
- Current ordered blockers:
|
||||||
|
- first: effective Qubes/qrexec allow path for `k_client -> k_proxy:8771`
|
||||||
|
- second: install `python-fido2` in `k_proxy`
|
||||||
|
- third: re-test end-to-end login and then proxy->server counter flow
|
||||||
|
|
||||||
|
Status (2026-04-25, after python3-fido2 install):
|
||||||
|
- `python3-fido2` blocker in `k_proxy` is resolved.
|
||||||
|
- Updated ordered blockers:
|
||||||
|
- first: effective Qubes/qrexec allow path for `k_client -> k_proxy:8771`
|
||||||
|
- second: restore CTAP HID device visibility/access in `k_proxy` (`No CTAP HID devices found`)
|
||||||
|
- third: re-test end-to-end login and then proxy->server counter flow
|
||||||
|
|
||||||
|
Status (2026-04-25, card reattached):
|
||||||
|
- CTAP HID visibility/access in `k_proxy` is restored.
|
||||||
|
- Local proxy login is working again with the attached card.
|
||||||
|
- The only currently confirmed blocker for the end-to-end path is the `k_client -> k_proxy:8771` qrexec/`qvm-connect-tcp` refusal.
|
||||||
|
|
||||||
|
Status (2026-04-25, clean forward retest):
|
||||||
|
- The retest shows the same qrexec failure mode on both hops, not just the client-facing one.
|
||||||
|
- Updated blocker statement:
|
||||||
|
- effective `qubes.ConnectTCP` allow path is failing for both
|
||||||
|
- `k_client -> k_proxy:8771`
|
||||||
|
- `k_proxy -> k_server:8780`
|
||||||
|
- App services and card path are currently good; forwarding remains the single active system blocker.
|
||||||
|
|
||||||
|
Status (2026-04-25, dom0 policy fix validated):
|
||||||
|
- The explicit-destination dom0 `qubes.ConnectTCP` policy fix resolved forwarding on both hops.
|
||||||
|
- Current verified working chain:
|
||||||
|
- `k_client -> k_proxy:8771`
|
||||||
|
- `k_proxy -> k_server:8780`
|
||||||
|
- Current verified prototype behavior:
|
||||||
|
- session login works from `k_client`
|
||||||
|
- session status works
|
||||||
|
- protected counter flow reaches `k_server`
|
||||||
|
- session reuse avoids re-login for repeated counter calls
|
||||||
|
- logout invalidates the session and subsequent protected access returns `401`
|
||||||
|
- Immediate networking blocker is cleared.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- New team member can follow docs end-to-end without path or tooling ambiguity.
|
||||||
|
|
||||||
|
## Phase 9: Migrate to Phone-Mediated Wireless Validation
|
||||||
|
|
||||||
|
Status (2026-04-29): **ACTIVE — emulator integration verified**
|
||||||
|
|
||||||
|
Architecture: `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
|
||||||
|
- `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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- Deploy to a real Android phone with physical ChromeCard via USB
|
||||||
|
- Verify USB HID path (Kotlin MainActivity.kt platform channel, hidraw node auto-detection)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Status (2026-04-29):
|
||||||
|
- Phase 9 emulator milestone complete: makeCredential + getAssertion verified via CardEmulator bridge.
|
||||||
|
- Next blocking step: deploy to real Android phone with ChromeCard over USB.
|
||||||
|
- k_server is not running in the Mac test environment; counter endpoint will work once running in Qubes.
|
||||||
|
|
||||||
|
Phase status (2026-04-29):
|
||||||
|
- Phase 6.5 (concurrency): deferred. ~10 in-flight ceiling is acceptable.
|
||||||
|
- Phase 7 (firmware build/flash): blocked on Chrome Roads (card vendor).
|
||||||
|
- Phase 9 (phone integration): **emulator FIDO2 verified; physical phone + USB HID path is next.**
|
||||||
|
|
||||||
|
Status (2026-04-26, markdown maintenance):
|
||||||
|
- Re-scanned `Setup.md`, `Workplan.md`, and `PHASE5_RUNBOOK.md` against the current workspace files.
|
||||||
|
|
||||||
|
## Inputs Expected During This Session
|
||||||
|
|
||||||
|
- Exact observed behavior on reconnect attempts (USB/hidraw/probe).
|
||||||
|
- Whether we should pull server-side code now.
|
||||||
|
- Any board/firmware variants different from default documentation assumptions.
|
||||||
|
- Preferred TLS ports, certificate approach, and hostname scheme for `k_client`, `k_proxy`, `k_server`.
|
||||||
|
- Session TTL and invalidation requirements for cached authenticated access.
|
||||||
|
- Decision on where user/session authority lives (`k_proxy` vs `k_server` vs split).
|
||||||
|
- Target concurrency level for validation (parallel clients and parallel requests per client).
|
||||||
|
- Preferred wireless transport/protocol between `k_proxy` and phone (for future phase).
|
||||||
|
|
||||||
|
## Session Maintenance Notes (2026-04-24)
|
||||||
|
|
||||||
|
- Top-level Markdown review completed for `PHASE5_RUNBOOK.md`, `Setup.md`, and `Workplan.md`.
|
||||||
|
- Current execution plan remains in sync with the Phase 5 runbook:
|
||||||
|
- prototype services at `/home/user/chromecard/k_proxy_app.py` and `/home/user/chromecard/k_server_app.py`
|
||||||
|
- run sequence documented in `/home/user/chromecard/PHASE5_RUNBOOK.md`
|
||||||
|
- No phase ordering or blocker changes were required from this review pass.
|
||||||
|
- Remote execution support is now active and validated:
|
||||||
|
- `ssh` command execution works for `k_client`, `k_proxy`, `k_server`
|
||||||
|
- `scp` push to VM home works (validated on `k_proxy`)
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Manual CTAPHID INIT probe for a specific hidraw node.
|
||||||
|
|
||||||
|
This bypasses python-fido2's device bootstrap so we can see whether the raw HID
|
||||||
|
transport itself exchanges packets on the expected FIDO interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import select
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CTAPHID_INIT = 0x06
|
||||||
|
TYPE_INIT = 0x80
|
||||||
|
BROADCAST_CID = 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def build_init_packet(nonce: bytes) -> bytes:
|
||||||
|
frame = struct.pack(">IBH", BROADCAST_CID, TYPE_INIT | CTAPHID_INIT, len(nonce)) + nonce
|
||||||
|
return b"\0" + frame.ljust(64, b"\0")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Manual CTAPHID INIT probe")
|
||||||
|
parser.add_argument("--device-path", default="/dev/hidraw0")
|
||||||
|
parser.add_argument("--timeout", type=float, default=3.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
path = Path(args.device_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"missing device: {path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
nonce = secrets.token_bytes(8)
|
||||||
|
packet = build_init_packet(nonce)
|
||||||
|
print(f"device={path}")
|
||||||
|
print(f"nonce={nonce.hex()}")
|
||||||
|
print(f"write_len={len(packet)}")
|
||||||
|
print(f"write_hex={packet.hex()}")
|
||||||
|
|
||||||
|
fd = os.open(str(path), os.O_RDWR)
|
||||||
|
try:
|
||||||
|
written = os.write(fd, packet)
|
||||||
|
print(f"written={written}")
|
||||||
|
poller = select.poll()
|
||||||
|
poller.register(fd, select.POLLIN)
|
||||||
|
events = poller.poll(int(args.timeout * 1000))
|
||||||
|
print(f"events={events}")
|
||||||
|
if not events:
|
||||||
|
print("timeout_waiting_for_response")
|
||||||
|
return 1
|
||||||
|
response = os.read(fd, 64)
|
||||||
|
print(f"read_len={len(response)}")
|
||||||
|
print(f"read_hex={response.hex()}")
|
||||||
|
if len(response) >= 24:
|
||||||
|
cid, cmd, bc = struct.unpack(">IBH", response[:7])
|
||||||
|
print(f"resp_cid=0x{cid:08x}")
|
||||||
|
print(f"resp_cmd=0x{cmd:02x}")
|
||||||
|
print(f"resp_bc={bc}")
|
||||||
|
print(f"resp_payload={response[7:7+bc].hex()}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Minimal host tool for checking CTAP2/FIDO2 connectivity to a ChromeCard.
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
pip install fido2
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fido2.hid import CtapHidDevice
|
||||||
|
from fido2.ctap2 import Ctap2
|
||||||
|
except Exception as exc:
|
||||||
|
print("Missing dependency: python-fido2", file=sys.stderr)
|
||||||
|
print("Install with: python3 -m pip install fido2", file=sys.stderr)
|
||||||
|
print(f"Import error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: Any) -> Any:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.hex()
|
||||||
|
if isinstance(value, set):
|
||||||
|
return sorted(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices() -> list[CtapHidDevice]:
|
||||||
|
return list(CtapHidDevice.list_devices())
|
||||||
|
|
||||||
|
|
||||||
|
def describe_device(dev: CtapHidDevice) -> dict[str, Any]:
|
||||||
|
desc = getattr(dev, "descriptor", None)
|
||||||
|
out = {
|
||||||
|
"product_name": getattr(desc, "product_name", None),
|
||||||
|
"manufacturer": getattr(desc, "manufacturer_string", None),
|
||||||
|
"vendor_id": getattr(desc, "vid", None),
|
||||||
|
"product_id": getattr(desc, "pid", None),
|
||||||
|
"path": getattr(desc, "path", None),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def print_device_table(devs: list[CtapHidDevice]) -> None:
|
||||||
|
if not devs:
|
||||||
|
print("No CTAP HID devices found.")
|
||||||
|
return
|
||||||
|
for idx, dev in enumerate(devs):
|
||||||
|
info = describe_device(dev)
|
||||||
|
print(
|
||||||
|
f"[{idx}] "
|
||||||
|
f"vid:pid={info['vendor_id']}:{info['product_id']} "
|
||||||
|
f"manufacturer={info['manufacturer']} "
|
||||||
|
f"product={info['product_name']} "
|
||||||
|
f"path={info['path']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_info(dev: CtapHidDevice) -> dict[str, Any]:
|
||||||
|
ctap2 = Ctap2(dev)
|
||||||
|
info = ctap2.get_info()
|
||||||
|
out = {
|
||||||
|
"versions": getattr(info, "versions", None),
|
||||||
|
"extensions": getattr(info, "extensions", None),
|
||||||
|
"aaguid": getattr(info, "aaguid", None),
|
||||||
|
"options": getattr(info, "options", None),
|
||||||
|
"max_msg_size": getattr(info, "max_msg_size", None),
|
||||||
|
"pin_uv_protocols": getattr(info, "pin_uv_protocols", None),
|
||||||
|
"firmware_version": getattr(info, "firmware_version", None),
|
||||||
|
"transports": getattr(info, "transports", None),
|
||||||
|
"algorithms": getattr(info, "algorithms", None),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Probe USB FIDO2/CTAP2 devices")
|
||||||
|
parser.add_argument(
|
||||||
|
"--index",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Device index from --list output (default: 0)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list",
|
||||||
|
action="store_true",
|
||||||
|
help="List devices only",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Print CTAP2 info as JSON",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
devs = list_devices()
|
||||||
|
if args.list:
|
||||||
|
print_device_table(devs)
|
||||||
|
return 0 if devs else 1
|
||||||
|
|
||||||
|
if not devs:
|
||||||
|
print("No CTAP HID devices found.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.index < 0 or args.index >= len(devs):
|
||||||
|
print(f"Invalid --index {args.index}; found {len(devs)} device(s).", file=sys.stderr)
|
||||||
|
print_device_table(devs)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
dev = devs[args.index]
|
||||||
|
dev_meta = describe_device(dev)
|
||||||
|
info = get_info(dev)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
payload = {"device": dev_meta, "ctap2_info": info}
|
||||||
|
print(json.dumps(payload, indent=2, default=_json_default))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("Selected device:")
|
||||||
|
print(
|
||||||
|
f" vid:pid={dev_meta['vendor_id']}:{dev_meta['product_id']} "
|
||||||
|
f"manufacturer={dev_meta['manufacturer']} "
|
||||||
|
f"product={dev_meta['product_name']}"
|
||||||
|
)
|
||||||
|
print(f" path={dev_meta['path']}")
|
||||||
|
print("CTAP2 getInfo:")
|
||||||
|
print(json.dumps(info, indent=2, default=_json_default))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a small local CA plus leaf certificates for Phase 2 HTTPS testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||||
|
|
||||||
|
|
||||||
|
def build_name(common_name: str) -> x509.Name:
|
||||||
|
return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
|
||||||
|
|
||||||
|
|
||||||
|
def new_private_key() -> rsa.RSAPrivateKey:
|
||||||
|
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_key(path: Path, key: rsa.RSAPrivateKey) -> None:
|
||||||
|
path.write_bytes(
|
||||||
|
key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_cert(path: Path, cert: x509.Certificate) -> None:
|
||||||
|
path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sans(names: list[str]) -> list[x509.GeneralName]:
|
||||||
|
sans: list[x509.GeneralName] = []
|
||||||
|
seen = set()
|
||||||
|
for value in names:
|
||||||
|
if value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
try:
|
||||||
|
sans.append(x509.IPAddress(ipaddress.ip_address(value)))
|
||||||
|
except ValueError:
|
||||||
|
sans.append(x509.DNSName(value))
|
||||||
|
return sans
|
||||||
|
|
||||||
|
|
||||||
|
def issue_ca(common_name: str, valid_days: int) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
key = new_private_key()
|
||||||
|
subject = issuer = build_name(common_name)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(issuer)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=valid_days))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
|
||||||
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False)
|
||||||
|
.add_extension(x509.KeyUsage(digital_signature=True, key_encipherment=False, key_cert_sign=True, crl_sign=True, content_commitment=False, data_encipherment=False, key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
return key, cert
|
||||||
|
|
||||||
|
|
||||||
|
def issue_leaf(
|
||||||
|
ca_key: rsa.RSAPrivateKey,
|
||||||
|
ca_cert: x509.Certificate,
|
||||||
|
common_name: str,
|
||||||
|
san_values: list[str],
|
||||||
|
valid_days: int,
|
||||||
|
) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
key = new_private_key()
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(build_name(common_name))
|
||||||
|
.issuer_name(ca_cert.subject)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=valid_days))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
|
.add_extension(x509.SubjectAlternativeName(parse_sans(san_values)), critical=False)
|
||||||
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
|
||||||
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
|
||||||
|
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
||||||
|
.add_extension(x509.KeyUsage(digital_signature=True, key_encipherment=True, key_cert_sign=False, crl_sign=False, content_commitment=False, data_encipherment=False, key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
return key, cert
|
||||||
|
|
||||||
|
|
||||||
|
def emit_leaf_bundle(
|
||||||
|
out_dir: Path,
|
||||||
|
leaf_name: str,
|
||||||
|
ca_key: rsa.RSAPrivateKey,
|
||||||
|
ca_cert: x509.Certificate,
|
||||||
|
san_values: list[str],
|
||||||
|
valid_days: int,
|
||||||
|
) -> None:
|
||||||
|
key, cert = issue_leaf(ca_key, ca_cert, leaf_name, san_values, valid_days)
|
||||||
|
write_private_key(out_dir / f"{leaf_name}.key", key)
|
||||||
|
write_cert(out_dir / f"{leaf_name}.crt", cert)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate local CA and Phase 2 service certificates")
|
||||||
|
parser.add_argument("--out-dir", default="tls/phase2")
|
||||||
|
parser.add_argument("--valid-days", type=int, default=30)
|
||||||
|
parser.add_argument("--ca-common-name", default="ChromeCard Phase2 Local CA")
|
||||||
|
parser.add_argument(
|
||||||
|
"--proxy-san",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Extra SAN for k_proxy certificate; may be repeated",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-san",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Extra SAN for k_server certificate; may be repeated",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
out_dir = Path(args.out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ca_key, ca_cert = issue_ca(args.ca_common_name, args.valid_days)
|
||||||
|
write_private_key(out_dir / "ca.key", ca_key)
|
||||||
|
write_cert(out_dir / "ca.crt", ca_cert)
|
||||||
|
|
||||||
|
proxy_sans = ["localhost", "127.0.0.1", "k_proxy", *args.proxy_san]
|
||||||
|
server_sans = ["localhost", "127.0.0.1", "k_server", *args.server_san]
|
||||||
|
|
||||||
|
emit_leaf_bundle(out_dir, "k_proxy", ca_key, ca_cert, proxy_sans, args.valid_days)
|
||||||
|
emit_leaf_bundle(out_dir, "k_server", ca_key, ca_cert, server_sans, args.valid_days)
|
||||||
|
|
||||||
|
print(f"Generated CA and leaf certificates in {out_dir}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,850 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
k_client_portal — browser-facing portal running in k_client.
|
||||||
|
|
||||||
|
Serves the single-page UI and thin API shim that delegates every auth and
|
||||||
|
resource operation to k_proxy over the localhost-forwarded TLS endpoint.
|
||||||
|
Persists one preferred username locally; all session and enrollment state
|
||||||
|
lives in k_proxy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ChromeCard Client Flow</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3efe8;
|
||||||
|
--panel: #fffdf8;
|
||||||
|
--ink: #181614;
|
||||||
|
--muted: #655f56;
|
||||||
|
--line: #d9cfbf;
|
||||||
|
--accent: #0c6a60;
|
||||||
|
--accent-2: #8a5b2b;
|
||||||
|
--ok: #17653c;
|
||||||
|
--warn: #8f5b00;
|
||||||
|
--bad: #8a1f28;
|
||||||
|
--shadow: rgba(55, 41, 19, 0.08);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(12,106,96,0.12), transparent 34%),
|
||||||
|
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 56px;
|
||||||
|
}
|
||||||
|
.hero, .panel {
|
||||||
|
padding: 22px 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
|
||||||
|
box-shadow: 0 18px 40px var(--shadow);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 62ch;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.9fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.actions, .row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
text-decoration: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.secondary { background: var(--accent-2); }
|
||||||
|
button.ghost {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
}
|
||||||
|
.status-card h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.status-line {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#usersList {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.86);
|
||||||
|
}
|
||||||
|
.user-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.user-subtle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.84);
|
||||||
|
}
|
||||||
|
.step-index {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 4px solid var(--accent-2);
|
||||||
|
background: rgba(138,91,43,0.08);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #16130f;
|
||||||
|
color: #efe7da;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>ChromeCard Client Flow</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
This page runs in `k_client` and drives the real split-VM flow:
|
||||||
|
register a user, ask the card in `k_proxy` for approval, and then call
|
||||||
|
the protected counter on `k_server` only if auth succeeds.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="stack">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<span class="badge">Browser: k_client</span>
|
||||||
|
<span class="badge">Card: k_proxy</span>
|
||||||
|
<span class="badge">Resource: k_server</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input id="username" value="directtest" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="registerBtn">Register User</button>
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
<button id="counterBtn">Call k_server</button>
|
||||||
|
<button id="logoutBtn" class="secondary">Logout</button>
|
||||||
|
<button id="runFlowBtn" class="ghost">Run Full Flow</button>
|
||||||
|
<button id="refreshBtn" class="ghost">Refresh State</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint" id="hintBox">
|
||||||
|
Registration: press <strong>yes</strong> on the card to enroll.
|
||||||
|
Login: press <strong>yes</strong> to allow the identity check, or
|
||||||
|
<strong>no</strong> to deny it. If login is denied, this page will
|
||||||
|
show that `k_server` was not called.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-index">1</div>
|
||||||
|
<div>
|
||||||
|
<strong>Register user</strong><br>
|
||||||
|
Creates or refreshes the enrolled identity in `k_proxy`.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-index">2</div>
|
||||||
|
<div>
|
||||||
|
<strong>Authenticate with the card</strong><br>
|
||||||
|
`k_proxy` asks the card for approval. Press `yes` to continue or `no` to reject.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-index">3</div>
|
||||||
|
<div>
|
||||||
|
<strong>Call `k_server`</strong><br>
|
||||||
|
The protected counter is only reached when login created a valid session.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel status">
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Client State</h2>
|
||||||
|
<div class="status-line" id="stateUser">Enrolled user: unknown</div>
|
||||||
|
<div class="status-line" id="stateSession">Session: unknown</div>
|
||||||
|
<div class="status-line" id="stateExpires">Expires: unknown</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Registered Users</h2>
|
||||||
|
<div class="status-line" id="usersSummary">Loading users...</div>
|
||||||
|
<div id="usersList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Flow Result</h2>
|
||||||
|
<div class="status-line" id="flowResult">No flow run yet.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2 style="margin-top:0">Event Log</h2>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const logNode = document.getElementById("log");
|
||||||
|
const hintBox = document.getElementById("hintBox");
|
||||||
|
const flowResult = document.getElementById("flowResult");
|
||||||
|
const stateUser = document.getElementById("stateUser");
|
||||||
|
const stateSession = document.getElementById("stateSession");
|
||||||
|
const stateExpires = document.getElementById("stateExpires");
|
||||||
|
const usersSummary = document.getElementById("usersSummary");
|
||||||
|
const usersList = document.getElementById("usersList");
|
||||||
|
const usernameInput = document.getElementById("username");
|
||||||
|
const buttons = Array.from(document.querySelectorAll("button"));
|
||||||
|
|
||||||
|
function log(message, payload) {
|
||||||
|
const stamp = new Date().toLocaleTimeString();
|
||||||
|
let line = `[${stamp}] ${message}`;
|
||||||
|
if (payload !== undefined) {
|
||||||
|
line += "\\n" + JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
for (const button of buttons) button.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function username() {
|
||||||
|
return usernameInput.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, payload) {
|
||||||
|
const resp = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
return {status: resp.status, data};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshState() {
|
||||||
|
const resp = await fetch("/api/client/state");
|
||||||
|
const data = await resp.json();
|
||||||
|
stateUser.textContent = `Enrolled user: ${data.enrolled_username || "none"}`;
|
||||||
|
stateSession.textContent = `Session active: ${data.session_active ? "yes" : "no"}`;
|
||||||
|
stateExpires.textContent = `Expires: ${data.session_expires_at || "none"}`;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(users) {
|
||||||
|
usersList.innerHTML = "";
|
||||||
|
if (!users.length) {
|
||||||
|
usersSummary.textContent = "No registered users in k_proxy.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usersSummary.textContent = `${users.length} registered user${users.length === 1 ? "" : "s"} visible in k_proxy.`;
|
||||||
|
for (const user of users) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "user-row";
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "user-meta";
|
||||||
|
meta.innerHTML =
|
||||||
|
`<div class="user-name">${user.username}</div>` +
|
||||||
|
`<div class="user-subtle">Credential present: ${user.has_credential ? "yes" : "no"}</div>`;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "user-actions";
|
||||||
|
|
||||||
|
const useBtn = document.createElement("button");
|
||||||
|
useBtn.className = "ghost small";
|
||||||
|
useBtn.textContent = "Use";
|
||||||
|
useBtn.addEventListener("click", () => {
|
||||||
|
usernameInput.value = user.username;
|
||||||
|
flowResult.textContent = `Selected user ${user.username}.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "secondary small";
|
||||||
|
deleteBtn.textContent = "Unregister";
|
||||||
|
deleteBtn.addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await deleteUser(user.username); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(useBtn);
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(actions);
|
||||||
|
usersList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUsers() {
|
||||||
|
const resp = await fetch("/api/enrollments");
|
||||||
|
const data = await resp.json();
|
||||||
|
renderUsers(data.users || []);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUser() {
|
||||||
|
hintBox.innerHTML = "Card step: if the card shows a <strong>registration</strong> prompt, press <strong>yes</strong> to enroll this user.";
|
||||||
|
const result = await api("/api/enroll", {username: username()});
|
||||||
|
log("Register user", result);
|
||||||
|
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
|
||||||
|
await refreshState();
|
||||||
|
await refreshUsers();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginUser() {
|
||||||
|
hintBox.innerHTML = "Card step: if the card shows an <strong>authentication</strong> prompt, press <strong>yes</strong> to allow login or <strong>no</strong> to deny it.";
|
||||||
|
const result = await api("/api/login", {username: username()});
|
||||||
|
log("Login", result);
|
||||||
|
await refreshState();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callCounter() {
|
||||||
|
const result = await api("/api/resource/counter", {});
|
||||||
|
log("Call k_server counter", result);
|
||||||
|
flowResult.textContent =
|
||||||
|
result.status === 200
|
||||||
|
? `k_server was reached. Counter value: ${result.data.upstream?.value}`
|
||||||
|
: "k_server was not reached successfully.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutUser() {
|
||||||
|
const result = await api("/api/logout", {});
|
||||||
|
log("Logout", result);
|
||||||
|
flowResult.textContent = result.status === 200 ? "Session cleared." : "Logout failed.";
|
||||||
|
await refreshState();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(usernameToDelete) {
|
||||||
|
const result = await api("/api/enroll/delete", {username: usernameToDelete});
|
||||||
|
log("Unregister user", result);
|
||||||
|
flowResult.textContent =
|
||||||
|
result.status === 200
|
||||||
|
? `User ${usernameToDelete} was unregistered.`
|
||||||
|
: `Could not unregister ${usernameToDelete}.`;
|
||||||
|
if (result.status === 200 && username() === usernameToDelete) {
|
||||||
|
usernameInput.value = "";
|
||||||
|
}
|
||||||
|
await refreshState();
|
||||||
|
await refreshUsers();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFlow() {
|
||||||
|
setBusy(true);
|
||||||
|
flowResult.textContent = "Flow running...";
|
||||||
|
try {
|
||||||
|
const login = await loginUser();
|
||||||
|
if (login.status !== 200) {
|
||||||
|
flowResult.textContent = "Login denied or failed. `k_server` was not called.";
|
||||||
|
log("Flow stopped before k_server", {
|
||||||
|
reason: "login failed",
|
||||||
|
status: login.status,
|
||||||
|
response: login.data
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const counter = await callCounter();
|
||||||
|
if (counter.status === 200) {
|
||||||
|
flowResult.textContent = `Flow succeeded. k_server returned counter ${counter.data.upstream?.value}.`;
|
||||||
|
} else {
|
||||||
|
flowResult.textContent = "Login succeeded, but the protected k_server call failed.";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("registerBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await registerUser(); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("loginBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await loginUser();
|
||||||
|
flowResult.textContent = result.status === 200 ? "Login succeeded. You can now call k_server." : "Login denied or failed. k_server was not called.";
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("counterBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await callCounter(); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await logoutUser(); } finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
document.getElementById("runFlowBtn").addEventListener("click", runFlow);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const state = await refreshState();
|
||||||
|
const users = await refreshUsers();
|
||||||
|
log("State refreshed", {state, users});
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => {
|
||||||
|
log("Client flow page ready", {state, users});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnrollmentRecord:
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class ClientState:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
proxy_base_url: str,
|
||||||
|
proxy_ca_file: str | None,
|
||||||
|
enroll_db: Path,
|
||||||
|
interactive_timeout_s: float = 90.0,
|
||||||
|
default_timeout_s: float = 10.0,
|
||||||
|
):
|
||||||
|
self.proxy_base_url = proxy_base_url.rstrip("/")
|
||||||
|
self.proxy_ca_file = proxy_ca_file
|
||||||
|
self.enroll_db = enroll_db
|
||||||
|
# Registration and login both require a physical card touch, which can
|
||||||
|
# take up to ~60 s in practice; 90 s gives a generous margin.
|
||||||
|
self.interactive_timeout_s = interactive_timeout_s
|
||||||
|
self.default_timeout_s = default_timeout_s
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.preferred_enrollment: EnrollmentRecord | None = None
|
||||||
|
self.session_token: str | None = None
|
||||||
|
self.session_expires_at: int | None = None
|
||||||
|
# Build the TLS context once; creating it on every request is expensive
|
||||||
|
# and the CA file doesn't change at runtime.
|
||||||
|
self._ssl_ctx: ssl.SSLContext | None = (
|
||||||
|
ssl.create_default_context(cafile=self.proxy_ca_file)
|
||||||
|
if proxy_base_url.startswith("https://")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._load_preferred_enrollment()
|
||||||
|
|
||||||
|
def _ssl_context(self) -> ssl.SSLContext | None:
|
||||||
|
return self._ssl_ctx
|
||||||
|
|
||||||
|
def _proxy_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
timeout_s: float | None = None,
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
|
req = Request(f"{self.proxy_base_url}{path}", method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
token = self.get_session_token()
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
body = json.dumps(payload or {}).encode("utf-8")
|
||||||
|
try:
|
||||||
|
with urlopen(
|
||||||
|
req,
|
||||||
|
data=body,
|
||||||
|
timeout=timeout_s or self.default_timeout_s,
|
||||||
|
context=self._ssl_context(),
|
||||||
|
) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
try:
|
||||||
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return exc.code, {"ok": False, "error": f"proxy http error {exc.code}"}
|
||||||
|
except URLError as exc:
|
||||||
|
return 502, {"ok": False, "error": f"proxy unavailable: {exc.reason}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return 502, {"ok": False, "error": f"proxy call failed: {exc}"}
|
||||||
|
|
||||||
|
def _load_preferred_enrollment(self) -> None:
|
||||||
|
if not self.enroll_db.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.enroll_db.read_text())
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
if username:
|
||||||
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
||||||
|
except Exception:
|
||||||
|
self.preferred_enrollment = None
|
||||||
|
|
||||||
|
def _save_preferred_enrollment_locked(self) -> None:
|
||||||
|
self.enroll_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"username": self.preferred_enrollment.username if self.preferred_enrollment else None}
|
||||||
|
self.enroll_db.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
|
||||||
|
def enroll(self, username: str) -> dict[str, Any]:
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
return {"ok": False, "error": "username required"}
|
||||||
|
# Best-effort: invalidate any active session on k_proxy before re-enrolling.
|
||||||
|
# The new credential will differ from what the old session was issued for.
|
||||||
|
with self.lock:
|
||||||
|
old_token = self.session_token
|
||||||
|
if old_token:
|
||||||
|
self._proxy_json("POST", "/session/logout")
|
||||||
|
status, data = self._proxy_json(
|
||||||
|
"POST",
|
||||||
|
"/enroll/register",
|
||||||
|
{"username": username},
|
||||||
|
timeout_s=self.interactive_timeout_s,
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
return data
|
||||||
|
with self.lock:
|
||||||
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
||||||
|
self._save_preferred_enrollment_locked()
|
||||||
|
self.session_token = None
|
||||||
|
self.session_expires_at = None
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"enrolled_username": username,
|
||||||
|
"proxy_enrollment": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_enrollments(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._proxy_json("GET", "/enroll/list")
|
||||||
|
|
||||||
|
def delete_enrollment(self, username: str) -> tuple[int, dict[str, Any]]:
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
return 400, {"ok": False, "error": "username required"}
|
||||||
|
status, data = self._proxy_json("POST", "/enroll/delete", {"username": username})
|
||||||
|
if status == 200:
|
||||||
|
with self.lock:
|
||||||
|
if self.preferred_enrollment and self.preferred_enrollment.username == username:
|
||||||
|
self.preferred_enrollment = None
|
||||||
|
self._save_preferred_enrollment_locked()
|
||||||
|
self.session_token = None
|
||||||
|
self.session_expires_at = None
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
with self.lock:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"enrolled_username": self.preferred_enrollment.username if self.preferred_enrollment else None,
|
||||||
|
"session_active": bool(self.session_token),
|
||||||
|
"session_expires_at": self.session_expires_at,
|
||||||
|
"proxy_base_url": self.proxy_base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_session_token(self) -> str | None:
|
||||||
|
with self.lock:
|
||||||
|
return self.session_token
|
||||||
|
|
||||||
|
def login(self, username: str | None = None) -> tuple[int, dict[str, Any]]:
|
||||||
|
requested = (username or "").strip()
|
||||||
|
with self.lock:
|
||||||
|
if requested:
|
||||||
|
username = requested
|
||||||
|
elif self.preferred_enrollment:
|
||||||
|
username = self.preferred_enrollment.username
|
||||||
|
else:
|
||||||
|
return 400, {"ok": False, "error": "no enrolled user"}
|
||||||
|
|
||||||
|
status, data = self._proxy_json(
|
||||||
|
"POST",
|
||||||
|
"/session/login",
|
||||||
|
{"username": username},
|
||||||
|
timeout_s=self.interactive_timeout_s,
|
||||||
|
)
|
||||||
|
if status == 200 and data.get("session_token"):
|
||||||
|
with self.lock:
|
||||||
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
||||||
|
self._save_preferred_enrollment_locked()
|
||||||
|
self.session_token = data["session_token"]
|
||||||
|
self.session_expires_at = int(data.get("expires_at", 0)) or None
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
def status(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._proxy_json("POST", "/session/status")
|
||||||
|
|
||||||
|
def counter(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._proxy_json("POST", "/resource/counter")
|
||||||
|
|
||||||
|
def logout(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
status, data = self._proxy_json("POST", "/session/logout")
|
||||||
|
if status == 200:
|
||||||
|
with self.lock:
|
||||||
|
self.session_token = None
|
||||||
|
self.session_expires_at = None
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
state: ClientState
|
||||||
|
|
||||||
|
def _json(self, status: int, payload: dict[str, Any]) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _html(self, body: str) -> None:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _read_json(self) -> dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def _require_json(self) -> dict[str, Any] | None:
|
||||||
|
# Returns None and sends 400 when the body is unparseable; the caller
|
||||||
|
# should return immediately without sending a second response.
|
||||||
|
try:
|
||||||
|
return self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
|
return None
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/":
|
||||||
|
self._html(HTML)
|
||||||
|
return
|
||||||
|
if path == "/health":
|
||||||
|
self._json(200, {"ok": True, "service": "k_client_portal", "time": int(time.time())})
|
||||||
|
return
|
||||||
|
if path == "/api/client/state":
|
||||||
|
self._json(200, self.state.snapshot())
|
||||||
|
return
|
||||||
|
if path == "/api/enrollments":
|
||||||
|
status, data = self.state.list_enrollments()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/enroll":
|
||||||
|
data = self._require_json()
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
result = self.state.enroll(str(data.get("username", "")))
|
||||||
|
self._json(200 if result.get("ok") else 400, result)
|
||||||
|
return
|
||||||
|
if path == "/api/login":
|
||||||
|
data = self._require_json()
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
status, data = self.state.login(str(data.get("username", "")))
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/enroll/delete":
|
||||||
|
data = self._require_json()
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
status, data = self.state.delete_enrollment(str(data.get("username", "")))
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/status":
|
||||||
|
status, data = self.state.status()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/resource/counter":
|
||||||
|
status, data = self.state.counter()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/logout":
|
||||||
|
status, data = self.state.logout()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run browser-facing client portal in k_client")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8766)
|
||||||
|
parser.add_argument("--proxy-base-url", default="https://127.0.0.1:9771")
|
||||||
|
parser.add_argument("--proxy-ca-file", help="CA certificate used to verify k_proxy HTTPS certificate")
|
||||||
|
parser.add_argument("--enroll-db", default="/home/user/chromecard/k_client_enrollment.json")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
if args.proxy_base_url.startswith("https://") and not args.proxy_ca_file:
|
||||||
|
raise SystemExit("--proxy-ca-file is required when --proxy-base-url uses https")
|
||||||
|
|
||||||
|
Handler.state = ClientState(
|
||||||
|
proxy_base_url=args.proxy_base_url,
|
||||||
|
proxy_ca_file=args.proxy_ca_file,
|
||||||
|
enroll_db=Path(args.enroll_db),
|
||||||
|
)
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
print(f"k_client_portal listening on http://{args.host}:{args.port}")
|
||||||
|
server.serve_forever()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
// 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.
|
||||||
|
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; keepalive loop as before.
|
||||||
|
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 = _emulatorMode ? await _receivePacket() : await _receivePacket();
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
// Enrollment storage — mirrors k_proxy_app.py ProxyState enrollment logic.
|
||||||
|
// Persists to a JSON file with the same schema so snapshots are portable.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Username validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
final _usernamePattern = RegExp(r'^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$');
|
||||||
|
|
||||||
|
String normalizeUsername(String raw) {
|
||||||
|
final s = raw.trim().toLowerCase();
|
||||||
|
if (!_usernamePattern.hasMatch(s)) {
|
||||||
|
throw ArgumentError(
|
||||||
|
'username must be 3–32 chars of lowercase letters, digits, dot, underscore, or dash');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? normalizeDisplayName(String? raw) {
|
||||||
|
final s = (raw ?? '').trim();
|
||||||
|
if (s.isEmpty) return null;
|
||||||
|
if (s.length > 64) throw ArgumentError('display_name must be 64 characters or fewer');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Enrollment {
|
||||||
|
final String username;
|
||||||
|
final String? displayName;
|
||||||
|
final int createdAt;
|
||||||
|
final int updatedAt;
|
||||||
|
final String? userIdB64;
|
||||||
|
final String? credentialDataB64;
|
||||||
|
|
||||||
|
const Enrollment({
|
||||||
|
required this.username,
|
||||||
|
this.displayName,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.userIdB64,
|
||||||
|
this.credentialDataB64,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasCredential => credentialDataB64 != null;
|
||||||
|
|
||||||
|
Enrollment copyWith({
|
||||||
|
String? displayName,
|
||||||
|
int? updatedAt,
|
||||||
|
String? userIdB64,
|
||||||
|
String? credentialDataB64,
|
||||||
|
}) =>
|
||||||
|
Enrollment(
|
||||||
|
username: username,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
userIdB64: userIdB64 ?? this.userIdB64,
|
||||||
|
credentialDataB64: credentialDataB64 ?? this.credentialDataB64,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'username': username,
|
||||||
|
'display_name': displayName,
|
||||||
|
'created_at': createdAt,
|
||||||
|
'updated_at': updatedAt,
|
||||||
|
'user_id_b64': userIdB64,
|
||||||
|
'credential_data_b64': credentialDataB64,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory Enrollment.fromJson(Map<String, dynamic> m) {
|
||||||
|
final username = (m['username'] as String? ?? '').trim();
|
||||||
|
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 {
|
||||||
|
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 with a simple future chain.
|
||||||
|
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 (_) {}
|
||||||
|
}
|
||||||
|
await op();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Persistence
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<File> _dbFile() async {
|
||||||
|
final dir = 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;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
// 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({
|
||||||
|
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),
|
||||||
|
CborString('uv'): CborBool(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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');
|
||||||
|
var offset = 2; // skip 0x30 + 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) {
|
||||||
|
final padded = s + '=' * ((4 - s.length % 4) % 4);
|
||||||
|
return Uint8List.fromList(base64Url.decode(padded));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
// 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) {
|
||||||
|
const skip = {'host', 'connection', 'transfer-encoding', 'authorization'};
|
||||||
|
return !skip.contains(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() => _client?.close();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
|
import 'proxy_service.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 = [];
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'Proxy service',
|
||||||
|
ok: _serviceRunning,
|
||||||
|
value: _serviceRunning ? 'Running on :8771' : '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),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _toggleService,
|
||||||
|
child: Text(_serviceRunning ? 'Stop proxy' : 'Start proxy'),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,652 @@
|
||||||
|
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 'fido2_ops.dart';
|
||||||
|
import 'k_server_client.dart';
|
||||||
|
import 'session_manager.dart';
|
||||||
|
|
||||||
|
const int kProxyPort = 8771;
|
||||||
|
const String kNotificationChannelId = 'kphone_proxy';
|
||||||
|
const String kNotificationChannelName = 'k_phone proxy service';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level entry points — required by flutter_background_service isolate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<bool> onIosBackground(ServiceInstance service) async => true;
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
void onServiceStart(ServiceInstance service) async {
|
||||||
|
final proxy = _ProxyServer(service);
|
||||||
|
service.on('stop').listen((_) async {
|
||||||
|
await proxy.stop();
|
||||||
|
service.stopSelf();
|
||||||
|
});
|
||||||
|
await proxy.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service bootstrap (called from main())
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
class ProxyService {
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||||
|
const channel = AndroidNotificationChannel(
|
||||||
|
kNotificationChannelId,
|
||||||
|
kNotificationChannelName,
|
||||||
|
description: 'Shows when the ChromeCard proxy is running',
|
||||||
|
importance: Importance.low,
|
||||||
|
);
|
||||||
|
await flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.createNotificationChannel(channel);
|
||||||
|
|
||||||
|
final service = FlutterBackgroundService();
|
||||||
|
await service.configure(
|
||||||
|
androidConfiguration: AndroidConfiguration(
|
||||||
|
onStart: onServiceStart,
|
||||||
|
autoStart: true,
|
||||||
|
isForegroundMode: true,
|
||||||
|
notificationChannelId: kNotificationChannelId,
|
||||||
|
initialNotificationTitle: 'k_phone proxy',
|
||||||
|
initialNotificationContent: 'Starting…',
|
||||||
|
foregroundServiceNotificationId: 1,
|
||||||
|
),
|
||||||
|
iosConfiguration: IosConfiguration(
|
||||||
|
autoStart: false,
|
||||||
|
onForeground: onServiceStart,
|
||||||
|
onBackground: onIosBackground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Proxy server (runs inside the background service isolate)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _ProxyServer {
|
||||||
|
final ServiceInstance _service;
|
||||||
|
HttpServer? _server;
|
||||||
|
final 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');
|
||||||
|
|
||||||
|
await _tryOpenCard();
|
||||||
|
await _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;
|
||||||
|
await _server?.close(force: true);
|
||||||
|
await closeCard();
|
||||||
|
_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 '/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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_cardAttached && _cardCid != null) {
|
||||||
|
// FIDO2-direct mode: run makeCredential on the card
|
||||||
|
MakeCredentialResult result;
|
||||||
|
try {
|
||||||
|
result = 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: result.userIdB64,
|
||||||
|
credentialDataB64: result.credentialDataB64,
|
||||||
|
);
|
||||||
|
await _send(req.response, 200, _enrollmentPayload(enrollment, created: true));
|
||||||
|
} on StateError {
|
||||||
|
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Probe mode: metadata-only enrollment
|
||||||
|
try {
|
||||||
|
final enrollment = await _db.register(username: canonical, displayName: pretty);
|
||||||
|
await _send(req.response, 200, _enrollmentPayload(enrollment, created: true));
|
||||||
|
} 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);
|
||||||
|
await _send(req.response, 200, {'ok': true, 'username': enrollment.username, 'deleted': true});
|
||||||
|
} 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(_enrollmentPayload).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, card is attached — accept
|
||||||
|
|
||||||
|
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': 300,
|
||||||
|
'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});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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, // SessionManager doesn't expose count; good enough
|
||||||
|
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _serveHtml(HttpRequest req) async {
|
||||||
|
final data = utf8.encode(_kPortalHtml);
|
||||||
|
req.response.statusCode = 200;
|
||||||
|
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
||||||
|
req.response.headers.contentLength = data.length;
|
||||||
|
req.response.add(data);
|
||||||
|
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 bytes = await req.fold<List<int>>([], (acc, chunk) => acc..addAll(chunk));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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>''';
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
// 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].
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,581 @@
|
||||||
|
# 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_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"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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
|
||||||
|
flutter_lints: ^3.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,128 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
k_server — protected resource backend.
|
||||||
|
|
||||||
|
Exposes a monotonic counter behind a shared proxy token. Only k_proxy
|
||||||
|
is expected to reach this service; k_client should have no direct path.
|
||||||
|
All state is process-local and resets on restart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
class ServerState:
|
||||||
|
# All state is process-local; a restart resets the counter to zero.
|
||||||
|
def __init__(self, proxy_token: str):
|
||||||
|
self.proxy_token = proxy_token
|
||||||
|
self.counter = 0
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def next_counter(self) -> int:
|
||||||
|
with self.lock:
|
||||||
|
self.counter += 1
|
||||||
|
return self.counter
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
state: ServerState
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def _json(self, status: int, payload: dict[str, Any]) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _discard_request_body(self) -> None:
|
||||||
|
# HTTP/1.1 keep-alive: the connection is reused, so the body must be fully
|
||||||
|
# consumed before we send the response, even for endpoints that ignore it.
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length > 0:
|
||||||
|
self.rfile.read(length)
|
||||||
|
|
||||||
|
def _is_proxy_authorized(self) -> bool:
|
||||||
|
return self.headers.get("X-Proxy-Token") == self.state.proxy_token
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/health":
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"service": "k_server",
|
||||||
|
"time": int(time.time()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path != "/resource/counter":
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
self._discard_request_body()
|
||||||
|
if not self._is_proxy_authorized():
|
||||||
|
self._json(401, {"ok": False, "error": "unauthorized proxy"})
|
||||||
|
return
|
||||||
|
|
||||||
|
value = self.state.next_counter()
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"resource": "counter",
|
||||||
|
"value": value,
|
||||||
|
"time": int(time.time()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run k_server counter service")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8780)
|
||||||
|
parser.add_argument("--tls-certfile", help="PEM certificate chain for HTTPS listener")
|
||||||
|
parser.add_argument("--tls-keyfile", help="PEM private key for HTTPS listener")
|
||||||
|
parser.add_argument(
|
||||||
|
"--proxy-token",
|
||||||
|
default="dev-proxy-token",
|
||||||
|
help="Shared token expected in X-Proxy-Token from k_proxy",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
if bool(args.tls_certfile) != bool(args.tls_keyfile):
|
||||||
|
raise SystemExit("Both --tls-certfile and --tls-keyfile are required to enable HTTPS")
|
||||||
|
|
||||||
|
state = ServerState(proxy_token=args.proxy_token)
|
||||||
|
Handler.state = state
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
scheme = "http"
|
||||||
|
if args.tls_certfile:
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.load_cert_chain(certfile=args.tls_certfile, keyfile=args.tls_keyfile)
|
||||||
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||||
|
scheme = "https"
|
||||||
|
|
||||||
|
print(f"k_server listening on {scheme}://{args.host}:{args.port}")
|
||||||
|
server.serve_forever()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
{
|
||||||
|
"name": "chromecard-browser-regression",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "chromecard-browser-regression",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "chromecard-browser-regression",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Playwright regression checks for the k_client browser flow",
|
||||||
|
"scripts": {
|
||||||
|
"test:k-client": "playwright test tests/k_client_portal.spec.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CLIENT_HOST="${CLIENT_HOST:-k_client}"
|
||||||
|
CA_FILE="${CA_FILE:-/home/user/chromecard/tls/phase2/ca.crt}"
|
||||||
|
PROXY_URL="${PROXY_URL:-https://127.0.0.1:9771}"
|
||||||
|
USERNAME="${USERNAME:-alice}"
|
||||||
|
REQUESTS="${REQUESTS:-20}"
|
||||||
|
PARALLELISM="${PARALLELISM:-8}"
|
||||||
|
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}"
|
||||||
|
LOGIN_TIMEOUT="${LOGIN_TIMEOUT:-90}"
|
||||||
|
INTERACTIVE_CARD="${INTERACTIVE_CARD:-0}"
|
||||||
|
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE:-}"
|
||||||
|
SSH_CONFIG="${SSH_CONFIG:-/home/user/.ssh/config}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: phase5_chain_regression.sh [options]
|
||||||
|
|
||||||
|
Runs the Phase 5 split-VM regression from the host by executing the client-side
|
||||||
|
flow inside k_client over SSH.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--client-host HOST SSH host alias for k_client (default: k_client)
|
||||||
|
--ca-file PATH CA bundle path inside k_client
|
||||||
|
--proxy-url URL Proxy URL visible from k_client
|
||||||
|
--username NAME Username for session login
|
||||||
|
--requests N Number of counter requests to issue
|
||||||
|
--parallelism N Number of concurrent workers
|
||||||
|
--connect-timeout SEC SSH connect timeout
|
||||||
|
--login-timeout SEC Timeout for the interactive login request (default: 90)
|
||||||
|
--interactive-card Print card-confirmation instructions before login
|
||||||
|
--expect-auth-mode NAME Require login response auth_mode to match
|
||||||
|
--ssh-config PATH SSH config file to use (default: /home/user/.ssh/config)
|
||||||
|
-h, --help Show this help text
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--client-host)
|
||||||
|
CLIENT_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ca-file)
|
||||||
|
CA_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--proxy-url)
|
||||||
|
PROXY_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--username)
|
||||||
|
USERNAME="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--requests)
|
||||||
|
REQUESTS="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--parallelism)
|
||||||
|
PARALLELISM="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--connect-timeout)
|
||||||
|
CONNECT_TIMEOUT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--login-timeout)
|
||||||
|
LOGIN_TIMEOUT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--interactive-card)
|
||||||
|
INTERACTIVE_CARD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--expect-auth-mode)
|
||||||
|
EXPECT_AUTH_MODE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ssh-config)
|
||||||
|
SSH_CONFIG="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${INTERACTIVE_CARD}" == "1" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
Starting interactive login for ${USERNAME}.
|
||||||
|
When the card shows the authentication prompt, press yes to approve.
|
||||||
|
Press no only if you want to reject the login.
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh \
|
||||||
|
-F "${SSH_CONFIG}" \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-o StrictHostKeyChecking=accept-new \
|
||||||
|
-o ConnectTimeout="${CONNECT_TIMEOUT}" \
|
||||||
|
"${CLIENT_HOST}" \
|
||||||
|
env \
|
||||||
|
CA_FILE="${CA_FILE}" \
|
||||||
|
PROXY_URL="${PROXY_URL}" \
|
||||||
|
USERNAME="${USERNAME}" \
|
||||||
|
REQUESTS="${REQUESTS}" \
|
||||||
|
PARALLELISM="${PARALLELISM}" \
|
||||||
|
LOGIN_TIMEOUT="${LOGIN_TIMEOUT}" \
|
||||||
|
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE}" \
|
||||||
|
python3 - <<'PY'
|
||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ca_file = os.environ["CA_FILE"]
|
||||||
|
proxy_url = os.environ["PROXY_URL"].rstrip("/")
|
||||||
|
username = os.environ["USERNAME"]
|
||||||
|
requests = int(os.environ["REQUESTS"])
|
||||||
|
parallelism = int(os.environ["PARALLELISM"])
|
||||||
|
login_timeout = int(os.environ["LOGIN_TIMEOUT"])
|
||||||
|
expect_auth_mode = os.environ["EXPECT_AUTH_MODE"]
|
||||||
|
|
||||||
|
if requests < 1:
|
||||||
|
raise SystemExit("REQUESTS must be >= 1")
|
||||||
|
if parallelism < 1:
|
||||||
|
raise SystemExit("PARALLELISM must be >= 1")
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(cafile=ca_file)
|
||||||
|
|
||||||
|
def post_json(path: str, payload: dict | None = None, token: str | None = None, timeout: int = 10):
|
||||||
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
headers = {}
|
||||||
|
if payload is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{proxy_url}{path}",
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {"ok": False, "error": body}
|
||||||
|
return exc.code, payload
|
||||||
|
|
||||||
|
status, login = post_json("/session/login", {"username": username}, timeout=login_timeout)
|
||||||
|
if status != 200 or "session_token" not in login:
|
||||||
|
print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login}))
|
||||||
|
raise SystemExit(1)
|
||||||
|
if expect_auth_mode and login.get("auth_mode") != expect_auth_mode:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"stage": "login",
|
||||||
|
"error": "unexpected auth_mode",
|
||||||
|
"expected": expect_auth_mode,
|
||||||
|
"response": login,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
token = login["session_token"]
|
||||||
|
values = []
|
||||||
|
|
||||||
|
def fetch_one(_: int) -> int:
|
||||||
|
status, payload = post_json("/resource/counter", {}, token=token)
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(json.dumps({"status": status, "response": payload}))
|
||||||
|
return int(payload["upstream"]["value"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as pool:
|
||||||
|
for value in pool.map(fetch_one, range(requests)):
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
status_resp, session = post_json("/session/status", {}, token=token)
|
||||||
|
logout_status, logout = post_json("/session/logout", {}, token=token)
|
||||||
|
invalid_status, invalid = post_json("/resource/counter", {}, token=token)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
post_json("/session/logout", {}, token=token)
|
||||||
|
finally:
|
||||||
|
raise SystemExit(str(exc))
|
||||||
|
|
||||||
|
sorted_values = sorted(values)
|
||||||
|
expected = list(range(sorted_values[0], sorted_values[-1] + 1)) if sorted_values else []
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"ok": True,
|
||||||
|
"username": username,
|
||||||
|
"proxy_url": proxy_url,
|
||||||
|
"requests": requests,
|
||||||
|
"parallelism": parallelism,
|
||||||
|
"unique": len(set(values)) == len(values),
|
||||||
|
"gap_free": sorted_values == expected,
|
||||||
|
"min": min(sorted_values) if sorted_values else None,
|
||||||
|
"max": max(sorted_values) if sorted_values else None,
|
||||||
|
"values": sorted_values,
|
||||||
|
"login": login,
|
||||||
|
"session_status": {"status": status_resp, "response": session},
|
||||||
|
"logout": {"status": logout_status, "response": logout},
|
||||||
|
"post_logout": {"status": invalid_status, "response": invalid},
|
||||||
|
}
|
||||||
|
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||||
|
if not summary["unique"] or not summary["gap_free"] or logout_status != 200 or invalid_status != 401:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Phase 6.5 concurrency probe for the direct browser-to-k_proxy path.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Creates a small batch of enrolled users.
|
||||||
|
- Logs each user in through k_proxy over TLS.
|
||||||
|
- Fires protected counter requests in parallel using the returned bearer tokens.
|
||||||
|
- Verifies that all calls succeed and that returned counter values are unique and contiguous.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
username: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
base_url: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
cafile: str | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
|
req = Request(f"{base_url.rstrip('/')}{path}", method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||||
|
context = ssl.create_default_context(cafile=cafile) if base_url.startswith("https://") else None
|
||||||
|
try:
|
||||||
|
with urlopen(req, data=data, timeout=timeout, context=context) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
try:
|
||||||
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return exc.code, {"ok": False, "error": f"http error {exc.code}"}
|
||||||
|
except URLError as exc:
|
||||||
|
return 502, {"ok": False, "error": f"url error: {exc.reason}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return 502, {"ok": False, "error": f"request failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_user(base_url: str, cafile: str, username: str, display_name: str) -> None:
|
||||||
|
status, data = request_json(
|
||||||
|
base_url,
|
||||||
|
"/enroll/register",
|
||||||
|
method="POST",
|
||||||
|
payload={"username": username, "display_name": display_name},
|
||||||
|
cafile=cafile,
|
||||||
|
)
|
||||||
|
if status == 200:
|
||||||
|
return
|
||||||
|
if status == 409 and data.get("error") == "user already enrolled":
|
||||||
|
return
|
||||||
|
raise RuntimeError(f"enroll failed for {username}: status={status} data={data}")
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(base_url: str, cafile: str, username: str) -> Session:
|
||||||
|
status, data = request_json(
|
||||||
|
base_url,
|
||||||
|
"/session/login",
|
||||||
|
method="POST",
|
||||||
|
payload={"username": username},
|
||||||
|
cafile=cafile,
|
||||||
|
)
|
||||||
|
if status != 200 or not data.get("session_token"):
|
||||||
|
raise RuntimeError(f"login failed for {username}: status={status} data={data}")
|
||||||
|
return Session(username=username, token=data["session_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def counter_call(base_url: str, cafile: str, session: Session, call_id: int) -> dict[str, Any]:
|
||||||
|
started = time.time()
|
||||||
|
status, data = request_json(
|
||||||
|
base_url,
|
||||||
|
"/resource/counter",
|
||||||
|
method="POST",
|
||||||
|
payload={},
|
||||||
|
token=session.token,
|
||||||
|
cafile=cafile,
|
||||||
|
)
|
||||||
|
finished = time.time()
|
||||||
|
return {
|
||||||
|
"call_id": call_id,
|
||||||
|
"username": session.username,
|
||||||
|
"status": status,
|
||||||
|
"data": data,
|
||||||
|
"latency_ms": int((finished - started) * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run Phase 6.5 concurrency probe against k_proxy")
|
||||||
|
parser.add_argument("--base-url", default="https://127.0.0.1:9771")
|
||||||
|
parser.add_argument("--ca-file", required=True)
|
||||||
|
parser.add_argument("--users", type=int, default=3)
|
||||||
|
parser.add_argument("--requests-per-user", type=int, default=4)
|
||||||
|
parser.add_argument("--username-prefix", default="phase65")
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-workers",
|
||||||
|
type=int,
|
||||||
|
help="Maximum number of in-flight protected calls; defaults to total requests",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
sessions: list[Session] = []
|
||||||
|
for idx in range(args.users):
|
||||||
|
username = f"{args.username_prefix}_{idx}"
|
||||||
|
enroll_user(args.base_url, args.ca_file, username, f"Phase65 User {idx}")
|
||||||
|
sessions.append(login_user(args.base_url, args.ca_file, username))
|
||||||
|
|
||||||
|
jobs: list[tuple[Session, int]] = []
|
||||||
|
call_id = 0
|
||||||
|
for session in sessions:
|
||||||
|
for _ in range(args.requests_per_user):
|
||||||
|
jobs.append((session, call_id))
|
||||||
|
call_id += 1
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
max_workers = args.max_workers or len(jobs)
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_map = {
|
||||||
|
executor.submit(counter_call, args.base_url, args.ca_file, session, job_id): (session.username, job_id)
|
||||||
|
for session, job_id in jobs
|
||||||
|
}
|
||||||
|
for future in as_completed(future_map):
|
||||||
|
username, job_id = future_map[future]
|
||||||
|
try:
|
||||||
|
results.append(future.result())
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"call_id": job_id,
|
||||||
|
"username": username,
|
||||||
|
"status": 599,
|
||||||
|
"data": {"ok": False, "error": str(exc)},
|
||||||
|
"latency_ms": -1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results.sort(key=lambda item: item["call_id"])
|
||||||
|
ok_results = [item for item in results if item["status"] == 200 and item["data"].get("ok")]
|
||||||
|
values = [item["data"]["upstream"]["value"] for item in ok_results]
|
||||||
|
values_sorted = sorted(values)
|
||||||
|
contiguous = bool(values_sorted) and values_sorted == list(range(values_sorted[0], values_sorted[0] + len(values_sorted)))
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"ok": len(ok_results) == len(results) and len(set(values)) == len(values) and contiguous,
|
||||||
|
"users": args.users,
|
||||||
|
"requests_per_user": args.requests_per_user,
|
||||||
|
"total_requests": len(results),
|
||||||
|
"max_workers": max_workers,
|
||||||
|
"successful_requests": len(ok_results),
|
||||||
|
"unique_counter_values": len(set(values)),
|
||||||
|
"counter_min": min(values_sorted) if values_sorted else None,
|
||||||
|
"counter_max": max(values_sorted) if values_sorted else None,
|
||||||
|
"counter_contiguous": contiguous,
|
||||||
|
"max_latency_ms": max((item["latency_ms"] for item in results), default=None),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
return 0 if summary["ok"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Minimal local Playwright config for the k_client browser flow.
|
||||||
|
const { defineConfig } = require("@playwright/test");
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
timeout: 180_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PORTAL_BASE_URL || "http://127.0.0.1:8766",
|
||||||
|
headless: process.env.PW_HEADLESS === "1",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
reporter: [["list"]],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Low-level CTAP2 probe for ChromeCard host debugging.
|
||||||
|
|
||||||
|
This bypasses the higher-level Fido2Client/WebAuthn helpers so we can inspect
|
||||||
|
raw makeCredential/getAssertion behavior, keepalive callbacks, and transport
|
||||||
|
errors on the host stack.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fido2.ctap import CtapError
|
||||||
|
from fido2.ctap2 import Ctap2
|
||||||
|
from fido2.hid import CtapHidDevice
|
||||||
|
from fido2.hid.linux import get_descriptor, open_connection
|
||||||
|
except Exception as exc:
|
||||||
|
print("Missing dependency: python-fido2", file=sys.stderr)
|
||||||
|
print("Install with: python3 -m pip install fido2", file=sys.stderr)
|
||||||
|
print(f"Import error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: Any) -> Any:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.hex()
|
||||||
|
if isinstance(value, set):
|
||||||
|
return sorted(value)
|
||||||
|
if hasattr(value, "items"):
|
||||||
|
return dict(value.items())
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(f"[{_now()}] {message}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices() -> list[CtapHidDevice]:
|
||||||
|
return list(CtapHidDevice.list_devices())
|
||||||
|
|
||||||
|
|
||||||
|
def describe_device(dev: CtapHidDevice) -> dict[str, Any]:
|
||||||
|
desc = getattr(dev, "descriptor", None)
|
||||||
|
return {
|
||||||
|
"product_name": getattr(desc, "product_name", None),
|
||||||
|
"manufacturer": getattr(desc, "manufacturer_string", None),
|
||||||
|
"vendor_id": getattr(desc, "vid", None),
|
||||||
|
"product_id": getattr(desc, "pid", None),
|
||||||
|
"path": getattr(desc, "path", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_ctap2(dev: CtapHidDevice) -> Ctap2:
|
||||||
|
return Ctap2(dev)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(index: int, device_path: str | None) -> CtapHidDevice:
|
||||||
|
if device_path:
|
||||||
|
descriptor = get_descriptor(device_path)
|
||||||
|
return CtapHidDevice(descriptor, open_connection(descriptor))
|
||||||
|
devs = list_devices()
|
||||||
|
if not devs:
|
||||||
|
raise SystemExit("No CTAP HID devices found.")
|
||||||
|
if index < 0 or index >= len(devs):
|
||||||
|
raise SystemExit(f"Invalid --index {index}; found {len(devs)} device(s).")
|
||||||
|
return devs[index]
|
||||||
|
|
||||||
|
|
||||||
|
def print_json(payload: dict[str, Any]) -> None:
|
||||||
|
print(json.dumps(payload, indent=2, default=_json_default))
|
||||||
|
|
||||||
|
|
||||||
|
def keepalive_logger(status: int) -> None:
|
||||||
|
log(f"keepalive status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_hex_bytes(value: str | None, label: str) -> bytes | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
raw = value.strip().lower()
|
||||||
|
if raw.startswith("0x"):
|
||||||
|
raw = raw[2:]
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"invalid hex for {label}: {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _client_data_hash(label: str) -> bytes:
|
||||||
|
return hashlib.sha256(label.encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _key_params() -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"type": "public-key", "alg": -7},
|
||||||
|
{"type": "public-key", "alg": -257},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def do_info(ctap2: Ctap2, device_meta: dict[str, Any]) -> int:
|
||||||
|
info = ctap2.get_info()
|
||||||
|
print_json({"device": device_meta, "ctap2_info": info})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def do_make_credential(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[str, Any]) -> int:
|
||||||
|
rp = {"id": args.rp_id, "name": args.rp_name or args.rp_id}
|
||||||
|
user_id = args.user_id.encode("utf-8")
|
||||||
|
user = {
|
||||||
|
"id": user_id,
|
||||||
|
"name": args.user_name,
|
||||||
|
"displayName": args.user_display_name or args.user_name,
|
||||||
|
}
|
||||||
|
client_data_hash = _client_data_hash(f"chromecard-make-credential:{args.rp_id}:{args.user_name}")
|
||||||
|
options = {"rk": args.resident_key, "uv": args.user_verification}
|
||||||
|
log(
|
||||||
|
"starting makeCredential "
|
||||||
|
f"rp_id={args.rp_id} user={args.user_name} rk={options['rk']} uv={options['uv']}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = ctap2.make_credential(
|
||||||
|
client_data_hash=client_data_hash,
|
||||||
|
rp=rp,
|
||||||
|
user=user,
|
||||||
|
key_params=_key_params(),
|
||||||
|
options=options,
|
||||||
|
on_keepalive=keepalive_logger,
|
||||||
|
)
|
||||||
|
except CtapError as exc:
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"operation": "makeCredential",
|
||||||
|
"device": device_meta,
|
||||||
|
"rp": rp,
|
||||||
|
"user": user,
|
||||||
|
"options": options,
|
||||||
|
"error_type": "CtapError",
|
||||||
|
"error_code": getattr(exc, "code", None),
|
||||||
|
"error_name": str(getattr(exc, "code", None)),
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
except Exception as exc:
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"operation": "makeCredential",
|
||||||
|
"device": device_meta,
|
||||||
|
"rp": rp,
|
||||||
|
"user": user,
|
||||||
|
"options": options,
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
auth_data = getattr(response, "auth_data", None)
|
||||||
|
credential_data = getattr(auth_data, "credential_data", None)
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"operation": "makeCredential",
|
||||||
|
"device": device_meta,
|
||||||
|
"rp": rp,
|
||||||
|
"user": user,
|
||||||
|
"options": options,
|
||||||
|
"fmt": getattr(response, "fmt", None),
|
||||||
|
"auth_data": auth_data,
|
||||||
|
"credential_id_hex": getattr(credential_data, "credential_id", b"").hex()
|
||||||
|
if credential_data is not None
|
||||||
|
else None,
|
||||||
|
"credential_data_hex": bytes(credential_data).hex() if credential_data is not None else None,
|
||||||
|
"att_stmt": getattr(response, "att_stmt", None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def do_get_assertion(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[str, Any]) -> int:
|
||||||
|
allow_credential = _coerce_hex_bytes(args.allow_credential_id, "allow-credential-id")
|
||||||
|
allow_list = [{"type": "public-key", "id": allow_credential}] if allow_credential else None
|
||||||
|
client_data_hash = _client_data_hash(f"chromecard-get-assertion:{args.rp_id}")
|
||||||
|
options = {"up": True, "uv": args.user_verification}
|
||||||
|
log(
|
||||||
|
"starting getAssertion "
|
||||||
|
f"rp_id={args.rp_id} allow_list={1 if allow_list else 0} uv={options['uv']}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = ctap2.get_assertion(
|
||||||
|
rp_id=args.rp_id,
|
||||||
|
client_data_hash=client_data_hash,
|
||||||
|
allow_list=allow_list,
|
||||||
|
options=options,
|
||||||
|
on_keepalive=keepalive_logger,
|
||||||
|
)
|
||||||
|
except CtapError as exc:
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"operation": "getAssertion",
|
||||||
|
"device": device_meta,
|
||||||
|
"rp_id": args.rp_id,
|
||||||
|
"allow_list": allow_list,
|
||||||
|
"options": options,
|
||||||
|
"error_type": "CtapError",
|
||||||
|
"error_code": getattr(exc, "code", None),
|
||||||
|
"error_name": str(getattr(exc, "code", None)),
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
except Exception as exc:
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"operation": "getAssertion",
|
||||||
|
"device": device_meta,
|
||||||
|
"rp_id": args.rp_id,
|
||||||
|
"allow_list": allow_list,
|
||||||
|
"options": options,
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assertions: list[dict[str, Any]] = []
|
||||||
|
for item in getattr(response, "assertions", []) or []:
|
||||||
|
assertions.append(
|
||||||
|
{
|
||||||
|
"credential": getattr(item, "credential", None),
|
||||||
|
"auth_data": getattr(item, "auth_data", None),
|
||||||
|
"signature": getattr(item, "signature", None),
|
||||||
|
"user": getattr(item, "user", None),
|
||||||
|
"number_of_credentials": getattr(item, "number_of_credentials", None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"operation": "getAssertion",
|
||||||
|
"device": device_meta,
|
||||||
|
"rp_id": args.rp_id,
|
||||||
|
"allow_list": allow_list,
|
||||||
|
"options": options,
|
||||||
|
"assertions": assertions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Low-level CTAP2 host probe")
|
||||||
|
parser.add_argument("--index", type=int, default=0, help="Device index from --list output")
|
||||||
|
parser.add_argument(
|
||||||
|
"--device-path",
|
||||||
|
help="Use a specific hidraw node such as /dev/hidraw0 instead of scanning all devices",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
subparsers.add_parser("list", help="List CTAP HID devices")
|
||||||
|
subparsers.add_parser("info", help="Fetch CTAP2 getInfo")
|
||||||
|
|
||||||
|
make_credential = subparsers.add_parser("make-credential", help="Run raw CTAP2 makeCredential")
|
||||||
|
make_credential.add_argument("--rp-id", default="localhost")
|
||||||
|
make_credential.add_argument("--rp-name", default="ChromeCard Local Probe")
|
||||||
|
make_credential.add_argument("--user-name", default="probe-user")
|
||||||
|
make_credential.add_argument("--user-display-name", default="Probe User")
|
||||||
|
make_credential.add_argument("--user-id", default=secrets.token_hex(16))
|
||||||
|
make_credential.add_argument("--resident-key", action="store_true")
|
||||||
|
make_credential.add_argument("--user-verification", action="store_true")
|
||||||
|
|
||||||
|
get_assertion = subparsers.add_parser("get-assertion", help="Run raw CTAP2 getAssertion")
|
||||||
|
get_assertion.add_argument("--rp-id", default="localhost")
|
||||||
|
get_assertion.add_argument("--allow-credential-id", help="Credential id as hex")
|
||||||
|
get_assertion.add_argument("--user-verification", action="store_true")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "list":
|
||||||
|
devs = list_devices()
|
||||||
|
print_json(
|
||||||
|
{
|
||||||
|
"devices": [describe_device(dev) for dev in devs],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0 if devs else 1
|
||||||
|
|
||||||
|
dev = get_device(args.index, args.device_path)
|
||||||
|
device_meta = describe_device(dev)
|
||||||
|
ctap2 = get_ctap2(dev)
|
||||||
|
|
||||||
|
if args.command == "info":
|
||||||
|
return do_info(ctap2, device_meta)
|
||||||
|
if args.command == "make-credential":
|
||||||
|
return do_make_credential(ctap2, args, device_meta)
|
||||||
|
if args.command == "get-assertion":
|
||||||
|
return do_get_assertion(ctap2, args, device_meta)
|
||||||
|
parser.error(f"unsupported command: {args.command}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
"""
|
||||||
|
CardEmulator — software emulator of the ChromeCard FIDO2 authenticator
|
||||||
|
======================================================================
|
||||||
|
|
||||||
|
What it is
|
||||||
|
----------
|
||||||
|
CardEmulator is a drop-in replacement for the physical ChromeCard in tests.
|
||||||
|
It implements the two Ctap2 methods that k_proxy_app calls in fido2-direct
|
||||||
|
mode — make_credential (registration) and get_assertion (authentication) —
|
||||||
|
using real P-256 cryptography and an in-memory credential store.
|
||||||
|
|
||||||
|
The auth_data layout and COSE key encoding mirror the firmware exactly
|
||||||
|
(see fido_make_cred.c and fido_get_assertion.c), so fido2.server's
|
||||||
|
register_complete and authenticate_complete accept the emulator's responses
|
||||||
|
without any extra patching.
|
||||||
|
|
||||||
|
|
||||||
|
Wiring the emulator into a ProxyState test
|
||||||
|
------------------------------------------
|
||||||
|
Two patches are needed: one to replace _with_direct_ctap2 so it hands the
|
||||||
|
emulator to the lambda instead of opening a real HID device, and one to
|
||||||
|
suppress _drop_direct_device which would otherwise try to close a real handle.
|
||||||
|
|
||||||
|
A convenience helper for this is provided in test_k_proxy.py:
|
||||||
|
|
||||||
|
def _patch_emulator(state, emulator):
|
||||||
|
return patch.multiple(
|
||||||
|
state,
|
||||||
|
_with_direct_ctap2=lambda fn: fn(emulator),
|
||||||
|
_drop_direct_device=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
Typical test setup:
|
||||||
|
|
||||||
|
from card_emulator import CardEmulator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
emulator = CardEmulator()
|
||||||
|
state = _make_state(tmp_path, auth_mode=AUTH_MODE_FIDO2_DIRECT)
|
||||||
|
|
||||||
|
with _patch_emulator(state, emulator):
|
||||||
|
enrollment = state.register_enrollment("alice", "Alice")
|
||||||
|
ok, msg = state.authenticate_with_card("alice") # True, "assertion verified"
|
||||||
|
|
||||||
|
|
||||||
|
User confirmation (Yes / No on the card)
|
||||||
|
-----------------------------------------
|
||||||
|
Both make_credential and get_assertion accept a `user_confirms` keyword
|
||||||
|
argument (default True). Setting it to False raises
|
||||||
|
CtapError(OPERATION_DENIED), exactly as the firmware does when the user taps
|
||||||
|
No on the card's confirmation dialog.
|
||||||
|
|
||||||
|
Direct calls — pass the flag explicitly:
|
||||||
|
|
||||||
|
attest = emulator.make_credential(
|
||||||
|
client_data_hash=..., rp=..., user=..., key_params=...,
|
||||||
|
user_confirms=False,
|
||||||
|
) # raises CtapError(OPERATION_DENIED)
|
||||||
|
|
||||||
|
Integration tests through _with_direct_ctap2 — the lambda that ProxyState
|
||||||
|
builds cannot inject user_confirms, so use refusing() instead. It returns
|
||||||
|
a thin wrapper whose methods forward to the emulator with user_confirms=False:
|
||||||
|
|
||||||
|
with _patch_emulator(state, emulator.refusing()):
|
||||||
|
ok, msg = state.authenticate_with_card("alice") # False
|
||||||
|
# msg contains "assertion verification failed: CTAP error: OPERATION_DENIED"
|
||||||
|
|
||||||
|
with _patch_emulator(state, emulator.refusing()):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
state.register_enrollment("bob", None)
|
||||||
|
# raises RuntimeError("card registration failed: ...")
|
||||||
|
|
||||||
|
refusing() shares the same credential store as the parent emulator, so
|
||||||
|
credentials registered before or after the call are visible to both.
|
||||||
|
|
||||||
|
|
||||||
|
Simulating card-side credential removal
|
||||||
|
----------------------------------------
|
||||||
|
forget_user(username) removes all credentials for that user from the
|
||||||
|
emulator's store and returns the count removed. Use it to simulate a
|
||||||
|
factory reset or a deliberate key deletion:
|
||||||
|
|
||||||
|
emulator.forget_user("alice")
|
||||||
|
ok, msg = state.authenticate_with_card("alice") # False
|
||||||
|
|
||||||
|
|
||||||
|
API summary
|
||||||
|
-----------
|
||||||
|
CardEmulator()
|
||||||
|
Create a new emulator with an empty credential store.
|
||||||
|
|
||||||
|
make_credential(client_data_hash, rp, user, key_params, *, user_confirms=True)
|
||||||
|
Simulate CTAP2 makeCredential. Returns AttestationResponse.
|
||||||
|
|
||||||
|
get_assertion(rp_id, client_data_hash, allow_list, *, user_confirms=True)
|
||||||
|
Simulate CTAP2 getAssertion. Returns AssertionResponse.
|
||||||
|
Raises CtapError(NO_CREDENTIALS) if no matching credential is found.
|
||||||
|
|
||||||
|
refusing() -> _RefusingView
|
||||||
|
Return a wrapper that forces user_confirms=False on every call.
|
||||||
|
|
||||||
|
forget_user(username) -> int
|
||||||
|
Remove all credentials for username. Returns count removed.
|
||||||
|
|
||||||
|
credential_count() -> int
|
||||||
|
Total credentials currently in the store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from fido2.ctap import CtapError
|
||||||
|
from fido2.ctap2 import AssertionResponse, AttestationResponse
|
||||||
|
from fido2.webauthn import AuthenticatorData
|
||||||
|
|
||||||
|
# AAGUID from fido_make_cred.c
|
||||||
|
_AAGUID = bytes([
|
||||||
|
0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF,
|
||||||
|
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _cose_es256(x: bytes, y: bytes) -> bytes:
|
||||||
|
"""CBOR-encoded COSE ES256 public key, byte-for-byte as the firmware builds it."""
|
||||||
|
return (
|
||||||
|
bytes([0xA5]) # map(5)
|
||||||
|
+ bytes([0x01, 0x02]) # kty: 2 (EC2)
|
||||||
|
+ bytes([0x03, 0x26]) # alg: -7 (ES256)
|
||||||
|
+ bytes([0x20, 0x01]) # crv (-1): 1 (P-256)
|
||||||
|
+ bytes([0x21, 0x58, 0x20]) + x # x (-2): bstr(32)
|
||||||
|
+ bytes([0x22, 0x58, 0x20]) + y # y (-3): bstr(32)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Credential:
|
||||||
|
private_key: ec.EllipticCurvePrivateKey
|
||||||
|
rp_id_hash: bytes
|
||||||
|
user_id: bytes
|
||||||
|
username: str
|
||||||
|
sign_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CardEmulator:
|
||||||
|
"""In-process emulator of a ChromeCard FIDO2 authenticator.
|
||||||
|
|
||||||
|
Implements make_credential and get_assertion with the same signatures as
|
||||||
|
fido2.ctap2.Ctap2, plus forget_user() for test teardown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._creds: dict[bytes, _Credential] = {}
|
||||||
|
|
||||||
|
# ── CTAP2 interface ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_credential(
|
||||||
|
self,
|
||||||
|
client_data_hash: bytes,
|
||||||
|
rp: Mapping[str, Any],
|
||||||
|
user: Mapping[str, Any],
|
||||||
|
key_params: list[Mapping[str, Any]],
|
||||||
|
exclude_list: list[Mapping[str, Any]] | None = None,
|
||||||
|
extensions: Mapping[str, Any] | None = None,
|
||||||
|
options: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
user_confirms: bool = True,
|
||||||
|
**_: Any,
|
||||||
|
) -> AttestationResponse:
|
||||||
|
"""Simulate makeCredential.
|
||||||
|
|
||||||
|
When user_confirms is False the call raises CtapError(OPERATION_DENIED),
|
||||||
|
mirroring the firmware's response when the user taps No on the card.
|
||||||
|
Otherwise a real P-256 keypair is generated, stored, and returned as a
|
||||||
|
fmt='none' AttestationResponse with a valid COSE ES256 public key.
|
||||||
|
"""
|
||||||
|
if not user_confirms:
|
||||||
|
raise CtapError(CtapError.ERR.OPERATION_DENIED)
|
||||||
|
|
||||||
|
rp_id: str = rp["id"]
|
||||||
|
rp_id_hash = hashlib.sha256(rp_id.encode()).digest()
|
||||||
|
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
pub_nums = priv.public_key().public_numbers()
|
||||||
|
x = pub_nums.x.to_bytes(32, "big")
|
||||||
|
y = pub_nums.y.to_bytes(32, "big")
|
||||||
|
|
||||||
|
credential_id = os.urandom(32)
|
||||||
|
|
||||||
|
raw_user_id: bytes = user.get("id", b"") # type: ignore[assignment]
|
||||||
|
if isinstance(raw_user_id, str):
|
||||||
|
raw_user_id = raw_user_id.encode()
|
||||||
|
|
||||||
|
cred = _Credential(
|
||||||
|
private_key=priv,
|
||||||
|
rp_id_hash=rp_id_hash,
|
||||||
|
user_id=raw_user_id,
|
||||||
|
username=user.get("name", ""), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
# authData layout matches fido_make_cred.c build_make_credential_response():
|
||||||
|
# rpIdHash(32) | flags(1) | signCount(4) | aaguid(16)
|
||||||
|
# | credIdLen(2) | credId(32) | coseKey(77)
|
||||||
|
auth_data_bytes = (
|
||||||
|
rp_id_hash
|
||||||
|
+ bytes([0x41]) # flags: UP=1, AT=1
|
||||||
|
+ struct.pack(">I", cred.sign_count)
|
||||||
|
+ _AAGUID
|
||||||
|
+ struct.pack(">H", len(credential_id))
|
||||||
|
+ credential_id
|
||||||
|
+ _cose_es256(x, y)
|
||||||
|
)
|
||||||
|
cred.sign_count += 1
|
||||||
|
self._creds[credential_id] = cred
|
||||||
|
|
||||||
|
return AttestationResponse(
|
||||||
|
fmt="none",
|
||||||
|
auth_data=AuthenticatorData(auth_data_bytes),
|
||||||
|
att_stmt={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_assertion(
|
||||||
|
self,
|
||||||
|
rp_id: str,
|
||||||
|
client_data_hash: bytes,
|
||||||
|
allow_list: list[Mapping[str, Any]] | None = None,
|
||||||
|
extensions: Mapping[str, Any] | None = None,
|
||||||
|
options: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
user_confirms: bool = True,
|
||||||
|
**_: Any,
|
||||||
|
) -> AssertionResponse:
|
||||||
|
"""Simulate getAssertion.
|
||||||
|
|
||||||
|
When user_confirms is False raises CtapError(OPERATION_DENIED).
|
||||||
|
Otherwise finds the credential, builds authData (37 bytes, no AT flag),
|
||||||
|
signs authData || clientDataHash with ECDSA-SHA256 (DER), and returns
|
||||||
|
an AssertionResponse — byte-for-byte compatible with the firmware output.
|
||||||
|
"""
|
||||||
|
if not user_confirms:
|
||||||
|
raise CtapError(CtapError.ERR.OPERATION_DENIED)
|
||||||
|
|
||||||
|
rp_id_hash = hashlib.sha256(rp_id.encode()).digest()
|
||||||
|
|
||||||
|
if not allow_list:
|
||||||
|
raise CtapError(CtapError.ERR.NO_CREDENTIALS)
|
||||||
|
|
||||||
|
cred_id: bytes | None = None
|
||||||
|
cred: _Credential | None = None
|
||||||
|
for desc in allow_list:
|
||||||
|
cid: bytes = desc["id"] if isinstance(desc, dict) else getattr(desc, "id")
|
||||||
|
entry = self._creds.get(cid)
|
||||||
|
if entry is not None and entry.rp_id_hash == rp_id_hash:
|
||||||
|
cred_id = cid
|
||||||
|
cred = entry
|
||||||
|
break
|
||||||
|
|
||||||
|
if cred is None or cred_id is None:
|
||||||
|
raise CtapError(CtapError.ERR.NO_CREDENTIALS)
|
||||||
|
|
||||||
|
# authData layout matches fido_get_assertion.c build_get_assertion_response():
|
||||||
|
# rpIdHash(32) | flags(1) | signCount(4)
|
||||||
|
auth_data_bytes = (
|
||||||
|
rp_id_hash
|
||||||
|
+ bytes([0x01]) # flags: UP=1
|
||||||
|
+ struct.pack(">I", cred.sign_count)
|
||||||
|
)
|
||||||
|
cred.sign_count += 1
|
||||||
|
|
||||||
|
# Signature over authData || clientDataHash, DER-encoded.
|
||||||
|
# Matches drv_crypto_sign_hash_DER() in the firmware.
|
||||||
|
sig = cred.private_key.sign(
|
||||||
|
auth_data_bytes + client_data_hash,
|
||||||
|
ec.ECDSA(hashes.SHA256()),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_field: dict[str, Any] | None = {"id": cred.user_id} if cred.user_id else None
|
||||||
|
|
||||||
|
return AssertionResponse(
|
||||||
|
credential={"id": cred_id, "type": "public-key"},
|
||||||
|
auth_data=AuthenticatorData(auth_data_bytes),
|
||||||
|
signature=sig,
|
||||||
|
user=user_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── test helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def refusing(self) -> "_RefusingView":
|
||||||
|
"""Return a view of this emulator that always declines confirmation.
|
||||||
|
|
||||||
|
Use this when the test goes through _with_direct_ctap2, which calls
|
||||||
|
make_credential / get_assertion via a lambda and cannot inject
|
||||||
|
user_confirms directly:
|
||||||
|
|
||||||
|
with patch.object(state, "_with_direct_ctap2",
|
||||||
|
side_effect=lambda fn: fn(emulator.refusing())):
|
||||||
|
ok, msg = state.authenticate_with_card("alice")
|
||||||
|
self.assertFalse(ok)
|
||||||
|
"""
|
||||||
|
return _RefusingView(self)
|
||||||
|
|
||||||
|
def forget_user(self, username: str) -> int:
|
||||||
|
"""Remove all credentials for *username* from the emulator store.
|
||||||
|
|
||||||
|
Returns the number of credentials removed. Use this to simulate a
|
||||||
|
card-side credential deletion (factory reset, or deliberate removal).
|
||||||
|
"""
|
||||||
|
to_delete = [cid for cid, e in self._creds.items() if e.username == username]
|
||||||
|
for cid in to_delete:
|
||||||
|
del self._creds[cid]
|
||||||
|
return len(to_delete)
|
||||||
|
|
||||||
|
def credential_count(self) -> int:
|
||||||
|
"""Total number of credentials currently in the emulator store."""
|
||||||
|
return len(self._creds)
|
||||||
|
|
||||||
|
|
||||||
|
class _RefusingView:
|
||||||
|
"""Thin proxy returned by CardEmulator.refusing().
|
||||||
|
|
||||||
|
Forwards make_credential and get_assertion to the underlying emulator
|
||||||
|
with user_confirms=False, so every call raises OPERATION_DENIED.
|
||||||
|
The credential store is shared with the parent emulator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, emulator: CardEmulator) -> None:
|
||||||
|
self._emulator = emulator
|
||||||
|
|
||||||
|
def make_credential(self, **kwargs: Any) -> AttestationResponse:
|
||||||
|
return self._emulator.make_credential(**kwargs, user_confirms=False)
|
||||||
|
|
||||||
|
def get_assertion(self, **kwargs: Any) -> AssertionResponse:
|
||||||
|
return self._emulator.get_assertion(**kwargs, user_confirms=False)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
|
||||||
|
const registrationTimeoutMs = Number(process.env.CARD_REGISTRATION_TIMEOUT_MS || "90000");
|
||||||
|
const loginTimeoutMs = Number(process.env.CARD_LOGIN_TIMEOUT_MS || "90000");
|
||||||
|
|
||||||
|
function uniqueUsername() {
|
||||||
|
return `pw_${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForActionResult(page, action, expectedText, timeoutMs) {
|
||||||
|
const flowResult = page.locator("#flowResult");
|
||||||
|
await action();
|
||||||
|
await expect(flowResult).toContainText(expectedText, { timeout: timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("k_client portal regression", () => {
|
||||||
|
test("registers, logs in, reads counter, logs out, and unregisters", async ({ page }) => {
|
||||||
|
const username = uniqueUsername();
|
||||||
|
const usersList = page.locator("#usersList");
|
||||||
|
const flowResult = page.locator("#flowResult");
|
||||||
|
const sessionLine = page.locator("#stateSession");
|
||||||
|
|
||||||
|
test.setTimeout(registrationTimeoutMs + loginTimeoutMs + 90_000);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("heading", { name: "ChromeCard Client Flow" })).toBeVisible();
|
||||||
|
await page.getByLabel("Username").fill(username);
|
||||||
|
|
||||||
|
await test.step("Register user", async () => {
|
||||||
|
// Card step: press yes on the registration prompt.
|
||||||
|
await waitForActionResult(
|
||||||
|
page,
|
||||||
|
() => page.getByRole("button", { name: "Register User" }).click(),
|
||||||
|
"User registration succeeded.",
|
||||||
|
registrationTimeoutMs
|
||||||
|
);
|
||||||
|
await expect(usersList).toContainText(username);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Login", async () => {
|
||||||
|
// Card step: press yes on the authentication prompt.
|
||||||
|
await waitForActionResult(
|
||||||
|
page,
|
||||||
|
() => page.getByRole("button", { name: "Login" }).click(),
|
||||||
|
"Login succeeded. You can now call k_server.",
|
||||||
|
loginTimeoutMs
|
||||||
|
);
|
||||||
|
await expect(sessionLine).toContainText("Session active: yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Call k_server counter", async () => {
|
||||||
|
await page.getByRole("button", { name: "Call k_server" }).click();
|
||||||
|
await expect(flowResult).toContainText("k_server was reached. Counter value:");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Logout", async () => {
|
||||||
|
await page.getByRole("button", { name: "Logout" }).click();
|
||||||
|
await expect(flowResult).toContainText("Session cleared.");
|
||||||
|
await expect(sessionLine).toContainText("Session active: no");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Unregister user", async () => {
|
||||||
|
const row = usersList.locator(".user-row", { hasText: username });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.getByRole("button", { name: "Unregister" }).click();
|
||||||
|
await expect(flowResult).toContainText(`User ${username} was unregistered.`);
|
||||||
|
await expect(usersList).not.toContainText(username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,389 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Local WebAuthn demo server for USB FIDO2 card testing.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Validate registration and authentication flows with the connected card.
|
||||||
|
- Keep setup minimal (Python stdlib only).
|
||||||
|
|
||||||
|
Security note:
|
||||||
|
- This demo does NOT verify attestation or assertion signatures.
|
||||||
|
- Use only for local bring-up/testing, not production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def b64u_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def b64u_decode(data: str) -> bytes:
|
||||||
|
pad = "=" * ((4 - len(data) % 4) % 4)
|
||||||
|
return base64.urlsafe_b64decode((data + pad).encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def random_b64u(n: int = 32) -> str:
|
||||||
|
return b64u_encode(secrets.token_bytes(n))
|
||||||
|
|
||||||
|
|
||||||
|
class DemoState:
|
||||||
|
def __init__(self, db_path: Path, rp_id: str, rp_name: str, origin: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.rp_id = rp_id
|
||||||
|
self.rp_name = rp_name
|
||||||
|
self.origin = origin
|
||||||
|
self.pending_register: dict[str, str] = {}
|
||||||
|
self.pending_auth: dict[str, str] = {}
|
||||||
|
self.db: dict[str, Any] = self._load_db()
|
||||||
|
|
||||||
|
def _load_db(self) -> dict[str, Any]:
|
||||||
|
if not self.db_path.exists():
|
||||||
|
return {"users": {}}
|
||||||
|
with self.db_path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def save_db(self) -> None:
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.db_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.db, f, indent=2)
|
||||||
|
|
||||||
|
def get_user(self, username: str) -> dict[str, Any]:
|
||||||
|
users = self.db.setdefault("users", {})
|
||||||
|
return users.setdefault(username, {"credentials": []})
|
||||||
|
|
||||||
|
|
||||||
|
def html_page() -> str:
|
||||||
|
return """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>ChromeCard WebAuthn Local Demo</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h1 { margin-bottom: 0.5rem; }
|
||||||
|
.row { display: flex; gap: 0.5rem; margin: 0.8rem 0; }
|
||||||
|
input { flex: 1; padding: 0.55rem; border: 1px solid #c8c8c8; border-radius: 8px; }
|
||||||
|
button { padding: 0.55rem 0.8rem; border: 1px solid #444; border-radius: 8px; background: #fff; cursor: pointer; }
|
||||||
|
pre { background: #111; color: #ddd; padding: 1rem; border-radius: 10px; overflow: auto; min-height: 200px; }
|
||||||
|
.muted { color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ChromeCard WebAuthn Demo</h1>
|
||||||
|
<p class="muted">Use this page to test local FIDO2 register/login over USB.</p>
|
||||||
|
<div class="row">
|
||||||
|
<input id="username" value="alice" />
|
||||||
|
<button id="registerBtn">Register</button>
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
</div>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
<script>
|
||||||
|
const log = (obj) => {
|
||||||
|
const el = document.getElementById("log");
|
||||||
|
const text = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
|
||||||
|
el.textContent = text + "\\n" + el.textContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toB64u = (bytes) => {
|
||||||
|
let str = "";
|
||||||
|
const arr = new Uint8Array(bytes);
|
||||||
|
for (let i = 0; i < arr.length; i++) str += String.fromCharCode(arr[i]);
|
||||||
|
return btoa(str).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromB64u = (s) => {
|
||||||
|
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function postJson(path, body) {
|
||||||
|
const resp = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(JSON.stringify(data));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register() {
|
||||||
|
const username = document.getElementById("username").value.trim();
|
||||||
|
const start = await postJson("/register/start", {username});
|
||||||
|
const pk = start.publicKey;
|
||||||
|
pk.challenge = fromB64u(pk.challenge);
|
||||||
|
pk.user.id = fromB64u(pk.user.id);
|
||||||
|
const cred = await navigator.credentials.create({ publicKey: pk });
|
||||||
|
const body = {
|
||||||
|
username,
|
||||||
|
id: cred.id,
|
||||||
|
rawId: toB64u(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: toB64u(cred.response.clientDataJSON),
|
||||||
|
attestationObject: toB64u(cred.response.attestationObject),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finish = await postJson("/register/finish", body);
|
||||||
|
log({registerResult: finish});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const username = document.getElementById("username").value.trim();
|
||||||
|
const start = await postJson("/auth/start", {username});
|
||||||
|
const pk = start.publicKey;
|
||||||
|
pk.challenge = fromB64u(pk.challenge);
|
||||||
|
pk.allowCredentials = pk.allowCredentials.map(c => ({...c, id: fromB64u(c.id)}));
|
||||||
|
const assertion = await navigator.credentials.get({ publicKey: pk });
|
||||||
|
const body = {
|
||||||
|
username,
|
||||||
|
id: assertion.id,
|
||||||
|
rawId: toB64u(assertion.rawId),
|
||||||
|
type: assertion.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: toB64u(assertion.response.clientDataJSON),
|
||||||
|
authenticatorData: toB64u(assertion.response.authenticatorData),
|
||||||
|
signature: toB64u(assertion.response.signature),
|
||||||
|
userHandle: assertion.response.userHandle ? toB64u(assertion.response.userHandle) : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finish = await postJson("/auth/finish", body);
|
||||||
|
log({authResult: finish});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("registerBtn").addEventListener("click", () => {
|
||||||
|
register().catch((e) => log("register error: " + e.message));
|
||||||
|
});
|
||||||
|
document.getElementById("loginBtn").addEventListener("click", () => {
|
||||||
|
login().catch((e) => log("login error: " + e.message));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
state: DemoState
|
||||||
|
|
||||||
|
def _json(self, status: int, data: dict[str, Any]) -> None:
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _bad(self, message: str, status: int = 400) -> None:
|
||||||
|
self._json(status, {"ok": False, "error": message})
|
||||||
|
|
||||||
|
def _read_json(self) -> dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/":
|
||||||
|
body = html_page().encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
try:
|
||||||
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._bad("Invalid JSON")
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/register/start":
|
||||||
|
self._register_start(data)
|
||||||
|
return
|
||||||
|
if path == "/register/finish":
|
||||||
|
self._register_finish(data)
|
||||||
|
return
|
||||||
|
if path == "/auth/start":
|
||||||
|
self._auth_start(data)
|
||||||
|
return
|
||||||
|
if path == "/auth/finish":
|
||||||
|
self._auth_finish(data)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def _register_start(self, data: dict[str, Any]) -> None:
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
if not username:
|
||||||
|
self._bad("username required")
|
||||||
|
return
|
||||||
|
|
||||||
|
challenge = random_b64u(32)
|
||||||
|
user_id = random_b64u(32)
|
||||||
|
self.state.pending_register[username] = challenge
|
||||||
|
public_key = {
|
||||||
|
"rp": {"name": self.state.rp_name, "id": self.state.rp_id},
|
||||||
|
"user": {"id": user_id, "name": username, "displayName": username},
|
||||||
|
"challenge": challenge,
|
||||||
|
"pubKeyCredParams": [{"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -257}],
|
||||||
|
"timeout": 60000,
|
||||||
|
"attestation": "none",
|
||||||
|
"authenticatorSelection": {
|
||||||
|
"residentKey": "discouraged",
|
||||||
|
"requireResidentKey": False,
|
||||||
|
"userVerification": "preferred",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self._json(200, {"ok": True, "publicKey": public_key})
|
||||||
|
|
||||||
|
def _register_finish(self, data: dict[str, Any]) -> None:
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
expected = self.state.pending_register.get(username)
|
||||||
|
if not username or not expected:
|
||||||
|
self._bad("no pending registration")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client_data_raw = b64u_decode(data["response"]["clientDataJSON"])
|
||||||
|
client_data = json.loads(client_data_raw.decode("utf-8"))
|
||||||
|
challenge = client_data.get("challenge")
|
||||||
|
typ = client_data.get("type")
|
||||||
|
origin = client_data.get("origin")
|
||||||
|
except Exception:
|
||||||
|
self._bad("invalid credential response")
|
||||||
|
return
|
||||||
|
|
||||||
|
if typ != "webauthn.create":
|
||||||
|
self._bad("unexpected clientData type")
|
||||||
|
return
|
||||||
|
if challenge != expected:
|
||||||
|
self._bad("challenge mismatch")
|
||||||
|
return
|
||||||
|
if origin != self.state.origin:
|
||||||
|
self._bad(f"origin mismatch: expected {self.state.origin}, got {origin}")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_id = str(data.get("rawId", ""))
|
||||||
|
if not raw_id:
|
||||||
|
self._bad("rawId missing")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = self.state.get_user(username)
|
||||||
|
creds = user.setdefault("credentials", [])
|
||||||
|
if raw_id not in creds:
|
||||||
|
creds.append(raw_id)
|
||||||
|
self.state.save_db()
|
||||||
|
self.state.pending_register.pop(username, None)
|
||||||
|
self._json(200, {"ok": True, "username": username, "credential_count": len(creds)})
|
||||||
|
|
||||||
|
def _auth_start(self, data: dict[str, Any]) -> None:
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
if not username:
|
||||||
|
self._bad("username required")
|
||||||
|
return
|
||||||
|
user = self.state.db.get("users", {}).get(username)
|
||||||
|
if not user or not user.get("credentials"):
|
||||||
|
self._bad("no credentials for user", 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
challenge = random_b64u(32)
|
||||||
|
self.state.pending_auth[username] = challenge
|
||||||
|
allow_credentials = [{"type": "public-key", "id": cid} for cid in user["credentials"]]
|
||||||
|
public_key = {
|
||||||
|
"challenge": challenge,
|
||||||
|
"rpId": self.state.rp_id,
|
||||||
|
"timeout": 60000,
|
||||||
|
"userVerification": "preferred",
|
||||||
|
"allowCredentials": allow_credentials,
|
||||||
|
}
|
||||||
|
self._json(200, {"ok": True, "publicKey": public_key})
|
||||||
|
|
||||||
|
def _auth_finish(self, data: dict[str, Any]) -> None:
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
expected = self.state.pending_auth.get(username)
|
||||||
|
if not username or not expected:
|
||||||
|
self._bad("no pending authentication")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = self.state.db.get("users", {}).get(username, {})
|
||||||
|
known = set(user.get("credentials", []))
|
||||||
|
raw_id = str(data.get("rawId", ""))
|
||||||
|
if raw_id not in known:
|
||||||
|
self._bad("unknown credential")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_data_raw = b64u_decode(data["response"]["clientDataJSON"])
|
||||||
|
client_data = json.loads(client_data_raw.decode("utf-8"))
|
||||||
|
challenge = client_data.get("challenge")
|
||||||
|
typ = client_data.get("type")
|
||||||
|
origin = client_data.get("origin")
|
||||||
|
except Exception:
|
||||||
|
self._bad("invalid assertion response")
|
||||||
|
return
|
||||||
|
|
||||||
|
if typ != "webauthn.get":
|
||||||
|
self._bad("unexpected clientData type")
|
||||||
|
return
|
||||||
|
if challenge != expected:
|
||||||
|
self._bad("challenge mismatch")
|
||||||
|
return
|
||||||
|
if origin != self.state.origin:
|
||||||
|
self._bad(f"origin mismatch: expected {self.state.origin}, got {origin}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state.pending_auth.pop(username, None)
|
||||||
|
self._json(200, {"ok": True, "username": username, "authenticated": True})
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Local WebAuthn demo server")
|
||||||
|
parser.add_argument("--host", default="localhost")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
parser.add_argument("--rp-id", default="localhost")
|
||||||
|
parser.add_argument("--rp-name", default="ChromeCard Local Demo")
|
||||||
|
parser.add_argument("--origin", default="http://localhost:8765")
|
||||||
|
parser.add_argument("--db", default=".webauthn_demo_db.json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db).resolve()
|
||||||
|
state = DemoState(db_path, rp_id=args.rp_id, rp_name=args.rp_name, origin=args.origin)
|
||||||
|
Handler.state = state
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
print(f"WebAuthn demo listening on http://{args.host}:{args.port}")
|
||||||
|
print(f"RP ID: {args.rp_id}")
|
||||||
|
print(f"Origin: {args.origin}")
|
||||||
|
print(f"DB: {db_path}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Reference in New Issue