feat: connection candidates refresh + relay identity hardening

Candidates refresh (server + mobile + desktop clients):
- GET /api/client-auth/connection/candidates returns the server's current
  LAN URLs, relay candidate, and serverId for already-paired devices
- /health and /api/version expose serverId so clients can verify a learned
  address belongs to the expected server before sending their bearer token
- mobile: refresh saved candidates over the live transport after every
  connect/wake, hot-switch relay->LAN when a fresh address is reachable;
  serverId gate on direct probes; token no longer sent to /health
- desktop: refresh stored host apiUrl after a relay connect and hot-switch
  back to direct; electron probe verifies serverId before authenticated fetch

Fixes found while debugging a dead pairing:
- settings: strict reader that throws on corrupt/unreadable file instead of
  returning {}; relay signing/encryption key generation is now gated on it,
  so a swallowed read failure can no longer mint a new server identity and
  orphan every paired device (loud log when a keypair IS generated)
- SessionAuthGate: bounded auto-retry for transient session-check failures
  (initial request racing the relay tunnel's first WS attempt, startup 5xx)
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 18:09:54 +03:00
parent 22d5ad3814
commit afb368e11b
16 changed files with 610 additions and 27 deletions
@@ -27,6 +27,16 @@ import {
} from '@/lib/passkeys';
const STATUS_CHECK_ENDPOINT = '/auth/session';
// Transient-failure auto-retry for the initial session check. Over the relay the
// very first /auth/session can race the tunnel's initial WebSocket attempt (a
// failed attempt rejects requests queued on the channel even though the tunnel
// immediately reconnects), and on a lossy link the first request can simply drop.
// A single-shot check pins the gate on the error screen for a self-healing
// condition, so network errors and non-auth server errors (5xx during startup)
// retry a bounded number of times before surfacing the error UI. Definitive auth
// answers (200/401/429) are never retried.
const TRANSIENT_RETRY_MAX_ATTEMPTS = 4;
const TRANSIENT_RETRY_BASE_DELAY_MS = 1_500;
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
@@ -373,6 +383,40 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
};
}, [skipAuth]);
// Bounded retry scheduling for transient session-check failures. Lives in refs
// so retries survive re-renders; the timer is cleared on unmount, endpoint
// switch, and any definitive server answer.
const transientRetryAttemptRef = React.useRef(0);
const transientRetryTimerRef = React.useRef<number | null>(null);
const checkStatusRef = React.useRef<(() => Promise<void>) | null>(null);
const clearTransientRetry = React.useCallback(() => {
if (transientRetryTimerRef.current !== null) {
window.clearTimeout(transientRetryTimerRef.current);
transientRetryTimerRef.current = null;
}
}, []);
const resetTransientRetry = React.useCallback(() => {
transientRetryAttemptRef.current = 0;
clearTransientRetry();
}, [clearTransientRetry]);
// Returns true when another attempt was scheduled (caller keeps the pending
// UI); false when the retry budget is exhausted (caller shows the error UI).
const scheduleTransientRetry = React.useCallback((): boolean => {
if (transientRetryAttemptRef.current >= TRANSIENT_RETRY_MAX_ATTEMPTS) return false;
transientRetryAttemptRef.current += 1;
clearTransientRetry();
transientRetryTimerRef.current = window.setTimeout(() => {
transientRetryTimerRef.current = null;
void checkStatusRef.current?.();
}, TRANSIENT_RETRY_BASE_DELAY_MS * transientRetryAttemptRef.current);
return true;
}, [clearTransientRetry]);
React.useEffect(() => clearTransientRetry, [clearTransientRetry]);
const checkStatus = React.useCallback(async () => {
if (skipAuth) {
setState('authenticated');
@@ -386,8 +430,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
refreshPasskeyStatus(),
]);
const responseText = await response.text();
if (response.ok) {
resetTransientRetry();
setState('authenticated');
setIsTunnelLocked(false);
setErrorMessage('');
@@ -401,6 +446,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
} catch {
data = {};
}
resetTransientRetry();
setIsTunnelLocked(data.tunnelLocked === true);
setPasskeyStatus(latestPasskeyStatus);
setState('locked');
@@ -414,11 +460,15 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
} catch {
data = {};
}
resetTransientRetry();
setRetryAfter(data.retryAfter);
setIsTunnelLocked(false);
setState('rate-limited');
return;
}
// Non-auth server error (e.g. 502/503 while the backend is still coming
// up) — transient; keep the pending UI and retry before surfacing.
if (scheduleTransientRetry()) return;
setState('error');
setIsTunnelLocked(false);
} catch (error) {
@@ -429,10 +479,17 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
setIsTunnelLocked(false);
return;
}
// Network-level failure — over the relay this is typically the initial
// tunnel attempt racing this request; it self-heals within seconds.
if (scheduleTransientRetry()) return;
setState('error');
setIsTunnelLocked(false);
}
}, [refreshPasskeyStatus, skipAuth]);
}, [refreshPasskeyStatus, resetTransientRetry, scheduleTransientRetry, skipAuth]);
React.useEffect(() => {
checkStatusRef.current = checkStatus;
}, [checkStatus]);
React.useEffect(() => {
if (skipAuth) {
@@ -451,10 +508,11 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
setErrorMessage('');
setRetryAfter(undefined);
setIsTunnelLocked(false);
resetTransientRetry();
setState('pending');
void checkStatus();
});
}, [checkStatus, skipAuth]);
}, [checkStatus, resetTransientRetry, skipAuth]);
React.useEffect(() => {
if (!skipAuth && state === 'locked') {
@@ -719,7 +777,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
if (state === 'error') {
return (
<ErrorScreen onRetry={() => void checkStatus()} errorType="network">
<ErrorScreen onRetry={() => { resetTransientRetry(); void checkStatus(); }} errorType="network">
{showHostSwitcher && (
<div className="w-full max-w-xs">
<DesktopHostSwitcherInline />
@@ -31,6 +31,7 @@ import {
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import {
desktopSshConnect,
@@ -519,6 +520,9 @@ export function DesktopHostSwitcherDialog({
runtimeKey: runtimeKeyForHost(host),
relay,
});
// On the relay: learn the server's current LAN address in the background
// and hot-switch back to direct if the stored one merely went stale.
scheduleDesktopHostCandidateRefresh(host.id);
};
const origin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(host.url) || '');