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