feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)

Reworks how devices connect to an OpenChamber server, end to end.

Pairing v2:
- One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links
- Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog
- Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain)

Multi-transport devices:
- A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved)
- Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch

Device management:
- Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux)
- One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname
- Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives

Android:
- LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
Iuliia Ivashko
2026-07-10 00:12:33 +03:00
committed by GitHub
parent a1aae30e66
commit 91a95bfdaa
53 changed files with 4589 additions and 1369 deletions
+246 -6
View File
@@ -358,9 +358,26 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
tunnelAuthController,
uiAuthController,
remoteClientAuthRuntime,
clientPairingRuntime,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
// Returns the relay pairing candidate ({ type:'relay', relayUrl, serverId,
// hostEncPubJwk, priority }) when the host relay is enabled, else null.
// Injected lazily because the relay service is constructed after these routes.
getRelayPairingCandidate = async () => null,
// Re-evaluate the relay lifecycle after pairing/device changes.
reconcileRelay = async () => {},
// Returns { local, lan, relayAvailable } — the direct transport URLs the
// server can actually be reached on (LAN derived from the server bind, not
// the UI origin), for the create-device dialog.
getPairingTransports = () => ({ local: null, lan: null, relayAvailable: true }),
// Display name a paired device shows for THIS server (issuing machine's
// hostname), distinct from the per-device pairing label typed by the operator.
getServerLabel = () => 'OpenChamber',
} = dependencies;
const PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS = 10;
const pairingRedeemAttempts = new Map();
const runWithUiAuth = async (req, res, next, handler, options = {}) => {
try {
@@ -440,6 +457,112 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
return clients.find((client) => client.id === clientId) || null;
};
const requestOrigin = (req) => {
const forwardedProto = typeof req.headers?.['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim()
: '';
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
const host = typeof req.headers?.host === 'string' ? req.headers.host.trim() : '';
if (!host) return null;
return `${protocol}://${host}`;
};
const requestIp = (req) => {
// Do not use req.ip here: Express rewrites it from X-Forwarded-For when
// trust proxy is enabled, and redeem is unauthenticated before this limit.
return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
};
const pairingIdFromRequest = (req) => {
const raw = typeof req.body?.pairingId === 'string' ? req.body.pairingId.trim() : '';
return raw || 'missing';
};
const checkPairingRedeemRateLimit = (req) => {
const now = Date.now();
const key = `${requestIp(req)}:${pairingIdFromRequest(req)}`;
for (const [entryKey, entry] of pairingRedeemAttempts.entries()) {
if (!entry || now - entry.firstAttemptAt >= PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) {
pairingRedeemAttempts.delete(entryKey);
}
}
const entry = pairingRedeemAttempts.get(key);
if (!entry) {
pairingRedeemAttempts.set(key, { count: 1, firstAttemptAt: now });
return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - 1, reset: Math.ceil((now + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000) };
}
const reset = Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS) / 1000);
if (entry.count >= PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS) {
return {
allowed: false,
remaining: 0,
reset,
retryAfter: Math.max(1, Math.ceil((entry.firstAttemptAt + PAIRING_REDEEM_RATE_LIMIT_WINDOW_MS - now) / 1000)),
};
}
entry.count += 1;
return { allowed: true, remaining: PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS - entry.count, reset };
};
const clearPairingRedeemRateLimit = (req) => {
pairingRedeemAttempts.delete(`${requestIp(req)}:${pairingIdFromRequest(req)}`);
};
const normalizeCandidateUrl = (value) => {
if (typeof value !== 'string' || !value.trim()) return null;
try {
const parsed = new URL(value.trim());
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
parsed.hash = '';
parsed.search = '';
return parsed.toString().replace(/\/+$/, '');
} catch {
return null;
}
};
// `preferredServerUrl` is the caller-supplied externally reachable URL (the
// desktop UI reaches its own server over loopback, so the request origin is not
// scannable — it passes the LAN URL instead). Falls back to the request origin
// for remote callers where the Host header IS the reachable address.
//
// `includeRelay` is the per-link transport choice from the create-link dialog:
// true → add the relay candidate, enabling the relay host on demand;
// false → direct only, never relay;
// undefined → legacy: advertise relay only if it is already enabled.
// `includeDirect === false` produces a relay-only link (no direct candidate).
const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => {
const candidates = [];
if (includeDirect) {
const direct = normalizeCandidateUrl(preferredServerUrl) || requestOrigin(req);
if (direct) {
let type = 'lan';
try {
const parsed = new URL(direct);
type = parsed.protocol === 'https:' ? 'tunnel' : 'lan';
} catch {
}
candidates.push({ type, url: direct, priority: 10 });
}
}
// The client races candidates and falls back to relay only if the direct URL
// is unreachable (relay carries a higher priority number).
if (includeRelay !== false) {
try {
const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: includeRelay === true });
if (relayCandidate) candidates.push(relayCandidate);
} catch {
// A relay enable/status failure must not break direct pairing.
}
}
return candidates;
};
const sendPairingRedeemError = (res, error) => {
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 400;
res.status(statusCode).json({ error: 'Invalid or expired pairing session' });
};
const requireApiAuth = async (req, res, next) => {
// Preview proxy requests carry a target-scoped capability token that the
// preview proxy validates against the registered target id/TTL. Let those
@@ -588,7 +711,12 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const client = await clientRecordFromAuthContext(authContext);
return res.json({ clients: client ? [client] : [] });
// The desktop shell's local client is the trusted operator of this
// server; it manages devices just like a browser UI session. Every
// other client token is scoped to its own record.
if (client?.clientKind !== 'desktop-local') {
return res.json({ clients: client ? [client] : [] });
}
}
const clients = await remoteClientAuthRuntime.listClients();
res.json({ clients });
@@ -610,24 +738,136 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const clientId = clientIdFromAuthContext(authContext);
if (!clientId || clientId !== req.params?.id) {
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
const actingClient = await clientRecordFromAuthContext(authContext);
// The desktop shell's local client manages every device; other client
// tokens may only revoke themselves.
if (actingClient?.clientKind !== 'desktop-local') {
const clientId = clientIdFromAuthContext(authContext);
if (!clientId || clientId !== req.params?.id) {
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
}
}
}
const result = await remoteClientAuthRuntime.revokeClient(req.params?.id);
if (!result.revoked) {
return res.status(404).json({ revoked: false, error: 'Client not found' });
}
void reconcileRelay();
res.json(result);
});
});
app.delete('/api/client-auth/clients', async (req, res, next) => {
await runWithUiAuth(req, res, next, async () => {
await runWithClientManagementAuth(req, res, next, async (authContext) => {
if (authContext.type === 'client') {
const actingClient = await clientRecordFromAuthContext(authContext);
// Purging revoked devices is a whole-server management action; only the
// trusted desktop shell client (or a UI session) may do it.
if (actingClient?.clientKind !== 'desktop-local') {
return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' });
}
}
const result = await remoteClientAuthRuntime.purgeRevokedClients();
void reconcileRelay();
res.json(result);
}, { sessionOnly: true });
});
});
app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async (authContext) => {
const candidates = await pairingServerCandidates(req, {
preferredServerUrl: req.body?.serverUrl,
includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
includeDirect: req.body?.includeDirect !== false,
});
const usesRelay = candidates.some((candidate) => candidate.type === 'relay');
const result = await clientPairingRuntime.createPairingSession({
label: req.body?.label,
allowedClientKinds: req.body?.allowedClientKinds,
createdByClientId: clientIdFromAuthContext(authContext),
usesRelay,
});
void reconcileRelay();
res.setHeader('Cache-Control', 'no-store');
res.status(201).json({
...result,
server: { label: getServerLabel(), candidates },
});
});
});
// Direct transports the server can be reached on (for the create-device dialog).
app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
res.setHeader('Cache-Control', 'no-store');
res.json(getPairingTransports());
});
});
// Pending pairing sessions (link created, device not yet connected) for the
// "pending devices" list. Secrets are never included.
app.get('/api/client-auth/pairing/sessions', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
const pending = await clientPairingRuntime.listPendingSessions();
res.setHeader('Cache-Control', 'no-store');
res.json({ pending });
});
});
app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
const result = await clientPairingRuntime.cancelPairingSession(req.params?.id);
if (!result.cancelled) {
return res.status(404).json({ cancelled: false, error: 'Pairing session not found' });
}
void reconcileRelay();
res.json(result);
});
});
app.post('/api/client-auth/pairing/redeem', express.json({ limit: '64kb' }), async (req, res, next) => {
try {
const rateLimit = checkPairingRedeemRateLimit(req);
res.setHeader('X-RateLimit-Limit', PAIRING_REDEEM_RATE_LIMIT_MAX_ATTEMPTS);
res.setHeader('X-RateLimit-Remaining', rateLimit.remaining);
res.setHeader('X-RateLimit-Reset', rateLimit.reset);
if (!rateLimit.allowed) {
res.setHeader('Retry-After', rateLimit.retryAfter);
return res.status(429).json({ error: 'Invalid or expired pairing session' });
}
const result = await clientPairingRuntime.redeemPairingSession({
pairingId: req.body?.pairingId,
secret: req.body?.secret,
clientLabel: req.body?.clientLabel,
clientKind: req.body?.clientKind,
deviceName: req.body?.deviceName,
devicePlatform: req.body?.devicePlatform,
deviceModel: req.body?.deviceModel,
appVersion: req.body?.appVersion,
dedupeKey: req.body?.dedupeKey,
});
clearPairingRedeemRateLimit(req);
// The session became a device: relay demand may have moved from the pending
// session to the paired device (or a non-relay redeem may drop it).
void reconcileRelay();
res.setHeader('Cache-Control', 'no-store');
res.json({
ok: true,
server: {
label: getServerLabel(),
url: requestOrigin(req),
fingerprint: result.pairing?.fingerprint || null,
},
client: result.client,
clientToken: result.token,
});
} catch (error) {
if (error?.message === 'Invalid or expired pairing session') {
sendPairingRedeemError(res, error);
return;
}
next(error);
}
});
app.get('/connect', async (req, res) => {