import crypto from "crypto"; import { promisify } from "util"; import { getPool } from "../../db/index.js"; import { getConfig } from "../../common/config.js"; const scrypt = promisify(crypto.scrypt); const KEYLEN = 64; // Read per call rather than captured at import, so loadConfig() governs these // the same way it governs the rest of config.dashboard. // // Per-process, so a revoked key can linger in another worker for one TTL. const cacheTtlMs = () => getConfig().dashboard.keyCache.ttlMs; // The TTL alone does not bound the cache: an expired entry is only dropped when // that same key is looked up again, so idle keys would accumulate forever. const cacheMaxEntries = () => getConfig().dashboard.keyCache.max; // Keyed by a digest of the raw key, never the raw key. Insertion order is // recency order, so the front of the Map is the least-recently-used entry. const cache = new Map(); const fastDigest = (raw) => crypto.createHash("sha256").update(raw).digest("hex"); // `expiresAt` is deliberately untouched, so a hammered key still expires on // time and revocation staleness stays bounded at one TTL. function touch(dg, entry) { cache.delete(dg); cache.set(dg, entry); } // Sweeps only when full, so the common insert stays O(1). function remember(dg, entry) { const max = cacheMaxEntries(); if (cacheTtlMs() <= 0) return; if (cache.size >= max) { const now = Date.now(); for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k); } cache.set(dg, entry); while (cache.size > max) { const lru = cache.keys().next().value; if (lru === undefined) break; cache.delete(lru); } } export function _resetKeyCache() { cache.clear(); } // ":". Guards a DB dump, not a weak-password guess. async function scryptHash(raw) { const salt = crypto.randomBytes(16); const derived = await scrypt(raw, salt, KEYLEN); return `${salt.toString("hex")}:${derived.toString("hex")}`; } async function scryptVerify(raw, stored) { const [saltHex, derivedHex] = String(stored).split(":"); if (!saltHex || !derivedHex) return false; const expected = Buffer.from(derivedHex, "hex"); const derived = await scrypt(raw, Buffer.from(saltHex, "hex"), KEYLEN); return derived.length === expected.length && crypto.timingSafeEqual(derived, expected); } const newRawKey = () => crypto.randomBytes(32).toString("hex"); // 64 hex chars const prefixOf = (raw) => raw.slice(0, 8); export async function verifyKey(rawKey) { if (!rawKey) return null; const dg = fastDigest(rawKey); const cached = cache.get(dg); if (cached && cached.expiresAt > Date.now()) { touch(dg, cached); return { user_id: cached.user_id, app_id: cached.app_id }; } if (cached) cache.delete(dg); const client = await getPool(); try { const rows = ( await client.query( `SELECT id, user_id, app_id, key_hash FROM api_key WHERE key_prefix = $1 AND (expires_ts IS NULL OR expires_ts > NOW())`, [prefixOf(rawKey)], ) ).rows; for (const row of rows) { if (await scryptVerify(rawKey, row.key_hash)) { client.query(`UPDATE api_key SET last_used_ts = NOW() WHERE id = $1`, [row.id]).catch(() => {}); remember(dg, { user_id: row.user_id, app_id: row.app_id, id: row.id, expiresAt: Date.now() + cacheTtlMs() }); return { user_id: row.user_id, app_id: row.app_id }; } } return null; } finally { await client.release(); } } // Returns the RAW key ONCE; only the salted hash + prefix are persisted. export async function generateKey(userId, appType, { label = null, expiresTs = null } = {}) { const client = await getPool(); try { const app = (await client.query(`SELECT id FROM app WHERE type = $1`, [appType])).rows[0]; if (!app) throw new Error(`unknown app type: ${appType}`); const raw = newRawKey(); const keyHash = await scryptHash(raw); const res = await client.query( `INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix, expires_ts) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_ts`, [userId, app.id, label, keyHash, prefixOf(raw), expiresTs], ); return { id: res.rows[0].id, key: raw, prefix: prefixOf(raw), appType, label, expiresTs, createdTs: res.rows[0].created_ts, }; } finally { await client.release(); } } // Idempotent import of the legacy plaintext keys in the `auth` table into the // hashed api_key store, so the same keys keep authenticating via verifyKey and // now appear in the /home API-key screen. export async function migrateLegacyApiKeys() { const client = await getPool(); try { const { rows } = await client.query( `SELECT user_id, app_id, api_key FROM public.auth WHERE api_key IS NOT NULL`, ); let imported = 0; for (const { user_id, app_id, api_key } of rows) { const prefix = prefixOf(api_key); const exists = await client.query( `SELECT 1 FROM api_key WHERE user_id = $1 AND app_id = $2 AND key_prefix = $3 LIMIT 1`, [user_id, app_id, prefix], ); if (exists.rowCount > 0) continue; const keyHash = await scryptHash(api_key); await client.query( `INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix) VALUES ($1, $2, $3, $4, $5)`, [user_id, app_id, "Imported from /dashboard", keyHash, prefix], ); imported++; } if (imported > 0) console.log(`Imported ${imported} legacy API key(s) into the hashed key store`); return imported; } finally { await client.release(); } } export async function listKeys(userId) { const client = await getPool(); try { return ( await client.query( `SELECT k.id, ap.type AS "appType", k.label, k.key_prefix AS "prefix", k.expires_ts AS "expiresTs", k.last_used_ts AS "lastUsedTs", k.created_ts AS "createdTs" FROM api_key k JOIN app ap ON ap.id = k.app_id WHERE k.user_id = $1 ORDER BY k.created_ts DESC`, [userId], ) ).rows; } finally { await client.release(); } } export async function revokeKey(userId, id) { const client = await getPool(); try { const res = await client.query( `DELETE FROM api_key WHERE id = $1 AND user_id = $2 RETURNING id`, [id, userId], ); if (res.rowCount > 0) { for (const [k, v] of cache) if (v.id === id) cache.delete(k); } return res.rowCount > 0; } finally { await client.release(); } }