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:
@@ -1,58 +1,213 @@
|
||||
export type ClientConnectionPayload = {
|
||||
v: 1;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
const MAX_PAIRING_PAYLOAD_LENGTH = 16_384;
|
||||
|
||||
// A pairing candidate is one way to reach the host's HTTP API. `type`
|
||||
// discriminates the transport:
|
||||
// - lan / tunnel: reach `url` directly (health-check, then redeem over fetch).
|
||||
// - relay: no reachable URL — open the E2EE relay tunnel to `serverId` via
|
||||
// `relayUrl`, trusting `hostEncPubJwk`, then redeem over the tunnel.
|
||||
// The one-time pairing `secret` (payload level) is the single auth credential,
|
||||
// redeemed over whichever transport connects first. Relay carries no embedded
|
||||
// bearer token — that is the v1 sin this format replaces.
|
||||
export type PairingDirectCandidate = {
|
||||
type: 'lan' | 'tunnel';
|
||||
url: string;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type PairingRelayCandidate = {
|
||||
type: 'relay';
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
// One-time relay-infrastructure authorization. Reserved: the v1 relay worker
|
||||
// ignores it (E2EE + the pairing secret are the actual gates). Plumbed for
|
||||
// future relay-side per-device/traffic control. Never persisted.
|
||||
grant?: string;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type PairingEndpointCandidate = PairingDirectCandidate | PairingRelayCandidate;
|
||||
|
||||
export type PairingConnectionPayload = {
|
||||
v: 2;
|
||||
pairingId: string;
|
||||
secret: string;
|
||||
label?: string;
|
||||
fingerprint?: string;
|
||||
expiresAt?: string;
|
||||
candidates: PairingEndpointCandidate[];
|
||||
};
|
||||
|
||||
export const buildClientConnectionPayload = (input: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
label?: string | null;
|
||||
}): ClientConnectionPayload => ({
|
||||
v: 1,
|
||||
serverUrl: input.serverUrl.trim().replace(/\/+$/, ''),
|
||||
token: input.token.trim(),
|
||||
...(input.label?.trim() ? { label: input.label.trim() } : {}),
|
||||
});
|
||||
|
||||
export const encodeClientConnectionPayload = (payload: ClientConnectionPayload): string => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', String(payload.v));
|
||||
params.set('server', payload.serverUrl);
|
||||
params.set('token', payload.token);
|
||||
if (payload.label) params.set('label', payload.label);
|
||||
return `openchamber://connect?${params.toString()}`;
|
||||
const globalWithBuffer = globalThis as typeof globalThis & {
|
||||
Buffer?: {
|
||||
from: (value: string, encoding?: string) => { toString: (encoding: string) => string };
|
||||
};
|
||||
};
|
||||
|
||||
export const parseClientConnectionPayload = (value: string): ClientConnectionPayload | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const base64UrlEncode = (value: string): string => {
|
||||
if (globalWithBuffer.Buffer) {
|
||||
return globalWithBuffer.Buffer.from(value, 'utf8').toString('base64url');
|
||||
}
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.slice(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
};
|
||||
|
||||
const base64UrlDecode = (value: string): string | null => {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') {
|
||||
return null;
|
||||
if (globalWithBuffer.Buffer) {
|
||||
return globalWithBuffer.Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
const version = url.searchParams.get('v');
|
||||
const serverUrl = url.searchParams.get('server')?.trim() || '';
|
||||
const token = url.searchParams.get('token')?.trim() || '';
|
||||
const label = url.searchParams.get('label')?.trim() || '';
|
||||
|
||||
if (version !== '1' || !serverUrl || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedServer = new URL(serverUrl);
|
||||
if (parsedServer.protocol !== 'http:' && parsedServer.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildClientConnectionPayload({ serverUrl, token, label });
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHttpUrl = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/g, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Relay endpoints are WebSocket URLs and keep their path (e.g. `/ws`, `/tunnel`),
|
||||
// so only the fragment is stripped — never the trailing path segment.
|
||||
const normalizeWsUrl = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return null;
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||
|
||||
// EC P-256 public JWK (the relay E2EE trust anchor). Strict: only the four
|
||||
// public-key members are retained; a private `d` or any other member is dropped.
|
||||
const normalizeEcPublicJwk = (value: unknown): JsonWebKey | null => {
|
||||
if (!value || typeof value !== 'object' || 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 };
|
||||
};
|
||||
|
||||
const normalizePriority = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const normalizePairingCandidate = (value: unknown): PairingEndpointCandidate | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const priority = normalizePriority(record.priority);
|
||||
|
||||
if (record.type === 'lan' || record.type === 'tunnel') {
|
||||
const url = normalizeHttpUrl(record.url);
|
||||
if (!url) return null;
|
||||
return priority === undefined ? { type: record.type, url } : { type: record.type, url, priority };
|
||||
}
|
||||
|
||||
if (record.type === 'relay') {
|
||||
const relayUrl = normalizeWsUrl(record.relayUrl);
|
||||
if (!relayUrl) return null;
|
||||
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : '';
|
||||
if (!serverId) return null;
|
||||
const hostEncPubJwk = normalizeEcPublicJwk(record.hostEncPubJwk);
|
||||
if (!hostEncPubJwk) return null;
|
||||
const grant = typeof record.grant === 'string' && record.grant.trim() ? record.grant.trim() : undefined;
|
||||
return {
|
||||
type: 'relay',
|
||||
relayUrl,
|
||||
serverId,
|
||||
hostEncPubJwk,
|
||||
...(grant ? { grant } : {}),
|
||||
...(priority === undefined ? {} : { priority }),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizePairingPayload = (value: unknown): PairingConnectionPayload | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.v !== 2) return null;
|
||||
const pairingId = typeof record.pairingId === 'string' ? record.pairingId.trim() : '';
|
||||
const secret = typeof record.secret === 'string' ? record.secret.trim() : '';
|
||||
if (!pairingId || !secret) return null;
|
||||
const candidates = Array.isArray(record.candidates)
|
||||
? record.candidates.map(normalizePairingCandidate).filter((candidate): candidate is PairingEndpointCandidate => Boolean(candidate))
|
||||
: [];
|
||||
if (candidates.length === 0) return null;
|
||||
const expiresAt = typeof record.expiresAt === 'string' && record.expiresAt.trim() ? record.expiresAt.trim() : undefined;
|
||||
if (expiresAt) {
|
||||
const expiresTime = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(expiresTime) || expiresTime <= Date.now()) return null;
|
||||
}
|
||||
const label = typeof record.label === 'string' && record.label.trim() ? record.label.trim() : undefined;
|
||||
const fingerprint = typeof record.fingerprint === 'string' && record.fingerprint.trim() ? record.fingerprint.trim() : undefined;
|
||||
return {
|
||||
v: 2,
|
||||
pairingId,
|
||||
secret,
|
||||
...(label ? { label } : {}),
|
||||
...(fingerprint ? { fingerprint } : {}),
|
||||
...(expiresAt ? { expiresAt } : {}),
|
||||
candidates,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPairingConnectionPayload = (input: Omit<PairingConnectionPayload, 'v'>): PairingConnectionPayload => ({
|
||||
v: 2,
|
||||
pairingId: input.pairingId.trim(),
|
||||
secret: input.secret.trim(),
|
||||
...(input.label?.trim() ? { label: input.label.trim() } : {}),
|
||||
...(input.fingerprint?.trim() ? { fingerprint: input.fingerprint.trim() } : {}),
|
||||
...(input.expiresAt?.trim() ? { expiresAt: input.expiresAt.trim() } : {}),
|
||||
candidates: input.candidates,
|
||||
});
|
||||
|
||||
export const encodePairingConnectionPayload = (payload: PairingConnectionPayload): string => {
|
||||
const normalized = normalizePairingPayload(payload);
|
||||
if (!normalized) throw new Error('Invalid pairing connection payload');
|
||||
const params = new URLSearchParams();
|
||||
params.set('v', '2');
|
||||
params.set('p', base64UrlEncode(JSON.stringify(normalized)));
|
||||
return `openchamber://connect?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const parsePairingConnectionPayload = (value: string): PairingConnectionPayload | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') return null;
|
||||
if (url.searchParams.get('v') !== '2') return null;
|
||||
const encoded = url.searchParams.get('p') || '';
|
||||
if (!encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||
const decoded = base64UrlDecode(encoded);
|
||||
if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||
return normalizePairingPayload(JSON.parse(decoded) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user