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,308 @@
|
||||
const STORE_VERSION = 1;
|
||||
const PAIRING_ID_PREFIX = 'pair_';
|
||||
const SECRET_BYTES = 32;
|
||||
const FINGERPRINT_BYTES = 4;
|
||||
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_LABEL_LENGTH = 80;
|
||||
const VALID_CLIENT_KINDS = new Set(['mobile', 'desktop']);
|
||||
const GENERIC_REDEEM_ERROR = 'Invalid or expired pairing session';
|
||||
|
||||
const normalizeOptionalString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
// Placeholder shown in the pending-devices list when the operator did not type a
|
||||
// name. It is a DISPLAY default only — the stored label stays null so redeem can
|
||||
// fall back to the device's own reported name instead of this placeholder.
|
||||
const PAIRING_LABEL_PLACEHOLDER = 'Pair new device';
|
||||
|
||||
// The operator's typed device label, capped. Returns null when unset so callers
|
||||
// can distinguish "no name given" from a real name.
|
||||
const normalizeStoredLabel = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) return null;
|
||||
return normalized.length > MAX_LABEL_LENGTH ? normalized.slice(0, MAX_LABEL_LENGTH) : normalized;
|
||||
};
|
||||
|
||||
const normalizeTimestamp = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) return null;
|
||||
const time = Date.parse(normalized);
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
||||
};
|
||||
|
||||
const normalizeClientKind = (value) => {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized && VALID_CLIENT_KINDS.has(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
const normalizeAllowedClientKinds = (value) => {
|
||||
if (!Array.isArray(value)) return ['mobile', 'desktop'];
|
||||
const kinds = value.map(normalizeClientKind).filter(Boolean);
|
||||
return kinds.length > 0 ? Array.from(new Set(kinds)) : ['mobile', 'desktop'];
|
||||
};
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const constantTimeEqual = (left, right, crypto) => {
|
||||
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
||||
const leftBuffer = Buffer.from(left, 'hex');
|
||||
const rightBuffer = Buffer.from(right, 'hex');
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
const publicSession = (session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
usedAt: session.usedAt,
|
||||
cancelledAt: session.cancelledAt,
|
||||
clientId: session.clientId,
|
||||
label: session.label || PAIRING_LABEL_PLACEHOLDER,
|
||||
fingerprint: session.fingerprint,
|
||||
allowedClientKinds: session.allowedClientKinds,
|
||||
createdByClientId: session.createdByClientId,
|
||||
usesRelay: session.usesRelay === true,
|
||||
});
|
||||
|
||||
// A pending session is one that can still be redeemed: not used, not cancelled,
|
||||
// not expired.
|
||||
const isPendingSession = (session) => !session.usedAt
|
||||
&& !session.cancelledAt
|
||||
&& Number.isFinite(Date.parse(session.expiresAt))
|
||||
&& Date.parse(session.expiresAt) > Date.now();
|
||||
|
||||
const redeemError = () => {
|
||||
const error = new Error(GENERIC_REDEEM_ERROR);
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
};
|
||||
|
||||
export const createClientPairingRuntime = ({
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
storePath,
|
||||
remoteClientAuthRuntime,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
} = {}) => {
|
||||
if (!fsPromises || !path || !crypto || !storePath || !remoteClientAuthRuntime) {
|
||||
throw new Error('createClientPairingRuntime requires fsPromises, path, crypto, storePath, and remoteClientAuthRuntime');
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const hashSecret = (secret) => crypto.createHash('sha256').update(secret).digest('hex');
|
||||
const generateId = () => `${PAIRING_ID_PREFIX}${crypto.randomBytes(12).toString('hex')}`;
|
||||
const generateSecret = () => crypto.randomBytes(SECRET_BYTES).toString('base64url');
|
||||
const generateFingerprint = () => crypto.randomBytes(FINGERPRINT_BYTES).toString('hex').toUpperCase().replace(/^(.{4})(.{4})$/, '$1-$2');
|
||||
let storeMutationQueue = Promise.resolve();
|
||||
|
||||
const withStoreMutation = async (fn) => {
|
||||
const previous = storeMutationQueue;
|
||||
let release;
|
||||
storeMutationQueue = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeStore = (payload) => ({
|
||||
version: STORE_VERSION,
|
||||
sessions: Array.isArray(payload?.sessions)
|
||||
? payload.sessions
|
||||
.filter((session) => session && typeof session === 'object')
|
||||
.map((session) => ({
|
||||
id: typeof session.id === 'string' ? session.id : generateId(),
|
||||
secretHash: typeof session.secretHash === 'string' ? session.secretHash : '',
|
||||
createdAt: typeof session.createdAt === 'string' ? session.createdAt : nowIso(),
|
||||
expiresAt: normalizeTimestamp(session.expiresAt) || new Date(Date.now() + ttlMs).toISOString(),
|
||||
usedAt: normalizeTimestamp(session.usedAt),
|
||||
cancelledAt: normalizeTimestamp(session.cancelledAt),
|
||||
clientId: normalizeOptionalString(session.clientId),
|
||||
label: normalizeStoredLabel(session.label),
|
||||
fingerprint: normalizeOptionalString(session.fingerprint) || generateFingerprint(),
|
||||
allowedClientKinds: normalizeAllowedClientKinds(session.allowedClientKinds),
|
||||
createdByClientId: normalizeOptionalString(session.createdByClientId),
|
||||
usesRelay: session.usesRelay === true,
|
||||
}))
|
||||
.filter((session) => session.secretHash.length > 0)
|
||||
: [],
|
||||
});
|
||||
|
||||
const readStore = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(storePath, 'utf8');
|
||||
return normalizeStore(safeJsonParse(raw));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return normalizeStore(null);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStore = async (store) => {
|
||||
await fsPromises.mkdir(path.dirname(storePath), { recursive: true, mode: 0o700 });
|
||||
await fsPromises.writeFile(storePath, JSON.stringify(normalizeStore(store), null, 2), { mode: 0o600 });
|
||||
if (typeof fsPromises.chmod === 'function') {
|
||||
await fsPromises.chmod(storePath, 0o600).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const sweepExpiredSessionsFromStore = (store) => {
|
||||
const now = Date.now();
|
||||
const cutoff = now - ttlMs;
|
||||
store.sessions = store.sessions.filter((session) => {
|
||||
const usedAt = Date.parse(session.usedAt || '');
|
||||
const cancelledAt = Date.parse(session.cancelledAt || '');
|
||||
const inactiveAt = Number.isFinite(usedAt) ? usedAt : cancelledAt;
|
||||
if (Number.isFinite(inactiveAt)) return inactiveAt >= cutoff;
|
||||
// Never used or cancelled: drop once the session itself has expired —
|
||||
// it can no longer be redeemed and would otherwise sit in the store forever.
|
||||
const expiresAt = Date.parse(session.expiresAt || '');
|
||||
return !Number.isFinite(expiresAt) || expiresAt > now;
|
||||
});
|
||||
};
|
||||
|
||||
const createPairingSession = async ({ label, allowedClientKinds, createdByClientId, usesRelay } = {}) => {
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
sweepExpiredSessionsFromStore(store);
|
||||
const secret = generateSecret();
|
||||
const session = {
|
||||
id: generateId(),
|
||||
secretHash: hashSecret(secret),
|
||||
createdAt: nowIso(),
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
usedAt: null,
|
||||
cancelledAt: null,
|
||||
clientId: null,
|
||||
label: normalizeStoredLabel(label),
|
||||
fingerprint: generateFingerprint(),
|
||||
allowedClientKinds: normalizeAllowedClientKinds(allowedClientKinds),
|
||||
createdByClientId: normalizeOptionalString(createdByClientId),
|
||||
usesRelay: usesRelay === true,
|
||||
};
|
||||
store.sessions.push(session);
|
||||
await writeStore(store);
|
||||
return { pairing: { ...publicSession(session), secret } };
|
||||
});
|
||||
};
|
||||
|
||||
// Sessions that can still be redeemed (link created, device not yet connected).
|
||||
const listPendingSessions = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.sessions.filter(isPendingSession).map(publicSession);
|
||||
});
|
||||
|
||||
// Relay-transport demand from pairing: any still-redeemable relay session.
|
||||
const hasActiveRelaySession = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
return store.sessions.some((session) => session.usesRelay === true && isPendingSession(session));
|
||||
});
|
||||
|
||||
const getPairingSession = async (id) => {
|
||||
const normalizedId = normalizeOptionalString(id);
|
||||
if (!normalizedId) return null;
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
return session ? publicSession(session) : null;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelPairingSession = async (id) => {
|
||||
const normalizedId = normalizeOptionalString(id);
|
||||
if (!normalizedId) return { cancelled: false };
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
if (!session) return { cancelled: false };
|
||||
if (!session.cancelledAt) session.cancelledAt = nowIso();
|
||||
await writeStore(store);
|
||||
return { cancelled: true, pairing: publicSession(session) };
|
||||
});
|
||||
};
|
||||
|
||||
const redeemPairingSession = async ({
|
||||
pairingId,
|
||||
secret,
|
||||
clientLabel,
|
||||
clientKind,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
dedupeKey,
|
||||
} = {}) => {
|
||||
const normalizedId = normalizeOptionalString(pairingId);
|
||||
const normalizedSecret = normalizeOptionalString(secret);
|
||||
const normalizedKind = normalizeClientKind(clientKind) || 'mobile';
|
||||
if (!normalizedId || !normalizedSecret) throw redeemError();
|
||||
|
||||
return withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const session = store.sessions.find((entry) => entry.id === normalizedId);
|
||||
if (!session) throw redeemError();
|
||||
if (session.cancelledAt || session.usedAt) throw redeemError();
|
||||
if (Date.parse(session.expiresAt) <= Date.now()) throw redeemError();
|
||||
if (!session.allowedClientKinds.includes(normalizedKind)) throw redeemError();
|
||||
if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError();
|
||||
|
||||
// The operator's typed pairing label is THIS server's name for the device
|
||||
// (shown in the device list). It wins over the device's self-reported
|
||||
// label; fall back to that only when no pairing label was set.
|
||||
const label = normalizeOptionalString(session.label)
|
||||
|| normalizeOptionalString(clientLabel)
|
||||
|| normalizeOptionalString(deviceName)
|
||||
|| 'Remote client';
|
||||
const result = await remoteClientAuthRuntime.createClient({
|
||||
label,
|
||||
clientKind: normalizedKind,
|
||||
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
|
||||
authMethod: 'pairing',
|
||||
pairingId: session.id,
|
||||
deviceName,
|
||||
devicePlatform,
|
||||
deviceModel,
|
||||
appVersion,
|
||||
usesRelay: session.usesRelay === true,
|
||||
});
|
||||
session.usedAt = nowIso();
|
||||
session.clientId = result.client?.id || null;
|
||||
await writeStore(store);
|
||||
return { pairing: publicSession(session), client: result.client, token: result.token };
|
||||
});
|
||||
};
|
||||
|
||||
const sweepExpiredSessions = async () => withStoreMutation(async () => {
|
||||
const store = await readStore();
|
||||
const before = store.sessions.length;
|
||||
sweepExpiredSessionsFromStore(store);
|
||||
const purged = before - store.sessions.length;
|
||||
if (purged > 0) await writeStore(store);
|
||||
return { purged };
|
||||
});
|
||||
|
||||
return {
|
||||
createPairingSession,
|
||||
getPairingSession,
|
||||
listPendingSessions,
|
||||
hasActiveRelaySession,
|
||||
cancelPairingSession,
|
||||
redeemPairingSession,
|
||||
sweepExpiredSessions,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user