Initialize workspace tracking repo
This commit is contained in:
commit
9abf41fcd5
|
|
@ -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/
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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,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