194 lines
6.4 KiB
JavaScript
194 lines
6.4 KiB
JavaScript
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();
|
|
}
|
|
|
|
// "<saltHex>:<derivedHex>". 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();
|
|
}
|
|
}
|