Files
Void-Homelab/lib/db/repos/agents.js
root aa9cf0917e fix(auth): O(1) selector+verifier token verification
verifyToken loaded every non-revoked token and bcrypt-compared each (O(n) per
request — auth-latency DoS + linear scaling). New token format
vk_<selector>.<verifier>: the non-secret selector is indexed and locates exactly
one row; only the verifier is bcrypt-hashed. Legacy NULL-selector tokens still
verify via a fallback scan. Dropped the useless idx_agent_tokens_hash.

- migration 010_token_selector.sql (adds selector col + unique partial index)
- createToken/verifyToken reworked; also adds listTokenMeta (read for Yerin's
  token_audit tool)
- tests/repos/token_selector.test.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:17:45 +10:00

117 lines
4.0 KiB
JavaScript

import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';
const FIELDS = ['slug','name','kind','model','persona_path','capabilities','scopes'];
export async function create(input, actor) {
const cols = [], vals = [], ph = [];
let i = 1;
for (const f of FIELDS) {
if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); }
}
const { rows: [r] } = await pool.query(
`INSERT INTO agents(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`,
vals
);
await recordAudit(actor, 'create', 'agent', r.id, null, r);
return r;
}
export async function getById(id) {
const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE id=$1`, [id]);
return r;
}
export async function getBySlug(slug) {
const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE slug=$1`, [slug]);
return r;
}
export async function list() {
const { rows } = await pool.query(`SELECT * FROM agents ORDER BY name`);
return rows;
}
export async function setCapabilities(id, capabilities, scopes) {
const { rows: [r] } = await pool.query(
`UPDATE agents SET capabilities=$1, scopes=$2 WHERE id=$3 RETURNING *`,
[capabilities, scopes || {}, id]
);
return r;
}
// Token format: vk_<selector>.<verifier>
// selector — non-secret, indexed, locates exactly one row (O(1))
// verifier — the secret; only this is bcrypt-hashed into token_hash
// base64url never contains '.', so the delimiter is unambiguous.
export async function createToken(agent_id, label) {
const selector = crypto.randomBytes(9).toString('base64url'); // ~12 chars
const verifier = crypto.randomBytes(32).toString('base64url'); // ~43 chars
const plaintext = `vk_${selector}.${verifier}`;
const token_hash = await bcrypt.hash(verifier, 12);
const { rows: [t] } = await pool.query(
`INSERT INTO agent_tokens(agent_id, label, token_hash, selector) VALUES($1,$2,$3,$4) RETURNING id`,
[agent_id, label || null, token_hash, selector]
);
return { token: plaintext, id: t.id };
}
const AGENT_SELECT =
`SELECT t.id, t.token_hash, t.agent_id, a.*
FROM agent_tokens t JOIN agents a ON a.id = t.agent_id`;
async function finalizeVerify(row, secret) {
if (await bcrypt.compare(secret, row.token_hash)) {
await pool.query(`UPDATE agent_tokens SET last_used=now() WHERE id=$1`, [row.id]);
const { token_hash, selector, ...agent } = row;
return agent;
}
return null;
}
export async function verifyToken(plaintext) {
if (!plaintext?.startsWith('vk_')) return null;
const rest = plaintext.slice(3);
const dot = rest.indexOf('.');
if (dot !== -1) {
// New format — single indexed lookup by selector, bcrypt the verifier only.
const selector = rest.slice(0, dot);
const verifier = rest.slice(dot + 1);
const { rows: [row] } = await pool.query(
`${AGENT_SELECT} WHERE t.selector = $1 AND t.revoked_at IS NULL`, [selector]
);
if (!row) return null;
return finalizeVerify(row, verifier);
}
// Legacy fallback — pre-migration tokens hashed the full plaintext and have
// no selector. Scan only the (shrinking) NULL-selector set.
const { rows } = await pool.query(
`${AGENT_SELECT} WHERE t.selector IS NULL AND t.revoked_at IS NULL`
);
for (const row of rows) {
const agent = await finalizeVerify(row, plaintext);
if (agent) return agent;
}
return null;
}
export async function revokeToken(token_id) {
await pool.query(`UPDATE agent_tokens SET revoked_at=now() WHERE id=$1`, [token_id]);
}
// Token metadata for security review — label/usage/revocation joined with the
// owning agent. NEVER selects token_hash.
export async function listTokenMeta() {
const { rows } = await pool.query(
`SELECT t.id, t.agent_id, a.slug AS agent_slug, a.name AS agent_name,
t.label, t.last_used, t.created_at, t.revoked_at
FROM agent_tokens t JOIN agents a ON a.id = t.agent_id
ORDER BY t.created_at DESC`
);
return rows;
}