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
+53
View File
@@ -1108,6 +1108,21 @@ export interface RemoteClientRecord {
revokedAt: string | null;
expiresAt?: string | null;
clientKind?: string | null;
authMethod?: string | null;
deviceName?: string | null;
devicePlatform?: string | null;
usesRelay?: boolean;
/** Transport that carried the device's most recent authenticated request. */
lastTransport?: 'relay' | 'direct' | null;
}
// A pairing link that has been created but not yet redeemed by a device.
export interface PendingPairingRecord {
id: string;
label?: string;
fingerprint?: string | null;
expiresAt?: string;
usesRelay?: boolean;
}
export interface RemoteClientCreateResult {
@@ -1124,11 +1139,49 @@ export interface RemoteClientPurgeRevokedResult {
purged: number;
}
export interface PairingSessionCreateResult {
pairing: {
id: string;
label?: string;
fingerprint?: string | null;
expiresAt?: string;
secret: string;
};
server: {
label: string;
// Transport candidates for the pairing-v2 payload. Shape matches
// PairingEndpointCandidate in `@/lib/connectionPayload` (direct lan/tunnel or
// relay); left as a structural type here so this contract file stays leaf.
candidates: Array<Record<string, unknown>>;
};
}
export interface ClientAuthAPI {
listClients(): Promise<RemoteClientRecord[]>;
createClient(input?: { label?: string }): Promise<RemoteClientCreateResult>;
// Creates a one-time pairing session (pairing v2). `serverUrl` is the
// externally reachable URL to advertise as the direct candidate (the desktop
// UI talks to its server over loopback, so it must supply the LAN URL); the
// server folds in a relay candidate when its relay host is enabled.
createPairingSession(input?: {
label?: string;
allowedClientKinds?: Array<'mobile' | 'desktop'>;
serverUrl?: string;
// Per-link transport choice. `includeRelay: true` adds the relay candidate
// and enables the relay host on demand; `false` omits it; omitted keeps the
// legacy "relay only if already enabled" behavior. `includeDirect: false`
// produces a relay-only link (no direct candidate).
includeRelay?: boolean;
includeDirect?: boolean;
}): Promise<PairingSessionCreateResult>;
purgeRevokedClients(): Promise<RemoteClientPurgeRevokedResult>;
revokeClient(id: string): Promise<RemoteClientRevokeResult>;
// Pairing links created but not yet redeemed (the "pending devices" list).
listPendingPairings(): Promise<PendingPairingRecord[]>;
cancelPairing(id: string): Promise<{ cancelled: boolean }>;
// Direct transports the server can be reached on, for the create-device dialog.
// LAN reflects the server's actual bind, independent of the UI origin.
getPairingTransports(): Promise<{ local: string | null; lan: string | null; relayAvailable: boolean }>;
}
export interface RuntimeAPIs {