import { randomUUID } from "node:crypto"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { createAsyncLock, pruneExpiredPending, readJsonFile, resolvePairingPaths, writeJsonAtomic, } from "./pairing-files.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; export type DevicePairingPendingRequest = { requestId: string; deviceId: string; publicKey: string; displayName?: string; platform?: string; clientId?: string; clientMode?: string; role?: string; roles?: string[]; scopes?: string[]; remoteIp?: string; silent?: boolean; isRepair?: boolean; ts: number; }; export type DeviceAuthToken = { token: string; role: string; scopes: string[]; createdAtMs: number; rotatedAtMs?: number; revokedAtMs?: number; lastUsedAtMs?: number; }; export type DeviceAuthTokenSummary = { role: string; scopes: string[]; createdAtMs: number; rotatedAtMs?: number; revokedAtMs?: number; lastUsedAtMs?: number; }; export type PairedDevice = { deviceId: string; publicKey: string; displayName?: string; platform?: string; clientId?: string; clientMode?: string; role?: string; roles?: string[]; scopes?: string[]; remoteIp?: string; tokens?: Record; createdAtMs: number; approvedAtMs: number; }; export type DevicePairingList = { pending: DevicePairingPendingRequest[]; paired: PairedDevice[]; }; type DevicePairingStateFile = { pendingById: Record; pairedByDeviceId: Record; }; const PENDING_TTL_MS = 5 * 60 * 1000; const withLock = createAsyncLock(); async function loadState(baseDir?: string): Promise { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices"); const [pending, paired] = await Promise.all([ readJsonFile>(pendingPath), readJsonFile>(pairedPath), ]); const state: DevicePairingStateFile = { pendingById: pending ?? {}, pairedByDeviceId: paired ?? {}, }; pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS); return state; } async function persistState(state: DevicePairingStateFile, baseDir?: string) { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices"); await Promise.all([ writeJsonAtomic(pendingPath, state.pendingById), writeJsonAtomic(pairedPath, state.pairedByDeviceId), ]); } function normalizeDeviceId(deviceId: string) { return deviceId.trim(); } function normalizeRole(role: string | undefined): string | null { const trimmed = role?.trim(); return trimmed ? trimmed : null; } function mergeRoles(...items: Array): string[] | undefined { const roles = new Set(); for (const item of items) { if (!item) { continue; } if (Array.isArray(item)) { for (const role of item) { const trimmed = role.trim(); if (trimmed) { roles.add(trimmed); } } } else { const trimmed = item.trim(); if (trimmed) { roles.add(trimmed); } } } if (roles.size === 0) { return undefined; } return [...roles]; } function mergeScopes(...items: Array): string[] | undefined { const scopes = new Set(); for (const item of items) { if (!item) { continue; } for (const scope of item) { const trimmed = scope.trim(); if (trimmed) { scopes.add(trimmed); } } } if (scopes.size === 0) { return undefined; } return [...scopes]; } function scopesAllow(requested: string[], allowed: string[]): boolean { if (requested.length === 0) { return true; } if (allowed.length === 0) { return false; } const allowedSet = new Set(allowed); return requested.every((scope) => allowedSet.has(scope)); } function newToken() { return generatePairingToken(); } function getPairedDeviceFromState( state: DevicePairingStateFile, deviceId: string, ): PairedDevice | null { return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; } function cloneDeviceTokens(device: PairedDevice): Record { return device.tokens ? { ...device.tokens } : {}; } function buildDeviceAuthToken(params: { role: string; scopes: string[]; existing?: DeviceAuthToken; now: number; rotatedAtMs?: number; }): DeviceAuthToken { return { token: newToken(), role: params.role, scopes: params.scopes, createdAtMs: params.existing?.createdAtMs ?? params.now, rotatedAtMs: params.rotatedAtMs, revokedAtMs: undefined, lastUsedAtMs: params.existing?.lastUsedAtMs, }; } export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts); const paired = Object.values(state.pairedByDeviceId).toSorted( (a, b) => b.approvedAtMs - a.approvedAtMs, ); return { pending, paired }; } export async function getPairedDevice( deviceId: string, baseDir?: string, ): Promise { const state = await loadState(baseDir); return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; } export async function requestDevicePairing( req: Omit, baseDir?: string, ): Promise<{ status: "pending"; request: DevicePairingPendingRequest; created: boolean; }> { return await withLock(async () => { const state = await loadState(baseDir); const deviceId = normalizeDeviceId(req.deviceId); if (!deviceId) { throw new Error("deviceId required"); } const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId); if (existing) { return { status: "pending", request: existing, created: false }; } const isRepair = Boolean(state.pairedByDeviceId[deviceId]); const request: DevicePairingPendingRequest = { requestId: randomUUID(), deviceId, publicKey: req.publicKey, displayName: req.displayName, platform: req.platform, clientId: req.clientId, clientMode: req.clientMode, role: req.role, roles: req.role ? [req.role] : undefined, scopes: req.scopes, remoteIp: req.remoteIp, silent: req.silent, isRepair, ts: Date.now(), }; state.pendingById[request.requestId] = request; await persistState(state, baseDir); return { status: "pending", request, created: true }; }); } export async function approveDevicePairing( requestId: string, baseDir?: string, ): Promise<{ requestId: string; device: PairedDevice } | null> { return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; } const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); const scopes = mergeScopes(existing?.scopes, pending.scopes); const tokens = existing?.tokens ? { ...existing.tokens } : {}; const roleForToken = normalizeRole(pending.role); if (roleForToken) { const nextScopes = normalizeDeviceAuthScopes(pending.scopes); const existingToken = tokens[roleForToken]; const now = Date.now(); tokens[roleForToken] = { token: newToken(), role: roleForToken, scopes: nextScopes, createdAtMs: existingToken?.createdAtMs ?? now, rotatedAtMs: existingToken ? now : undefined, revokedAtMs: undefined, lastUsedAtMs: existingToken?.lastUsedAtMs, }; } const device: PairedDevice = { deviceId: pending.deviceId, publicKey: pending.publicKey, displayName: pending.displayName, platform: pending.platform, clientId: pending.clientId, clientMode: pending.clientMode, role: pending.role, roles, scopes, remoteIp: pending.remoteIp, tokens, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, }; delete state.pendingById[requestId]; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, baseDir); return { requestId, device }; }); } export async function rejectDevicePairing( requestId: string, baseDir?: string, ): Promise<{ requestId: string; deviceId: string } | null> { return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; } delete state.pendingById[requestId]; await persistState(state, baseDir); return { requestId, deviceId: pending.deviceId }; }); } export async function removePairedDevice( deviceId: string, baseDir?: string, ): Promise<{ deviceId: string } | null> { return await withLock(async () => { const state = await loadState(baseDir); const normalized = normalizeDeviceId(deviceId); if (!normalized || !state.pairedByDeviceId[normalized]) { return null; } delete state.pairedByDeviceId[normalized]; await persistState(state, baseDir); return { deviceId: normalized }; }); } export async function updatePairedDeviceMetadata( deviceId: string, patch: Partial>, baseDir?: string, ): Promise { return await withLock(async () => { const state = await loadState(baseDir); const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)]; if (!existing) { return; } const roles = mergeRoles(existing.roles, existing.role, patch.role); const scopes = mergeScopes(existing.scopes, patch.scopes); state.pairedByDeviceId[deviceId] = { ...existing, ...patch, deviceId: existing.deviceId, createdAtMs: existing.createdAtMs, approvedAtMs: existing.approvedAtMs, role: patch.role ?? existing.role, roles, scopes, }; await persistState(state, baseDir); }); } export function summarizeDeviceTokens( tokens: Record | undefined, ): DeviceAuthTokenSummary[] | undefined { if (!tokens) { return undefined; } const summaries = Object.values(tokens) .map((token) => ({ role: token.role, scopes: token.scopes, createdAtMs: token.createdAtMs, rotatedAtMs: token.rotatedAtMs, revokedAtMs: token.revokedAtMs, lastUsedAtMs: token.lastUsedAtMs, })) .toSorted((a, b) => a.role.localeCompare(b.role)); return summaries.length > 0 ? summaries : undefined; } export async function verifyDeviceToken(params: { deviceId: string; token: string; role: string; scopes: string[]; baseDir?: string; }): Promise<{ ok: boolean; reason?: string }> { return await withLock(async () => { const state = await loadState(params.baseDir); const device = getPairedDeviceFromState(state, params.deviceId); if (!device) { return { ok: false, reason: "device-not-paired" }; } const role = normalizeRole(params.role); if (!role) { return { ok: false, reason: "role-missing" }; } const entry = device.tokens?.[role]; if (!entry) { return { ok: false, reason: "token-missing" }; } if (entry.revokedAtMs) { return { ok: false, reason: "token-revoked" }; } if (!verifyPairingToken(params.token, entry.token)) { return { ok: false, reason: "token-mismatch" }; } const requestedScopes = normalizeDeviceAuthScopes(params.scopes); if (!scopesAllow(requestedScopes, entry.scopes)) { return { ok: false, reason: "scope-mismatch" }; } entry.lastUsedAtMs = Date.now(); device.tokens ??= {}; device.tokens[role] = entry; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return { ok: true }; }); } export async function ensureDeviceToken(params: { deviceId: string; role: string; scopes: string[]; baseDir?: string; }): Promise { return await withLock(async () => { const state = await loadState(params.baseDir); const requestedScopes = normalizeDeviceAuthScopes(params.scopes); const context = resolveDeviceTokenUpdateContext({ state, deviceId: params.deviceId, role: params.role, }); if (!context) { return null; } const { device, role, tokens, existing } = context; if (existing && !existing.revokedAtMs) { if (scopesAllow(requestedScopes, existing.scopes)) { return existing; } } const now = Date.now(); const next = buildDeviceAuthToken({ role, scopes: requestedScopes, existing, now, rotatedAtMs: existing ? now : undefined, }); tokens[role] = next; device.tokens = tokens; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return next; }); } function resolveDeviceTokenUpdateContext(params: { state: DevicePairingStateFile; deviceId: string; role: string; }): { device: PairedDevice; role: string; tokens: Record; existing: DeviceAuthToken | undefined; } | null { const device = getPairedDeviceFromState(params.state, params.deviceId); if (!device) { return null; } const role = normalizeRole(params.role); if (!role) { return null; } const tokens = cloneDeviceTokens(device); const existing = tokens[role]; return { device, role, tokens, existing }; } export async function rotateDeviceToken(params: { deviceId: string; role: string; scopes?: string[]; baseDir?: string; }): Promise { return await withLock(async () => { const state = await loadState(params.baseDir); const context = resolveDeviceTokenUpdateContext({ state, deviceId: params.deviceId, role: params.role, }); if (!context) { return null; } const { device, role, tokens, existing } = context; const requestedScopes = normalizeDeviceAuthScopes( params.scopes ?? existing?.scopes ?? device.scopes, ); const now = Date.now(); const next = buildDeviceAuthToken({ role, scopes: requestedScopes, existing, now, rotatedAtMs: now, }); tokens[role] = next; device.tokens = tokens; if (params.scopes !== undefined) { device.scopes = requestedScopes; } state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return next; }); } export async function revokeDeviceToken(params: { deviceId: string; role: string; baseDir?: string; }): Promise { return await withLock(async () => { const state = await loadState(params.baseDir); const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) { return null; } const role = normalizeRole(params.role); if (!role) { return null; } if (!device.tokens?.[role]) { return null; } const tokens = { ...device.tokens }; const entry = { ...tokens[role], revokedAtMs: Date.now() }; tokens[role] = entry; device.tokens = tokens; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return entry; }); }