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:
@@ -862,13 +862,37 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}) => {
|
||||
const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}, expectedServerId = '') => {
|
||||
const versionUrl = buildVersionUrl(url);
|
||||
if (!versionUrl) {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
|
||||
// Identity gate for learned/untrusted addresses: verify the UNAUTHENTICATED
|
||||
// /health identity before the token-carrying version fetch, so the bearer
|
||||
// token is never sent to a re-assigned address that now belongs to a
|
||||
// different machine. Older servers omit serverId from /health; only an
|
||||
// explicit mismatch rejects.
|
||||
if (typeof expectedServerId === 'string' && expectedServerId.trim()) {
|
||||
const healthUrl = buildHealthUrl(url);
|
||||
if (healthUrl) {
|
||||
try {
|
||||
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'application/json' } });
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const reported = typeof payload?.serverId === 'string' ? payload.serverId.trim() : '';
|
||||
if (reported && reported !== expectedServerId.trim()) {
|
||||
return { status: 'wrong-service', latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unreachable/timeout surfaces in the version fetch below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' };
|
||||
const token = typeof clientToken === 'string' ? clientToken.trim() : '';
|
||||
@@ -3814,7 +3838,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
return getOrCreateDesktopInstallId();
|
||||
|
||||
case 'desktop_host_probe':
|
||||
return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {});
|
||||
return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}, String(args.expectedServerId || ''));
|
||||
|
||||
case 'desktop_remote_password_login':
|
||||
return loginRemoteAndIssueClientToken({
|
||||
|
||||
Reference in New Issue
Block a user