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:
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildPairingConnectionPayload,
|
||||
encodePairingConnectionPayload,
|
||||
parsePairingConnectionPayload,
|
||||
} from './connectionPayload';
|
||||
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
describe('connection payload helpers', () => {
|
||||
test('round-trips v2 pairing payloads with direct candidates', () => {
|
||||
const payload = buildPairingConnectionPayload({
|
||||
pairingId: 'pair_123',
|
||||
secret: 'one-time-secret',
|
||||
label: 'Desktop',
|
||||
fingerprint: 'ABCD-1234',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z',
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096/', priority: 20 },
|
||||
{ type: 'tunnel', url: 'https://runtime.example/', priority: 10 },
|
||||
],
|
||||
});
|
||||
|
||||
const encoded = encodePairingConnectionPayload(payload);
|
||||
|
||||
expect(encoded.startsWith('openchamber://connect?v=2&p=')).toBe(true);
|
||||
expect(parsePairingConnectionPayload(encoded)).toEqual({
|
||||
...payload,
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 20 },
|
||||
{ type: 'tunnel', url: 'https://runtime.example', priority: 10 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('round-trips a relay candidate (transport, not a URL)', () => {
|
||||
const payload = buildPairingConnectionPayload({
|
||||
pairingId: 'pair_relay',
|
||||
secret: 'one-time-secret',
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
|
||||
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 },
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = parsePairingConnectionPayload(encodePairingConnectionPayload(payload));
|
||||
expect(parsed?.candidates).toEqual([
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
|
||||
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('relay candidate keeps its path and rejects non-ws relay URLs / bad JWKs', () => {
|
||||
const withBadRelay = (candidate: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify({ v: 2, pairingId: 'pair_1', secret: 's', candidates: [candidate] })).toString('base64url');
|
||||
|
||||
// https relay URL is not a WebSocket endpoint → candidate dropped → no candidates → null.
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'https://relay.example/ws', serverId: 'srv', hostEncPubJwk })}`)).toBeNull();
|
||||
// Missing serverId.
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'wss://relay.example/ws', hostEncPubJwk })}`)).toBeNull();
|
||||
// Non-P-256 key.
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withBadRelay({ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk: { kty: 'EC', crv: 'P-384', x: 'a', y: 'b' } })}`)).toBeNull();
|
||||
});
|
||||
|
||||
test('drops a private-key member from a relay JWK (keeps only public coordinates)', () => {
|
||||
const withKey = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_1',
|
||||
secret: 's',
|
||||
candidates: [{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk: { ...hostEncPubJwk, d: 'PRIVATE' } }],
|
||||
})).toString('base64url');
|
||||
const parsed = parsePairingConnectionPayload(`openchamber://connect?v=2&p=${withKey}`);
|
||||
expect(parsed?.candidates[0]).toEqual({ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv', hostEncPubJwk });
|
||||
});
|
||||
|
||||
test('rejects invalid v2 pairing payloads', () => {
|
||||
expect(parsePairingConnectionPayload('openchamber://connect?v=1&server=https://runtime.example&token=t')).toBeNull();
|
||||
expect(parsePairingConnectionPayload('openchamber://connect?v=2&p=not-json')).toBeNull();
|
||||
|
||||
const missingSecret = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_123',
|
||||
candidates: [{ type: 'lan', url: 'http://runtime.example' }],
|
||||
})).toString('base64url');
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${missingSecret}`)).toBeNull();
|
||||
|
||||
const invalidCandidate = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_123',
|
||||
secret: 'secret',
|
||||
candidates: [{ type: 'lan', url: 'file:///tmp/socket' }],
|
||||
})).toString('base64url');
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${invalidCandidate}`)).toBeNull();
|
||||
|
||||
const expired = Buffer.from(JSON.stringify({
|
||||
v: 2,
|
||||
pairingId: 'pair_123',
|
||||
secret: 'secret',
|
||||
expiresAt: '2000-01-01T00:00:00.000Z',
|
||||
candidates: [{ type: 'lan', url: 'http://runtime.example' }],
|
||||
})).toString('base64url');
|
||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user