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
@@ -25,6 +25,15 @@ const normalizeOptionalString = (value) => {
return trimmed.length > 0 ? trimmed : null;
};
const normalizeMetadata = (client) => ({
authMethod: normalizeOptionalString(client.authMethod),
pairingId: normalizeOptionalString(client.pairingId),
deviceName: normalizeOptionalString(client.deviceName),
devicePlatform: normalizeOptionalString(client.devicePlatform),
deviceModel: normalizeOptionalString(client.deviceModel),
appVersion: normalizeOptionalString(client.appVersion),
});
const safeJsonParse = (raw) => {
try {
return JSON.parse(raw);
@@ -77,6 +86,9 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
expiresAt: normalizeTimestamp(client.expiresAt),
clientKind: normalizeOptionalString(client.clientKind),
dedupeKey: normalizeOptionalString(client.dedupeKey),
usesRelay: client.usesRelay === true,
lastTransport: client.lastTransport === 'relay' || client.lastTransport === 'direct' ? client.lastTransport : null,
...normalizeMetadata(client),
}))
.filter((client) => client.tokenHash.length > 0)
: [],
@@ -108,6 +120,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
revokedAt: client.revokedAt,
expiresAt: client.expiresAt,
clientKind: client.clientKind,
authMethod: client.authMethod,
pairingId: client.pairingId,
deviceName: client.deviceName,
devicePlatform: client.devicePlatform,
deviceModel: client.deviceModel,
appVersion: client.appVersion,
usesRelay: client.usesRelay === true,
lastTransport: client.lastTransport ?? null,
});
const listClients = async () => {
@@ -117,7 +137,34 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
});
};
const createClient = async ({ label, expiresAt, clientKind, dedupeKey } = {}) => {
// Relay-transport demand from paired devices: any non-revoked, non-expired
// client that was paired over the relay.
const hasActiveRelayClients = async () => {
return withStoreMutation(async () => {
const store = await readStore();
const now = Date.now();
return store.clients.some((client) => {
if (client.usesRelay !== true) return false;
if (client.revokedAt) return false;
const expires = Date.parse(client.expiresAt || '');
return !Number.isFinite(expires) || expires > now;
});
});
};
const createClient = async ({
label,
expiresAt,
clientKind,
dedupeKey,
authMethod,
pairingId,
deviceName,
devicePlatform,
deviceModel,
appVersion,
usesRelay,
} = {}) => {
return withStoreMutation(async () => {
const store = await readStore();
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
@@ -132,6 +179,13 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
expiresAt: normalizeTimestamp(expiresAt),
clientKind: normalizeOptionalString(clientKind),
dedupeKey: normalizedDedupeKey,
authMethod: normalizeOptionalString(authMethod),
pairingId: normalizeOptionalString(pairingId),
deviceName: normalizeOptionalString(deviceName),
devicePlatform: normalizeOptionalString(devicePlatform),
deviceModel: normalizeOptionalString(deviceModel),
appVersion: normalizeOptionalString(appVersion),
usesRelay: usesRelay === true,
};
if (normalizedDedupeKey) {
store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey);
@@ -177,10 +231,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
});
};
const authenticateBearerToken = async (token) => {
const authenticateBearerToken = async (token, req) => {
if (typeof token !== 'string' || !token.startsWith(TOKEN_PREFIX)) {
return null;
}
// Which transport carried this request: the relay tunnel proxy stamps every
// forwarded request with x-openchamber-relay-connection; anything else is a
// direct (local/LAN/tunnel-URL) request. Display-only device metadata.
const transport = req?.headers?.['x-openchamber-relay-connection'] ? 'relay' : 'direct';
return withStoreMutation(async () => {
const tokenHash = hashToken(token);
const store = await readStore();
@@ -189,8 +247,11 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
if (client.expiresAt && Date.parse(client.expiresAt) <= Date.now()) return null;
const now = Date.now();
const lastUsedAt = Date.parse(client.lastUsedAt || '');
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS) {
// Write on the throttle interval — or immediately when the transport
// changed, so a LAN⇄relay switch is visible right away, not a minute late.
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= LAST_USED_WRITE_INTERVAL_MS || client.lastTransport !== transport) {
client.lastUsedAt = new Date(now).toISOString();
client.lastTransport = transport;
await writeStore(store);
}
return { ok: true, clientId: client.id, sessionToken: client.id, client: publicClient(client) };
@@ -201,6 +262,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
authenticateBearerToken,
createClient,
listClients,
hasActiveRelayClients,
purgeRevokedClients,
revokeClient,
};