import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "../config/paths.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; function resolvePaths(baseDir?: string) { const root = baseDir ?? resolveStateDir(); const dir = path.join(root, "devices"); return { dir, pendingPath: path.join(dir, "pending.json"), pairedPath: path.join(dir, "paired.json"), }; } async function readJSON(filePath: string): Promise { try { const raw = await fs.readFile(filePath, "utf8"); return JSON.parse(raw) as T; } catch { return null; } } async function writeJSONAtomic(filePath: string, value: unknown) { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); const tmp = `${filePath}.${randomUUID()}.tmp`; await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); try { await fs.chmod(tmp, 0o600); } catch { // best-effort } await fs.rename(tmp, filePath); try { await fs.chmod(filePath, 0o600); } catch { // best-effort } } function pruneExpiredPending( pendingById: Record, nowMs: number, ) { for (const [id, req] of Object.entries(pendingById)) { if (nowMs - req.ts > PENDING_TTL_MS) { delete pendingById[id]; } } } let lock: Promise = Promise.resolve(); async function withLock(fn: () => Promise): Promise { const prev = lock; let release: (() => void) | undefined; lock = new Promise((resolve) => { release = resolve; }); await prev; try { return await fn(); } finally { release?.(); } } async function loadState(baseDir?: string): Promise { const { pendingPath, pairedPath } = resolvePaths(baseDir); const [pending, paired] = await Promise.all([ readJSON>(pendingPath), readJSON>(pairedPath), ]); const state: DevicePairingStateFile = { pendingById: pending ?? {}, pairedByDeviceId: paired ?? {}, }; pruneExpiredPending(state.pendingById, Date.now()); return state; } async function persistState(state: DevicePairingStateFile, baseDir?: string) { const { pendingPath, pairedPath } = resolvePaths(baseDir); 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 normalizeScopes(scopes: string[] | undefined): string[] { if (!Array.isArray(scopes)) return []; const out = new Set(); for (const scope of scopes) { const trimmed = scope.trim(); if (trimmed) out.add(trimmed); } return [...out].sort(); } 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 randomUUID().replaceAll("-", ""); } export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts); const paired = Object.values(state.pairedByDeviceId).sort( (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 = normalizeScopes(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 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, })) .sort((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 = state.pairedByDeviceId[normalizeDeviceId(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 (entry.token !== params.token) return { ok: false, reason: "token-mismatch" }; const requestedScopes = normalizeScopes(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 device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) return null; const role = normalizeRole(params.role); if (!role) return null; const requestedScopes = normalizeScopes(params.scopes); const tokens = device.tokens ? { ...device.tokens } : {}; const existing = tokens[role]; if (existing && !existing.revokedAtMs) { if (scopesAllow(requestedScopes, existing.scopes)) { return existing; } } const now = Date.now(); const next: DeviceAuthToken = { token: newToken(), role, scopes: requestedScopes, createdAtMs: existing?.createdAtMs ?? now, rotatedAtMs: existing ? now : undefined, revokedAtMs: undefined, lastUsedAtMs: existing?.lastUsedAtMs, }; tokens[role] = next; device.tokens = tokens; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return next; }); } 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 device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) return null; const role = normalizeRole(params.role); if (!role) return null; const tokens = device.tokens ? { ...device.tokens } : {}; const existing = tokens[role]; const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes); const now = Date.now(); const next: DeviceAuthToken = { token: newToken(), role, scopes: requestedScopes, createdAtMs: existing?.createdAtMs ?? now, rotatedAtMs: now, revokedAtMs: undefined, lastUsedAtMs: existing?.lastUsedAtMs, }; 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; }); }