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>
This commit is contained in:
48
tests/repos/token_selector.test.js
Normal file
48
tests/repos/token_selector.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { pool } from '../../lib/db/pool.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import * as agents from '../../lib/db/repos/agents.js';
|
||||
|
||||
// verifyToken must be O(1): a public "selector" indexes the one candidate row,
|
||||
// then bcrypt verifies only that row's "verifier". (Previously it bcrypt-scanned
|
||||
// every token — code-review/security-sweep HIGH finding.)
|
||||
|
||||
const owner = { kind: 'user', id: null };
|
||||
let agent;
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
agent = await agents.create({ slug: 'tok', name: 'Tok', kind: 'claude', model: 'sonnet',
|
||||
capabilities: { read: true }, scopes: {} }, owner);
|
||||
});
|
||||
|
||||
describe('selector+verifier tokens', () => {
|
||||
it('new tokens are vk_<selector>.<verifier> and verify O(1) by selector', async () => {
|
||||
const { token } = await agents.createToken(agent.id, 'k1');
|
||||
expect(token).toMatch(/^vk_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
// the selector is stored in plaintext and indexed
|
||||
const selector = token.slice(3, token.indexOf('.'));
|
||||
const { rows } = await pool.query('SELECT selector FROM agent_tokens WHERE selector=$1', [selector]);
|
||||
expect(rows).toHaveLength(1);
|
||||
const found = await agents.verifyToken(token);
|
||||
expect(found.id).toBe(agent.id);
|
||||
});
|
||||
|
||||
it('a tampered verifier (right selector, wrong secret) does not verify', async () => {
|
||||
const { token } = await agents.createToken(agent.id, 'k2');
|
||||
const tampered = token.slice(0, -3) + 'AAA';
|
||||
expect(await agents.verifyToken(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it('legacy tokens (selector NULL, full-plaintext hash) still verify', async () => {
|
||||
const legacyPlain = 'vk_legacyflatToken1234567890';
|
||||
const hash = await bcrypt.hash(legacyPlain, 12);
|
||||
await pool.query(
|
||||
`INSERT INTO agent_tokens(agent_id, label, token_hash, selector) VALUES($1,'legacy',$2,NULL)`,
|
||||
[agent.id, hash]
|
||||
);
|
||||
const found = await agents.verifyToken(legacyPlain);
|
||||
expect(found.id).toBe(agent.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user