Improve portal enrollment controls and direct hidraw selection

This commit is contained in:
Morten V. Christiansen 2026-04-25 19:29:28 +02:00
parent 1d85c21d7f
commit e57f8a446f
5 changed files with 197 additions and 12 deletions

View File

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

View File

@ -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`

View File

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

View File

@ -146,6 +146,41 @@ HTML = """<!doctype 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 = """<!doctype html>
<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>
@ -298,6 +338,8 @@ HTML = """<!doctype 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 = """<!doctype 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 =
`<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", {});
const result = await api("/api/login", {username: username()});
log("Login", result);
await refreshState();
return result;
@ -372,6 +466,21 @@ HTML = """<!doctype 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 = """<!doctype 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});
});
</script>
</body>
@ -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":

View File

@ -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: