From e57f8a446fb05573d5495876661f89e0a4c5c0de Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 25 Apr 2026 19:29:28 +0200 Subject: [PATCH] Improve portal enrollment controls and direct hidraw selection --- PHASE5_RUNBOOK.md | 4 +- Setup.md | 8 +++ Workplan.md | 5 +- k_client_portal.py | 167 ++++++++++++++++++++++++++++++++++++++++++--- k_proxy_app.py | 25 ++++++- 5 files changed, 197 insertions(+), 12 deletions(-) diff --git a/PHASE5_RUNBOOK.md b/PHASE5_RUNBOOK.md index ede6b07..d5f4446 100644 --- a/PHASE5_RUNBOOK.md +++ b/PHASE5_RUNBOOK.md @@ -8,6 +8,8 @@ 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 @@ -218,7 +220,7 @@ Verified result on 2026-04-25: - next step is to recover the USB/Qubes transport path before retrying direct auth - after a full power cycle and reattach, manual CTAPHID `INIT` replies again and `webauthn_local_demo.py` registration succeeds again - direct `raw_ctap_probe.py --device-path /dev/hidraw0 make-credential --rp-id localhost` also succeeds again after pressing `yes` on the card - - `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and explicit `/dev/hidraw0` + - `k_proxy_app.py --auth-mode fido2-direct` was patched to use low-level CTAP2 and to auto-detect the working `/dev/hidraw*` node when the card re-enumerates - after additional fixes for hidraw lifetime, VM-side `python-fido2` response mapping, and CTAP payload shape, `/enroll/register` now succeeds again for `directtest` - `/session/login` for `directtest` now also succeeds after card confirmation and returns `auth_mode: "fido2_assertion"` - `/session/status` succeeds diff --git a/Setup.md b/Setup.md index b3f79f3..80d938b 100644 --- a/Setup.md +++ b/Setup.md @@ -366,6 +366,11 @@ Session note (2026-04-25, k_client browser flow page): - 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 - 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. @@ -586,6 +591,9 @@ Session note (2026-04-25, direct FIDO2 auth attempt): - 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` diff --git a/Workplan.md b/Workplan.md index 6dd0fc7..8be6eef 100644 --- a/Workplan.md +++ b/Workplan.md @@ -258,7 +258,7 @@ Status (2026-04-25): - 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 and explicit `/dev/hidraw0` + - `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` @@ -321,6 +321,9 @@ Status (2026-04-25): - 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 - Verified end-to-end through the portal: - enroll `alice` - login succeeds diff --git a/k_client_portal.py b/k_client_portal.py index df8cbee..82f4b13 100644 --- a/k_client_portal.py +++ b/k_client_portal.py @@ -146,6 +146,41 @@ HTML = """ 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; @@ -277,6 +312,11 @@ HTML = """
Session: unknown
Expires: unknown
+
+

Registered Users

+
Loading users...
+
+

Flow Result

No flow run yet.
@@ -298,6 +338,8 @@ HTML = """ 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")); @@ -337,18 +379,70 @@ HTML = """ 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 = + `
${user.username}
` + + `
Credential present: ${user.has_credential ? "yes" : "no"}
`; + + 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 registration prompt, press yes 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 authentication prompt, press yes to allow login or no to deny it."; - const result = await api("/api/login", {}); + const result = await api("/api/login", {username: username()}); log("Login", result); await refreshState(); return result; @@ -372,6 +466,21 @@ HTML = """ 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..."; @@ -421,12 +530,13 @@ HTML = """ setBusy(true); try { const state = await refreshState(); - log("State refreshed", state); + const users = await refreshUsers(); + log("State refreshed", {state, users}); } finally { setBusy(false); } }); - refreshState().then((state) => { - log("Client flow page ready", state); + Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => { + log("Client flow page ready", {state, users}); }); @@ -509,6 +619,23 @@ class ClientState: "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 { @@ -523,15 +650,21 @@ class ClientState: with self.lock: return self.session_token - def login(self) -> tuple[int, dict[str, Any]]: + def login(self, username: str | None = None) -> tuple[int, dict[str, Any]]: + requested = (username or "").strip() with self.lock: - if not self.preferred_enrollment: + if requested: + username = requested + elif self.preferred_enrollment: + username = self.preferred_enrollment.username + else: return 400, {"ok": False, "error": "no enrolled user"} - username = self.preferred_enrollment.username status, data = self._proxy_json("POST", "/session/login", {"username": username}) 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 @@ -588,6 +721,10 @@ class Handler(BaseHTTPRequestHandler): 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 @@ -602,7 +739,21 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result.get("ok") else 400, result) return if path == "/api/login": - status, data = self.state.login() + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + status, data = self.state.login(str(data.get("username", ""))) + self._json(status, data) + return + if path == "/api/enroll/delete": + try: + data = self._read_json() + except Exception: + self._json(400, {"ok": False, "error": "invalid json"}) + return + status, data = self.state.delete_enrollment(str(data.get("username", ""))) self._json(status, data) return if path == "/api/status": diff --git a/k_proxy_app.py b/k_proxy_app.py index 346c82e..c411220 100644 --- a/k_proxy_app.py +++ b/k_proxy_app.py @@ -615,9 +615,30 @@ class ProxyState: return Fido2Client(device, self.client_data_collector, ProxyUserInteraction()) return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=ProxyUserInteraction()) + def _direct_device_candidates(self) -> list[str]: + configured = str(self.direct_device_path).strip() + candidates: list[str] = [] + if configured: + candidates.append(configured) + for path in sorted(Path("/dev").glob("hidraw*")): + as_text = str(path) + if as_text not in candidates: + candidates.append(as_text) + return candidates + def _open_direct_device(self) -> CtapHidDevice: - descriptor = get_descriptor(self.direct_device_path) - return CtapHidDevice(descriptor, open_connection(descriptor)) + last_exc: Exception | None = None + for candidate in self._direct_device_candidates(): + try: + descriptor = get_descriptor(candidate) + device = CtapHidDevice(descriptor, open_connection(descriptor)) + self.direct_device_path = candidate + return device + except Exception as exc: + last_exc = exc + if last_exc is None: + raise FileNotFoundError(f"no hidraw devices available for direct auth (configured {self.direct_device_path})") + raise last_exc def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice: with self.direct_device_lock: