Initialize workspace tracking repo

This commit is contained in:
Morten V. Christiansen 2026-04-24 05:38:00 +02:00
commit 9abf41fcd5
5 changed files with 994 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Local agent and transient artifacts
.codex/
__pycache__/
*.pyc
# Keep firmware SDK tree out of this workspace-tracking repo
CR_SDK_CK-main/

191
Setup.md Normal file
View File

@ -0,0 +1,191 @@
# Setup
Last updated: 2026-04-24
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 template.
- 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.
## 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)
- Python is available: `Python 3.13.12`.
- `python3 fido2_probe.py --list` runs, but returns: `No CTAP HID devices found.`
- No HID raw device nodes currently visible: `no hidraw devices visible`.
- `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.
Implication:
- We cannot currently confirm live FIDO2 connectivity from this host.
- 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.
## 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`
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
- Why no `/dev/hidraw*` device is visible despite USB connection.
- Whether udev rule is missing or device VID/PID differs from expected.
- Whether current firmware on card exposes the FIDO2 HID interface.
- 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 Qubes firewall and service binding rules to enforce the `k_client -> k_proxy -> k_server` chain.
- Exact enrollment process interface running in `k_client` and how it reaches `k_proxy`.
- Concrete session format/lifetime so cached sessions reduce card prompts without weakening security.
- 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).

269
Workplan.md Normal file
View File

@ -0,0 +1,269 @@
# Workplan
Last updated: 2026-04-24
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 Debian-based AppVMs: `k_client`, `k_proxy`, `k_server`.
- Current authenticator link is card->`k_proxy` (USB), but architecture must allow migration to wireless phone-mediated validation.
## 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 the Debian template.
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 (Blocking)
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.
## 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.
## 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.
## 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.
## 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.
## 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.
## 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`.
- Validate end-to-end login to `k_server` resource through proxy chain.
Exit criteria:
- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`.
## 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.
Exit criteria:
- New team member can follow docs end-to-end without path or tooling ambiguity.
## Phase 9: Migrate to Phone-Mediated Wireless Validation (Future)
1. Auth transport abstraction in `k_proxy`.
- Introduce/keep a transport interface for authenticator operations.
- Implement at least two backends:
- USB-direct backend (current).
- Phone-wireless backend (future).
2. Wireless phone integration.
- Define protocol between `k_proxy` and phone service.
- Define secure pairing/authentication and message integrity for wireless link.
- Add timeout/retry behavior and offline handling.
3. Functional equivalence tests.
- Verify login/enrollment behavior is unchanged at API level for `k_client`.
- Verify session reuse still works and card prompts are not increased unexpectedly.
Exit criteria:
- `k_proxy` can validate via wireless phone path with no client-facing API changes.
## 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).

138
fido2_probe.py Executable file
View File

@ -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())

389
webauthn_local_demo.py Normal file
View File

@ -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())