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
-21
View File
@@ -1,21 +0,0 @@
// openchamber_relay_gate
//
// Feature gate for the private-relay UI — the surfaces for enabling the relay and
// pairing devices through it (Settings → Remote Instances "Relay" section and its
// settings-search entry). The relay transport itself is fully implemented and
// tested; this flag only hides the UI entry points until the feature is ready for
// public release (the connect flow is being unified across LAN / tunnels / relay).
//
// TO UNBLOCK FOR PUBLIC RELEASE: set RELAY_UI_ENABLED to true. Grep this token —
// `openchamber_relay_gate` — to find this file. Nothing else needs to change; the
// gated surfaces read this one constant. Also add a CHANGELOG entry then — the
// relay's changelog note is intentionally held back while this is off.
//
// Note: existing saved relay connections keep working regardless (this gates the
// UI for ADDING/pairing, not the runtime transport). If you also want to hide the
// mobile side of importing a relay link, gate the relay branch in
// packages/ui/src/apps/mobileQrScan.ts / mobileConnections.ts on this same flag.
// Typed as boolean (not the literal `false`) so gated call sites don't trip
// "condition always false" / unreachable-code checks — flipping to true is a
// one-word change with no other edits.
export const RELAY_UI_ENABLED: boolean = false;
-130
View File
@@ -1,130 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { buildRelayOfferUrl, parseRelayOfferUrl, redactOffer } from './offer';
import type { RelayOfferV1 } from './protocol';
const baseOffer: RelayOfferV1 = {
v: 1,
mode: 'relay',
relayUrl: 'wss://relay.example.com/host',
serverId: 'srv_0123456789abcdef',
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x-coordinate-b64u', y: 'y-coordinate-b64u' },
};
const fullOffer: RelayOfferV1 = {
...baseOffer,
label: 'My Mac',
token: 'oc_client_secret_token_value',
grant: 'grant-value',
};
describe('buildRelayOfferUrl / parseRelayOfferUrl', () => {
test('round-trips a minimal offer', () => {
expect(parseRelayOfferUrl(buildRelayOfferUrl(baseOffer))).toEqual(baseOffer);
});
test('round-trips a full offer with optional fields', () => {
expect(parseRelayOfferUrl(buildRelayOfferUrl(fullOffer))).toEqual(fullOffer);
});
test('URL has the expected shape', () => {
const url = buildRelayOfferUrl(baseOffer);
expect(url.startsWith('openchamber://connect?v=1&mode=relay#offer=')).toBe(true);
});
test('token appears only in the fragment, never in the query string', () => {
const url = buildRelayOfferUrl(fullOffer);
const [beforeFragment, fragment] = url.split('#');
expect(beforeFragment).toBe('openchamber://connect?v=1&mode=relay');
expect(beforeFragment.includes(fullOffer.token as string)).toBe(false);
expect(fragment.startsWith('offer=')).toBe(true);
// Token round-trips through the fragment payload.
expect(parseRelayOfferUrl(url)?.token).toBe(fullOffer.token as string);
});
const encodeOffer = (value: unknown): string => {
const json = JSON.stringify(value);
const b64 = Buffer.from(json, 'utf8').toString('base64url');
return `openchamber://connect?v=1&mode=relay#offer=${b64}`;
};
test('rejects wrong scheme, host, version, and mode', () => {
const url = buildRelayOfferUrl(baseOffer);
expect(parseRelayOfferUrl(url.replace('openchamber://', 'https://'))).toBeNull();
expect(parseRelayOfferUrl(url.replace('//connect', '//pair'))).toBeNull();
expect(parseRelayOfferUrl(url.replace('v=1', 'v=2'))).toBeNull();
expect(parseRelayOfferUrl(url.replace('mode=relay', 'mode=lan'))).toBeNull();
expect(parseRelayOfferUrl('not a url')).toBeNull();
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay')).toBeNull();
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=')).toBeNull();
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=!!not-b64url!!')).toBeNull();
});
const without = (key: keyof RelayOfferV1): Record<string, unknown> => {
const clone: Record<string, unknown> = { ...fullOffer };
delete clone[key];
return clone;
};
test('rejects wholly when any required field is missing or malformed', () => {
const cases: unknown[] = [
{ ...fullOffer, v: 2 },
without('v'),
{ ...fullOffer, mode: 'direct' },
without('mode'),
without('relayUrl'),
{ ...fullOffer, relayUrl: '' },
{ ...fullOffer, relayUrl: 'not-a-url' },
{ ...fullOffer, relayUrl: 'ftp://relay.example.com' },
without('serverId'),
{ ...fullOffer, serverId: '' },
{ ...fullOffer, serverId: 42 },
without('hostEncPubJwk'),
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, kty: 'RSA' } },
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, crv: 'P-384' } },
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', y: 'y' } },
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x' } },
{ ...fullOffer, hostEncPubJwk: 'jwk' },
{ ...fullOffer, label: '' },
{ ...fullOffer, token: '' },
{ ...fullOffer, token: 123 },
{ ...fullOffer, grant: '' },
['array'],
];
for (const payload of cases) {
expect(parseRelayOfferUrl(encodeOffer(payload))).toBeNull();
}
});
test('parse strips unknown fields', () => {
const parsed = parseRelayOfferUrl(encodeOffer({ ...baseOffer, extra: 'field' }));
expect(parsed).toEqual(baseOffer);
});
});
describe('redactOffer', () => {
test('masks token, grant, and host public key coordinates', () => {
const redacted = redactOffer(fullOffer);
expect(redacted.token).toBe('[redacted]');
expect(redacted.grant).toBe('[redacted]');
expect(redacted.hostEncPubJwk.x).toBe('[redacted]');
expect(redacted.hostEncPubJwk.y).toBe('[redacted]');
const serialized = JSON.stringify(redacted);
expect(serialized.includes(fullOffer.token as string)).toBe(false);
expect(serialized.includes(baseOffer.hostEncPubJwk.x as string)).toBe(false);
});
test('keeps non-secret fields and omits absent optionals', () => {
const redacted = redactOffer(baseOffer);
expect(redacted.relayUrl).toBe(baseOffer.relayUrl);
expect(redacted.serverId).toBe(baseOffer.serverId);
expect('token' in redacted).toBe(false);
expect('grant' in redacted).toBe(false);
});
test('does not mutate the input offer', () => {
const copy = structuredClone(fullOffer);
redactOffer(fullOffer);
expect(fullOffer).toEqual(copy);
});
});
-102
View File
@@ -1,102 +0,0 @@
// Relay pairing offer URL codec (spec §Pairing payload).
// The offer JSON travels ONLY in the URL fragment so secrets (token) never
// reach servers, logs, or referrer headers via the query string.
// Shared by: settings UI (build), mobile scan (parse), desktop host import
// (parse), CLI (build).
import { base64UrlToBytes, bytesToBase64Url } from './crypto';
import type { RelayOfferV1 } from './protocol';
const OFFER_SCHEME = 'openchamber:';
const OFFER_HOST = 'connect';
const OFFER_FRAGMENT_KEY = 'offer=';
const REDACTED = '[redacted]';
export const buildRelayOfferUrl = (offer: RelayOfferV1): string => {
const json = JSON.stringify(offer);
const encoded = bytesToBase64Url(new TextEncoder().encode(json));
return `openchamber://connect?v=1&mode=relay#${OFFER_FRAGMENT_KEY}${encoded}`;
};
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
const isValidHttpOrWsUrl = (value: string): boolean => {
try {
const parsed = new URL(value);
return parsed.protocol === 'wss:' || parsed.protocol === 'ws:' || parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
};
const parsePublicKeyJwk = (value: unknown): JsonWebKey | null => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const jwk = value as Record<string, unknown>;
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null;
if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null;
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
};
// Strict parse: every required field is validated; any malformed or missing
// field rejects the whole offer (returns null, never a partial object).
export const parseRelayOfferUrl = (url: string): RelayOfferV1 | null => {
let parsed: URL;
try {
parsed = new URL(url.trim());
} catch {
return null;
}
if (parsed.protocol !== OFFER_SCHEME) return null;
// Custom-scheme URLs may surface the authority as hostname or pathname
// depending on the runtime's parser.
const authority = parsed.hostname || parsed.pathname.replace(/^\/*/, '').split(/[/?#]/)[0];
if (authority !== OFFER_HOST) return null;
if (parsed.searchParams.get('v') !== '1') return null;
if (parsed.searchParams.get('mode') !== 'relay') return null;
const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
if (!fragment.startsWith(OFFER_FRAGMENT_KEY)) return null;
const encoded = fragment.slice(OFFER_FRAGMENT_KEY.length);
if (!encoded) return null;
let raw: unknown;
try {
raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(encoded)));
} catch {
return null;
}
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
const candidate = raw as Record<string, unknown>;
if (candidate.v !== 1) return null;
if (candidate.mode !== 'relay') return null;
if (!isNonEmptyString(candidate.relayUrl) || !isValidHttpOrWsUrl(candidate.relayUrl)) return null;
if (!isNonEmptyString(candidate.serverId)) return null;
const hostEncPubJwk = parsePublicKeyJwk(candidate.hostEncPubJwk);
if (!hostEncPubJwk) return null;
if (candidate.label !== undefined && !isNonEmptyString(candidate.label)) return null;
if (candidate.token !== undefined && !isNonEmptyString(candidate.token)) return null;
if (candidate.grant !== undefined && !isNonEmptyString(candidate.grant)) return null;
return {
v: 1,
mode: 'relay',
relayUrl: candidate.relayUrl,
serverId: candidate.serverId,
hostEncPubJwk,
...(candidate.label !== undefined ? { label: candidate.label } : {}),
...(candidate.token !== undefined ? { token: candidate.token } : {}),
...(candidate.grant !== undefined ? { grant: candidate.grant } : {}),
};
};
// Safe-for-logging copy: masks the access token and the host public key
// coordinates. Never log a raw offer.
export const redactOffer = (offer: RelayOfferV1): RelayOfferV1 => ({
...offer,
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: REDACTED, y: REDACTED },
...(offer.token !== undefined ? { token: REDACTED } : {}),
...(offer.grant !== undefined ? { grant: REDACTED } : {}),
});
-11
View File
@@ -126,14 +126,3 @@ export const RelayCloseCode = {
ChannelFailure: 1011,
} as const;
// Pairing payload carried in QR / deep-link URL fragments only.
export interface RelayOfferV1 {
v: 1;
mode: 'relay';
relayUrl: string;
serverId: string;
hostEncPubJwk: JsonWebKey;
label?: string;
token?: string;
grant?: string;
}