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:
|
||||
- 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
|
||||
|
|
|
|||
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`
|
||||
- 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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue