Files
openchamber/packages/web/server/lib/client-auth/pairing.test.js
T
Iuliia Ivashko 91a95bfdaa 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
2026-07-10 00:12:33 +03:00

143 lines
5.4 KiB
JavaScript

import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { createClientPairingRuntime } from './pairing.js';
const makeRuntime = async (options = {}) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-pairing-test-'));
const createdClients = [];
const remoteClientAuthRuntime = options.remoteClientAuthRuntime || {
createClient: vi.fn(async (input) => {
const client = {
id: `client-${createdClients.length + 1}`,
label: input.label,
clientKind: input.clientKind,
authMethod: input.authMethod,
pairingId: input.pairingId,
deviceName: input.deviceName ?? null,
};
createdClients.push(client);
return { client, token: `token-${createdClients.length}` };
}),
};
const runtime = createClientPairingRuntime({
fsPromises: fs,
path,
crypto,
storePath: path.join(dir, 'pairing.json'),
remoteClientAuthRuntime,
ttlMs: options.ttlMs ?? 10 * 60 * 1000,
});
return { dir, runtime, remoteClientAuthRuntime, createdClients };
};
describe('client auth pairing runtime', () => {
it('redeems a pairing session once and propagates client metadata', async () => {
const { runtime, remoteClientAuthRuntime } = await makeRuntime();
const created = await runtime.createPairingSession({ allowedClientKinds: ['mobile'] });
const result = await runtime.redeemPairingSession({
pairingId: created.pairing.id,
secret: created.pairing.secret,
clientLabel: 'Iryna iPhone',
clientKind: 'mobile',
deviceName: 'Iryna iPhone',
dedupeKey: 'device-key',
});
expect(result.token).toBe('token-1');
expect(result.client).toMatchObject({
label: 'Iryna iPhone',
clientKind: 'mobile',
authMethod: 'pairing',
pairingId: created.pairing.id,
deviceName: 'Iryna iPhone',
});
expect(remoteClientAuthRuntime.createClient).toHaveBeenCalledWith(expect.objectContaining({
authMethod: 'pairing',
pairingId: created.pairing.id,
clientKind: 'mobile',
dedupeKey: 'device-key',
}));
await expect(runtime.redeemPairingSession({
pairingId: created.pairing.id,
secret: created.pairing.secret,
clientKind: 'mobile',
})).rejects.toThrow('Invalid or expired pairing session');
});
it('rejects expired, cancelled, wrong-secret, and disallowed-kind redemption', async () => {
const { runtime: expiredRuntime } = await makeRuntime({ ttlMs: -1000 });
const expired = await expiredRuntime.createPairingSession();
await expect(expiredRuntime.redeemPairingSession({
pairingId: expired.pairing.id,
secret: expired.pairing.secret,
clientKind: 'mobile',
})).rejects.toThrow('Invalid or expired pairing session');
const { runtime } = await makeRuntime();
const cancelled = await runtime.createPairingSession();
await runtime.cancelPairingSession(cancelled.pairing.id);
await expect(runtime.redeemPairingSession({
pairingId: cancelled.pairing.id,
secret: cancelled.pairing.secret,
clientKind: 'mobile',
})).rejects.toThrow('Invalid or expired pairing session');
const wrongSecret = await runtime.createPairingSession();
await expect(runtime.redeemPairingSession({
pairingId: wrongSecret.pairing.id,
secret: 'wrong',
clientKind: 'mobile',
})).rejects.toThrow('Invalid or expired pairing session');
const desktopOnly = await runtime.createPairingSession({ allowedClientKinds: ['desktop'] });
await expect(runtime.redeemPairingSession({
pairingId: desktopOnly.pairing.id,
secret: desktopOnly.pairing.secret,
clientKind: 'mobile',
})).rejects.toThrow('Invalid or expired pairing session');
});
it('does not consume the pairing session if client issuance fails', async () => {
const createClient = vi.fn()
.mockRejectedValueOnce(new Error('disk failed'))
.mockResolvedValueOnce({ client: { id: 'client-1' }, token: 'token-1' });
const { runtime } = await makeRuntime({ remoteClientAuthRuntime: { createClient } });
const created = await runtime.createPairingSession();
await expect(runtime.redeemPairingSession({
pairingId: created.pairing.id,
secret: created.pairing.secret,
clientKind: 'mobile',
})).rejects.toThrow('disk failed');
await expect(runtime.redeemPairingSession({
pairingId: created.pairing.id,
secret: created.pairing.secret,
clientKind: 'mobile',
})).resolves.toMatchObject({ token: 'token-1' });
expect(createClient).toHaveBeenLastCalledWith(expect.objectContaining({
dedupeKey: `pairing:${created.pairing.id}`,
}));
});
it('sweeps expired never-used sessions from the store on the next create', async () => {
const { dir, runtime } = await makeRuntime({ ttlMs: -1000 });
// Immediately expired (negative TTL), never used or cancelled.
const expired = await runtime.createPairingSession({ label: 'stale' });
// The next create sweeps the store; only the fresh session should remain.
const storePath = path.join(dir, 'pairing.json');
await runtime.createPairingSession({ label: 'fresh' });
const store = JSON.parse(await fs.readFile(storePath, 'utf8'));
const ids = store.sessions.map((session) => session.id);
expect(ids).not.toContain(expired.pairing.id);
expect(ids).toHaveLength(1);
});
});