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
215 lines
8.3 KiB
TypeScript
215 lines
8.3 KiB
TypeScript
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[];
|
|
};
|
|
|
|
const globalWithBuffer = globalThis as typeof globalThis & {
|
|
Buffer?: {
|
|
from: (value: string, encoding?: string) => { toString: (encoding: string) => string };
|
|
};
|
|
};
|
|
|
|
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 {
|
|
if (globalWithBuffer.Buffer) {
|
|
return globalWithBuffer.Buffer.from(value, 'base64url').toString('utf8');
|
|
}
|
|
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;
|
|
}
|
|
};
|