Files
Bohdan Triapitsyn 3aa45805bc fix(devices): keep the existing device name on re-pair and re-login
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.
2026-08-24 14:03:18 +03:00

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,
};
};