A dedupe-keyed client re-mint (QR rescan, password/passkey re-login) replaces
the stored record, which also reset the operator-visible name to the app's
hardcoded default ('OpenChamber Mobile'). The app-reported name is now only a
fallback: an explicit pairing label wins, otherwise the replaced record's
label is kept, and the default applies only to a first-ever pairing.
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
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) and wins outright. The device's self-reported
|
|
// label is only a fallback: on a re-pair with the same dedupeKey,
|
|
// createClient keeps the replaced record's label over it, so a rescan
|
|
// without a typed name does not reset the device to the app default.
|
|
const result = await remoteClientAuthRuntime.createClient({
|
|
label: normalizeOptionalString(session.label),
|
|
fallbackLabel: normalizeOptionalString(clientLabel)
|
|
|| normalizeOptionalString(deviceName)
|
|
|| 'Remote client',
|
|
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,
|
|
};
|
|
};
|