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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user