502 lines
15 KiB
TypeScript
502 lines
15 KiB
TypeScript
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<string, DeviceAuthToken>;
|
|
createdAtMs: number;
|
|
approvedAtMs: number;
|
|
};
|
|
|
|
export type DevicePairingList = {
|
|
pending: DevicePairingPendingRequest[];
|
|
paired: PairedDevice[];
|
|
};
|
|
|
|
type DevicePairingStateFile = {
|
|
pendingById: Record<string, DevicePairingPendingRequest>;
|
|
pairedByDeviceId: Record<string, PairedDevice>;
|
|
};
|
|
|
|
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<T>(filePath: string): Promise<T | null> {
|
|
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<string, DevicePairingPendingRequest>,
|
|
nowMs: number,
|
|
) {
|
|
for (const [id, req] of Object.entries(pendingById)) {
|
|
if (nowMs - req.ts > PENDING_TTL_MS) {
|
|
delete pendingById[id];
|
|
}
|
|
}
|
|
}
|
|
|
|
let lock: Promise<void> = Promise.resolve();
|
|
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
const prev = lock;
|
|
let release: (() => void) | undefined;
|
|
lock = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await prev;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release?.();
|
|
}
|
|
}
|
|
|
|
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
|
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
|
const [pending, paired] = await Promise.all([
|
|
readJSON<Record<string, DevicePairingPendingRequest>>(pendingPath),
|
|
readJSON<Record<string, PairedDevice>>(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 | string[] | undefined>): string[] | undefined {
|
|
const roles = new Set<string>();
|
|
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>): string[] | undefined {
|
|
const scopes = new Set<string>();
|
|
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<string>();
|
|
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<DevicePairingList> {
|
|
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<PairedDevice | null> {
|
|
const state = await loadState(baseDir);
|
|
return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null;
|
|
}
|
|
|
|
export async function requestDevicePairing(
|
|
req: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
|
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<Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs">>,
|
|
baseDir?: string,
|
|
): Promise<void> {
|
|
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<string, DeviceAuthToken> | 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<DeviceAuthToken | null> {
|
|
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<DeviceAuthToken | null> {
|
|
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;
|
|
state.pairedByDeviceId[device.deviceId] = device;
|
|
await persistState(state, params.baseDir);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
export async function revokeDeviceToken(params: {
|
|
deviceId: string;
|
|
role: string;
|
|
baseDir?: string;
|
|
}): Promise<DeviceAuthToken | null> {
|
|
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;
|
|
});
|
|
}
|