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:
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
||||
## Entrypoints and structure
|
||||
|
||||
Host side (`packages/web/server/lib/relay/`):
|
||||
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable,offer}`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing offer, and status, so paired clients inherit the endpoint automatically from the offer.
|
||||
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically.
|
||||
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
||||
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
||||
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
||||
@@ -32,7 +32,8 @@ Client side (`packages/ui/src/lib/relay/`):
|
||||
- `tunnel-codec.ts` — Layer 3 frame codec, fragmentation, and outbound frame batching.
|
||||
- `tunnel-client.ts` — the client tunnel: exposes a `fetch()`-compatible and a WebSocket-compatible surface backed by the encrypted tunnel.
|
||||
- `tunnel-payloads.ts`, `runtime-tunnel.ts`, `runtime-socket.ts` — payload helpers, the active-tunnel singleton, and the shared "open a runtime WebSocket the right way" helper.
|
||||
- `offer.ts` — the pairing payload builder/parser (secrets travel in URL fragments only).
|
||||
|
||||
Relay is not a separate link format: it is one transport candidate inside the unified **pairing v2** payload (`packages/ui/src/lib/connectionPayload.ts`). A relay candidate is `{ type: 'relay', relayUrl, serverId, hostEncPubJwk }` — no embedded token; the client redeems the one-time pairing secret over the tunnel like any other candidate.
|
||||
|
||||
## What travels the tunnel
|
||||
|
||||
@@ -52,7 +53,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
|
||||
|
||||
## End-to-end flow (overview)
|
||||
|
||||
1. **Pairing.** The host builds an offer describing the relay endpoint, its routing id, and its encryption public key, rendered as a QR code / deep link. Secrets are carried in the URL fragment so they never reach any server. The client imports it and stores the connection.
|
||||
1. **Pairing.** The host issues a pairing-v2 link (QR / deep link) carrying a one-time secret and a list of transport candidates. When the relay is enabled, one candidate is the relay transport (its endpoint, routing id, and encryption public key — the E2EE trust anchor). The client redeems the secret over the first reachable candidate; over the relay candidate it opens the E2EE tunnel first, then redeems through it, and stores the connection.
|
||||
2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits.
|
||||
3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection.
|
||||
4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read.
|
||||
|
||||
@@ -12,6 +12,14 @@ import { createTunnelHost } from './tunnel-host.js';
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_CAP_MS = 30000;
|
||||
const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
|
||||
// Clients send a tunnel Ping at least every ~30s when idle, so a data socket
|
||||
// with no inbound traffic for 3 ping intervals belongs to a client that died
|
||||
// without a WebSocket close (network loss, battery kill). The relay worker may
|
||||
// not notice the dead client leg for a long time, so the host must reap these
|
||||
// itself — both to free resources and to keep the "N devices connected" status
|
||||
// honest instead of counting ghosts.
|
||||
const DATA_SOCKET_IDLE_TIMEOUT_MS = 90_000;
|
||||
const DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
|
||||
// Resolve the frame-batching flush window: explicit option wins, then env, then
|
||||
@@ -103,7 +111,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null };
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null, lastActivityAt: Date.now() };
|
||||
dataSockets.set(connectionId, entry);
|
||||
entry.openTimer = setTimeout(() => {
|
||||
logger.warn('[Relay] host-data socket open timeout');
|
||||
@@ -141,6 +149,9 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
const current = dataSockets.get(connectionId);
|
||||
if (current !== entry) return;
|
||||
// Any inbound message (including the client's keepalive Ping) proves the
|
||||
// client is alive; the idle sweeper reaps sockets this stops updating.
|
||||
entry.lastActivityAt = Date.now();
|
||||
|
||||
if (!isBinary) {
|
||||
const action = await handshake.handleText(data.toString('utf8'));
|
||||
@@ -298,9 +309,22 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
||||
});
|
||||
};
|
||||
|
||||
// Reap data sockets whose client went silent (no frames, no keepalive pings)
|
||||
// — a dead phone leg the relay worker hasn't noticed yet.
|
||||
const idleSweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [connectionId, entry] of [...dataSockets.entries()]) {
|
||||
if (now - entry.lastActivityAt <= DATA_SOCKET_IDLE_TIMEOUT_MS) continue;
|
||||
logger.info(`[Relay] reaping idle data socket connectionId=${connectionId}`);
|
||||
teardownDataSocket(connectionId, 1001, 'client idle timeout');
|
||||
}
|
||||
}, DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS);
|
||||
if (typeof idleSweepTimer.unref === 'function') idleSweepTimer.unref();
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(idleSweepTimer);
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
|
||||
@@ -15,7 +15,6 @@ import express from 'express';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import { bytesToBase64Url } from './e2ee.js';
|
||||
|
||||
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
||||
|
||||
@@ -49,21 +48,20 @@ const envRelayUrlOverride = () => {
|
||||
/**
|
||||
* @param {{
|
||||
* crypto: typeof import('node:crypto'),
|
||||
* os: typeof import('node:os'),
|
||||
* readSettingsFromDiskMigrated: () => Promise<object>,
|
||||
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
||||
* remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> },
|
||||
* getLocalPort: () => number,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} deps
|
||||
*/
|
||||
export const createRelayService = ({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort,
|
||||
// Returns true when any paired device or pending pairing session uses the
|
||||
// relay transport. The relay lifecycle is driven purely by this demand.
|
||||
hasRelayDemand = async () => false,
|
||||
logger = console,
|
||||
}) => {
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
@@ -125,6 +123,28 @@ export const createRelayService = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Drive the relay lifecycle from demand: run it when a device or pending
|
||||
// session uses the relay, stop it when none remain. Called on startup and after
|
||||
// pairing/device changes, so the operator never toggles it manually.
|
||||
const reconcile = async () => {
|
||||
try {
|
||||
const demand = await hasRelayDemand();
|
||||
const config = await readConfig();
|
||||
if (demand) {
|
||||
if (!config.enabled) await writeConfig({ enabled: true, relayUrl: config.relayUrl });
|
||||
if (!hostClient) {
|
||||
const next = await readConfig();
|
||||
await start(next.relayUrl);
|
||||
}
|
||||
} else {
|
||||
if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl });
|
||||
stop();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] reconcile failed: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
@@ -140,29 +160,44 @@ export const createRelayService = ({
|
||||
};
|
||||
};
|
||||
|
||||
const buildOffer = async ({ includeToken = false, clientLabel } = {}) => {
|
||||
// Pairing candidate for the unified connection payload (pairing v2). Relay is
|
||||
// just another transport: it carries the relay route + E2EE trust anchor, no
|
||||
// embedded token — the client redeems the one-time pairing secret over the
|
||||
// tunnel like any other candidate. Returns null when the host relay is off, so
|
||||
// callers only advertise relay when it is actually reachable. Priority is high
|
||||
// (tried after LAN/tunnel) since the relay path is the last-resort transport.
|
||||
const buildPairingCandidate = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
return {
|
||||
type: 'relay',
|
||||
relayUrl: config.relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label: os.hostname(),
|
||||
priority: 30,
|
||||
};
|
||||
if (includeToken) {
|
||||
const label = typeof clientLabel === 'string' && clientLabel.trim().length > 0
|
||||
? clientLabel.trim()
|
||||
: 'Relay client';
|
||||
const { token } = await remoteClientAuthRuntime.createClient({ label, clientKind: 'relay' });
|
||||
offer.token = token;
|
||||
};
|
||||
|
||||
const getPairingCandidate = async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.enabled) return null;
|
||||
return buildPairingCandidate();
|
||||
};
|
||||
|
||||
// Enable the relay host on demand and return its pairing candidate. Creating a
|
||||
// relay pairing link IS the demand signal, so the relay turns itself on here
|
||||
// rather than requiring a separate manual toggle. Idempotent: a no-op when the
|
||||
// relay is already enabled and running.
|
||||
const ensureEnabledForPairing = async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.enabled) {
|
||||
await writeConfig({ enabled: true, relayUrl: config.relayUrl });
|
||||
}
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return {
|
||||
offer,
|
||||
url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`,
|
||||
};
|
||||
if (!hostClient) {
|
||||
const next = await readConfig();
|
||||
await start(next.relayUrl);
|
||||
}
|
||||
return buildPairingCandidate();
|
||||
};
|
||||
|
||||
const registerRoutes = (app) => {
|
||||
@@ -198,24 +233,15 @@ export const createRelayService = ({
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/offer', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const result = await buildOffer({
|
||||
includeToken: req.body?.includeToken === true,
|
||||
clientLabel: req.body?.clientLabel,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to build relay offer' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerRoutes,
|
||||
startIfEnabled,
|
||||
reconcile,
|
||||
stop,
|
||||
getStatus,
|
||||
buildOffer,
|
||||
getPairingCandidate,
|
||||
ensureEnabledForPairing,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user