Implement password handling for unattended access
build / build-linux-amd64 (push) Successful in 2m0s

This commit is contained in:
2026-05-08 09:32:13 +02:00
parent c1eaac1cb3
commit 9d53999eea
10 changed files with 260 additions and 30 deletions
+8
View File
@@ -80,6 +80,14 @@ html, body {
}
.pw-form button:hover { background: #0369a1; }
/* Separator between "waiting for approval / cancel" and the unattended-
* password override on the awaiting-approval screen. */
.pw-divider {
border: none;
border-top: 1px solid rgba(148, 163, 184, 0.2);
margin: 20px 0 12px;
}
.error-inline {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
+13 -7
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+53 -8
View File
@@ -99,11 +99,22 @@ function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
/**
* Render the "waiting for approval" placeholder shown after the host
* emits LoginResponse{error:"No Password Access"}. The Cancel button
* reloads the page to abort cleanly — the websocket closes on unload,
* which the host treats as a normal client disconnect.
* emits LoginResponse{error:"No Password Access"} or accepts our
* empty-password LoginRequest into its `try_start_cm` branch. Two
* affordances:
* - Cancel: reload the page (closes the websocket, host sees a
* clean client disconnect).
* - Submit a password: tell the caller, which closes the in-flight
* session and restarts the connect loop with that password — used
* for unattended access when no user is logged in to click Accept.
*/
function renderAwaitingApproval(cfg: CustomConfig): void {
interface AwaitingApprovalCallbacks {
onUsePassword: (pw: string) => void;
}
function renderAwaitingApproval(
cfg: CustomConfig,
callbacks: AwaitingApprovalCallbacks,
): void {
status(`
<h1>Waiting for approval</h1>
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> is being asked to allow this connection.</p>
@@ -111,12 +122,26 @@ function renderAwaitingApproval(cfg: CustomConfig): void {
<form class="pw-form" id="cancel-form">
<button type="submit">Cancel</button>
</form>
<hr class="pw-divider" />
<p class="muted">Or connect with the unattended password:</p>
<form class="pw-form" id="override-form">
<input id="override-input" type="password" autocomplete="current-password" placeholder="Password" />
<button type="submit">Connect</button>
</form>
`);
const form = document.getElementById("cancel-form") as HTMLFormElement | null;
form?.addEventListener("submit", (ev) => {
const cancelForm = document.getElementById("cancel-form") as HTMLFormElement | null;
cancelForm?.addEventListener("submit", (ev) => {
ev.preventDefault();
window.location.reload();
});
const overrideForm = document.getElementById("override-form") as HTMLFormElement | null;
const overrideInput = document.getElementById("override-input") as HTMLInputElement | null;
overrideForm?.addEventListener("submit", (ev) => {
ev.preventDefault();
const pw = overrideInput?.value || "";
if (!pw) return;
callbacks.onUsePassword(pw);
});
}
/**
@@ -165,6 +190,12 @@ async function main(): Promise<void> {
// ConnectionDeclinedError → "declined" placeholder with Try Again.
let password = "";
let pwErr: string | undefined;
// When the user submits a password from the awaiting-approval screen,
// the renderer sets this and aborts the in-flight session. The catch
// block below sees the resulting error, picks up the password, and
// re-enters the connect loop — without going through askPassword
// (which would replace the awaiting UI with its own prompt mid-flow).
let overridePassword: string | null = null;
while (true) {
if (pwErr !== undefined) {
// Host has signalled it requires a password — collect one.
@@ -176,13 +207,27 @@ async function main(): Promise<void> {
serverPk,
password,
/*statusUI=*/true,
() => renderAwaitingApproval(cfg),
(abort) =>
renderAwaitingApproval(cfg, {
onUsePassword: (pw) => {
overridePassword = pw;
abort();
},
}),
);
// First connect succeeded — hand off to the session loop, which
// owns reconnect.
await runSession(cfg, serverPk, password, ready);
return;
} catch (e) {
if (overridePassword !== null) {
// User chose the unattended-password path while waiting for
// approval. Restart the connect with that password.
password = overridePassword;
overridePassword = null;
pwErr = undefined;
continue;
}
if (e instanceof ConnectionDeclinedError) {
await renderDeclined(cfg, e.message);
// Reset to empty-password attempt for the retry — the host's
@@ -227,7 +272,7 @@ async function connectOnce(
serverPk: Uint8Array,
password: string,
statusUI: boolean,
onAwaitingApproval?: () => void,
onAwaitingApproval?: (abort: () => void) => void,
): Promise<ConnectResult> {
const setStatus = (html: string): void => { if (statusUI) status(html); };
+42 -9
View File
@@ -60,13 +60,18 @@ export interface SessionOptions {
/** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */
sessionId: number;
/**
* Fired once if the host responds to LoginRequest with the interim
* "No Password Access" error — meaning it's now showing an approval
* popup to the user on its end and we should keep waiting for the
* real LoginResponse. Use this to swap the UI to "waiting for
* approval" without aborting the connection.
* Fired once when we enter the "waiting for the host's user to click
* Accept" state — either because the host emitted the interim
* `LOGIN_MSG_NO_PASSWORD_ACCESS` error, or because we sent an empty-
* password LoginRequest (the host's `try_start_cm` branch is silent).
*
* The renderer is given an `abort` callback that closes the relay,
* which causes the in-flight `recv()` to reject and bubbles out of
* `Session.open` as a generic error. Use it to wire UI affordances
* like "cancel" or "switch to password auth" without leaving a half-
* connected websocket dangling.
*/
onAwaitingApproval?: () => void;
onAwaitingApproval?: (abort: () => void) => void;
}
/**
@@ -166,11 +171,22 @@ export class Session {
// UTF-8 bytes the way the Rust side reads them.
// pwd_hash = SHA256(password_text || salt_utf8_bytes)
// password_response = SHA256(pwd_hash || challenge_utf8_bytes)
//
// Special case the empty password: the desktop client sends LITERAL
// empty bytes for `lr.password` when the user hasn't typed a
// password yet (client.rs:3472-3475 — "login without password, the
// remote side can click accept"). The host's connection.rs:2488
// branches on `lr.password.is_empty()` to start the cm_popup
// approval flow; if we send a SHA-256-derived 32-byte response
// instead, the host treats it as a wrong-password attempt and
// returns LOGIN_MSG_PASSWORD_WRONG, which collapses the
// attended-no-password approval path.
const enc = new TextEncoder();
const saltBytes = enc.encode(salt);
const challengeBytes = enc.encode(challenge);
const pwdHash = sha256(concat(opts.password, saltBytes));
const passwordResp = sha256(concat(pwdHash, challengeBytes));
const passwordResp = opts.password.length === 0
? new Uint8Array(0)
: sha256(concat(sha256(concat(opts.password, saltBytes)), challengeBytes));
// -------- 8. Send Message{login_request} --------
const loginReq = hbb.Message.create({
@@ -219,6 +235,21 @@ export class Session {
// replay them into its main receive dispatch.
const preloginExtras: hbb.Message[] = [];
let approvalNotified = false;
// Empty-password path: when the host has a permanent password set
// AND a user is logged in, connection.rs:2488 just calls
// try_start_cm and stays silent (no LOGIN_MSG_NO_PASSWORD_ACCESS).
// The desktop client renders "Please wait for the remote side…" on
// its own as soon as it sends LoginRequest in that case; we do the
// same here so the UI doesn't sit on the "secure handshake + login"
// step indefinitely. The notification is idempotent — if the host
// *does* later send LOGIN_MSG_NO_PASSWORD_ACCESS, the existing
// branch below no-ops on `approvalNotified`.
if (passwordResp.length === 0) {
approvalNotified = true;
try {
opts.onAwaitingApproval?.(() => session.close());
} catch { /* swallow */ }
}
while (true) {
const respMsg = await session.recv();
if (respMsg.test_delay) {
@@ -246,7 +277,9 @@ export class Session {
if (lr.error === "No Password Access") {
if (!approvalNotified) {
approvalNotified = true;
try { opts.onAwaitingApproval?.(); } catch { /* swallow */ }
try {
opts.onAwaitingApproval?.(() => session.close());
} catch { /* swallow */ }
}
continue;
}
+8
View File
@@ -80,6 +80,14 @@ html, body {
}
.pw-form button:hover { background: #0369a1; }
/* Separator between "waiting for approval / cancel" and the unattended-
* password override on the awaiting-approval screen. */
.pw-divider {
border: none;
border-top: 1px solid rgba(148, 163, 184, 0.2);
margin: 20px 0 12px;
}
.error-inline {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);