Improve portal enrollment controls and direct hidraw selection
This commit is contained in:
parent
1d85c21d7f
commit
e57f8a446f
|
|
@ -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:
|
- `k_client_portal.py` can now be used in `k_client` at `http://127.0.0.1:8766` to show:
|
||||||
- registration
|
- registration
|
||||||
|
- current registered-user list from `k_proxy`
|
||||||
|
- unregister from the browser page
|
||||||
- login with card approval/denial
|
- login with card approval/denial
|
||||||
- protected `k_server` counter access
|
- protected `k_server` counter access
|
||||||
- logout
|
- 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
|
- 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
|
- 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
|
- 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`
|
- 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/login` for `directtest` now also succeeds after card confirmation and returns `auth_mode: "fido2_assertion"`
|
||||||
- `/session/status` succeeds
|
- `/session/status` succeeds
|
||||||
|
|
|
||||||
8
Setup.md
8
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`
|
- login with card approval or denial in `k_proxy`
|
||||||
- call the protected `k_server` counter
|
- call the protected `k_server` counter
|
||||||
- logout
|
- 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:
|
- It also makes the negative path explicit:
|
||||||
- if login is denied on the card, the page reports that `k_server` was not called
|
- 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.
|
- 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`
|
- protected `/resource/counter` access succeeds again through `k_proxy -> k_server`
|
||||||
- logout succeeds
|
- logout succeeds
|
||||||
- post-logout protected access returns `401`
|
- 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
|
- 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:
|
- `/home/user/chromecard/phase5_chain_regression.sh` now supports the direct-auth baseline via:
|
||||||
- `--interactive-card`
|
- `--interactive-card`
|
||||||
|
|
|
||||||
|
|
@ -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
|
- 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
|
- 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
|
- 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`
|
- 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`
|
## 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`
|
- 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_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`
|
- `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:
|
- Verified end-to-end through the portal:
|
||||||
- enroll `alice`
|
- enroll `alice`
|
||||||
- login succeeds
|
- login succeeds
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,41 @@ HTML = """<!doctype html>
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--muted);
|
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 {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|
@ -277,6 +312,11 @@ HTML = """<!doctype html>
|
||||||
<div class="status-line" id="stateSession">Session: unknown</div>
|
<div class="status-line" id="stateSession">Session: unknown</div>
|
||||||
<div class="status-line" id="stateExpires">Expires: unknown</div>
|
<div class="status-line" id="stateExpires">Expires: unknown</div>
|
||||||
</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">
|
<div class="status-card">
|
||||||
<h2>Flow Result</h2>
|
<h2>Flow Result</h2>
|
||||||
<div class="status-line" id="flowResult">No flow run yet.</div>
|
<div class="status-line" id="flowResult">No flow run yet.</div>
|
||||||
|
|
@ -298,6 +338,8 @@ HTML = """<!doctype html>
|
||||||
const stateUser = document.getElementById("stateUser");
|
const stateUser = document.getElementById("stateUser");
|
||||||
const stateSession = document.getElementById("stateSession");
|
const stateSession = document.getElementById("stateSession");
|
||||||
const stateExpires = document.getElementById("stateExpires");
|
const stateExpires = document.getElementById("stateExpires");
|
||||||
|
const usersSummary = document.getElementById("usersSummary");
|
||||||
|
const usersList = document.getElementById("usersList");
|
||||||
const usernameInput = document.getElementById("username");
|
const usernameInput = document.getElementById("username");
|
||||||
const buttons = Array.from(document.querySelectorAll("button"));
|
const buttons = Array.from(document.querySelectorAll("button"));
|
||||||
|
|
||||||
|
|
@ -337,18 +379,70 @@ HTML = """<!doctype html>
|
||||||
return data;
|
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() {
|
async function registerUser() {
|
||||||
hintBox.innerHTML = "Card step: if the card shows a <strong>registration</strong> prompt, press <strong>yes</strong> to enroll this user.";
|
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()});
|
const result = await api("/api/enroll", {username: username()});
|
||||||
log("Register user", result);
|
log("Register user", result);
|
||||||
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
|
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
|
||||||
await refreshState();
|
await refreshState();
|
||||||
|
await refreshUsers();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginUser() {
|
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.";
|
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);
|
log("Login", result);
|
||||||
await refreshState();
|
await refreshState();
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -372,6 +466,21 @@ HTML = """<!doctype html>
|
||||||
return result;
|
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() {
|
async function runFlow() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
flowResult.textContent = "Flow running...";
|
flowResult.textContent = "Flow running...";
|
||||||
|
|
@ -421,12 +530,13 @@ HTML = """<!doctype html>
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const state = await refreshState();
|
const state = await refreshState();
|
||||||
log("State refreshed", state);
|
const users = await refreshUsers();
|
||||||
|
log("State refreshed", {state, users});
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshState().then((state) => {
|
Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => {
|
||||||
log("Client flow page ready", state);
|
log("Client flow page ready", {state, users});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -509,6 +619,23 @@ class ClientState:
|
||||||
"proxy_enrollment": data,
|
"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]:
|
def snapshot(self) -> dict[str, Any]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return {
|
return {
|
||||||
|
|
@ -523,15 +650,21 @@ class ClientState:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return self.session_token
|
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:
|
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"}
|
return 400, {"ok": False, "error": "no enrolled user"}
|
||||||
username = self.preferred_enrollment.username
|
|
||||||
|
|
||||||
status, data = self._proxy_json("POST", "/session/login", {"username": username})
|
status, data = self._proxy_json("POST", "/session/login", {"username": username})
|
||||||
if status == 200 and data.get("session_token"):
|
if status == 200 and data.get("session_token"):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
||||||
|
self._save_preferred_enrollment_locked()
|
||||||
self.session_token = data["session_token"]
|
self.session_token = data["session_token"]
|
||||||
self.session_expires_at = int(data.get("expires_at", 0)) or None
|
self.session_expires_at = int(data.get("expires_at", 0)) or None
|
||||||
return status, data
|
return status, data
|
||||||
|
|
@ -588,6 +721,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if path == "/api/client/state":
|
if path == "/api/client/state":
|
||||||
self._json(200, self.state.snapshot())
|
self._json(200, self.state.snapshot())
|
||||||
return
|
return
|
||||||
|
if path == "/api/enrollments":
|
||||||
|
status, data = self.state.list_enrollments()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
|
@ -602,7 +739,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(200 if result.get("ok") else 400, result)
|
self._json(200 if result.get("ok") else 400, result)
|
||||||
return
|
return
|
||||||
if path == "/api/login":
|
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)
|
self._json(status, data)
|
||||||
return
|
return
|
||||||
if path == "/api/status":
|
if path == "/api/status":
|
||||||
|
|
|
||||||
|
|
@ -615,9 +615,30 @@ class ProxyState:
|
||||||
return Fido2Client(device, self.client_data_collector, ProxyUserInteraction())
|
return Fido2Client(device, self.client_data_collector, ProxyUserInteraction())
|
||||||
return Fido2Client(device, self.origin, verify=verify_rp_id, user_interaction=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:
|
def _open_direct_device(self) -> CtapHidDevice:
|
||||||
descriptor = get_descriptor(self.direct_device_path)
|
last_exc: Exception | None = None
|
||||||
return CtapHidDevice(descriptor, open_connection(descriptor))
|
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:
|
def _get_direct_device(self, *, force_reopen: bool = False) -> CtapHidDevice:
|
||||||
with self.direct_device_lock:
|
with self.direct_device_lock:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue