Harden controlplane agent API key lookup

---
Build: pass | Tests: FAIL — 26 failed
This commit is contained in:
Operator & Codex 2026-05-03 18:10:46 +02:00
parent defc8df5f2
commit 8414953776
7 changed files with 201 additions and 17 deletions

View file

@ -15,7 +15,10 @@ import path from 'path';
import pg from 'pg';
import { ensureControlplaneAgentApiKey } from '../src/controlplane-agent-keys.js';
import {
ensureControlplaneAgentApiKey,
syncControlplaneAgentApiKeysFromDisk,
} from '../src/controlplane-agent-keys.js';
import {
BETTER_AUTH_SECRET,
BETTER_AUTH_URL,
@ -85,6 +88,7 @@ export async function run(_args: string[]): Promise<void> {
await runSchemaMigration(pool);
logger.info('Schema ready');
appendSetupLog('schema migration: ready');
await syncControlplaneAgentApiKeysFromDisk(pool);
// ── 2. Provision default agents ──────────────────────────────────
const defaultAgents = getDefaultAgents(TENANT_ID);

View file

@ -0,0 +1,124 @@
import fs from 'fs';
import path from 'path';
import type { Pool } from 'pg';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
tmpDir,
getAgentMock,
getAgentsMock,
setAgentApiKeyMock,
} = vi.hoisted(() => ({
tmpDir: `${process.cwd()}/tmp/controlplane-agent-keys-test`,
getAgentMock: vi.fn(),
getAgentsMock: vi.fn(),
setAgentApiKeyMock: vi.fn(),
}));
vi.mock('./config.js', () => ({
DATA_DIR: tmpDir,
TENANT_ID: '',
}));
vi.mock('./platform-identity.js', () => ({
SERVICE_NAME: 'clawdie',
}));
vi.mock('./controlplane-db.js', async () => {
const actual = await vi.importActual<typeof import('./controlplane-db.js')>(
'./controlplane-db.js',
);
return {
...actual,
getAgent: getAgentMock,
getAgents: getAgentsMock,
setAgentApiKey: setAgentApiKeyMock,
};
});
import {
deriveAgentApiKeyLookupHash,
generateAgentApiKey,
} from './controlplane-db.js';
import {
ensureControlplaneAgentApiKey,
getControlplaneAgentApiKeyPath,
readControlplaneAgentApiKey,
syncControlplaneAgentApiKeysFromDisk,
writeControlplaneAgentApiKey,
} from './controlplane-agent-keys.js';
describe('controlplane-agent-keys', () => {
beforeEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.mkdirSync(tmpDir, { recursive: true });
getAgentMock.mockReset();
getAgentsMock.mockReset();
setAgentApiKeyMock.mockReset();
});
it('writes and reads agent API keys from disk', () => {
writeControlplaneAgentApiKey('sysadmin', 'sk-test-123');
expect(readControlplaneAgentApiKey('sysadmin')).toBe('sk-test-123');
expect(fs.existsSync(getControlplaneAgentApiKeyPath('sysadmin'))).toBe(true);
});
it('reuses an in-sync on-disk key without rewriting DB state', async () => {
const apiKey = 'sk-test-123';
writeControlplaneAgentApiKey('sysadmin', apiKey);
getAgentMock.mockResolvedValue({
id: 'sysadmin',
api_key_hash: 'argon2id$present',
api_key_lookup_hash: deriveAgentApiKeyLookupHash(apiKey),
});
await expect(
ensureControlplaneAgentApiKey({} as Pool, 'sysadmin'),
).resolves.toBe(apiKey);
expect(setAgentApiKeyMock).not.toHaveBeenCalled();
});
it('backfills DB state when an old on-disk key has no lookup hash row', async () => {
const apiKey = 'sk-test-legacy';
writeControlplaneAgentApiKey('sysadmin', apiKey);
getAgentMock.mockResolvedValue({
id: 'sysadmin',
api_key_hash: 'argon2id$present',
api_key_lookup_hash: null,
});
await ensureControlplaneAgentApiKey({} as Pool, 'sysadmin');
expect(setAgentApiKeyMock).toHaveBeenCalledWith(
expect.anything(),
'sysadmin',
apiKey,
);
});
it('syncs only agents that have key files on disk', async () => {
const sysadminKey = generateAgentApiKey();
writeControlplaneAgentApiKey('sysadmin', sysadminKey);
getAgentsMock.mockResolvedValue([
{
id: 'sysadmin',
api_key_hash: 'argon2id$present',
api_key_lookup_hash: null,
},
{
id: 'db-admin',
api_key_hash: 'argon2id$present',
api_key_lookup_hash: null,
},
]);
await syncControlplaneAgentApiKeysFromDisk({} as Pool);
expect(setAgentApiKeyMock).toHaveBeenCalledTimes(1);
expect(setAgentApiKeyMock).toHaveBeenCalledWith(
expect.anything(),
'sysadmin',
sysadminKey,
);
});
});

View file

@ -5,10 +5,11 @@ import type { Pool } from 'pg';
import { DATA_DIR, TENANT_ID } from './config.js';
import {
deriveAgentApiKeyLookupHash,
generateAgentApiKey,
getAgent,
getAgents,
setAgentApiKey,
verifyPassword,
} from './controlplane-db.js';
import { SERVICE_NAME } from './platform-identity.js';
@ -57,6 +58,23 @@ export function writeControlplaneAgentApiKey(
fs.chmodSync(target, 0o600);
}
export async function syncControlplaneAgentApiKeysFromDisk(
pool: Pool,
): Promise<void> {
const agents = await getAgents(pool);
for (const agent of agents) {
const apiKey = readControlplaneAgentApiKey(agent.id);
if (!apiKey) continue;
const lookupHash = deriveAgentApiKeyLookupHash(apiKey);
if (agent.api_key_hash && agent.api_key_lookup_hash === lookupHash) {
syncedApiKeys.set(agent.id, apiKey);
continue;
}
await setAgentApiKey(pool, agent.id, apiKey);
syncedApiKeys.set(agent.id, apiKey);
}
}
export async function ensureControlplaneAgentApiKey(
pool: Pool,
agentId: string,
@ -77,8 +95,9 @@ export async function ensureControlplaneAgentApiKey(
return apiKey;
}
const lookupHash = deriveAgentApiKeyLookupHash(apiKey);
const inSync =
!!agent.api_key_hash && (await verifyPassword(apiKey, agent.api_key_hash));
!!agent.api_key_hash && agent.api_key_lookup_hash === lookupHash;
if (!inSync) {
await setAgentApiKey(pool, agentId, apiKey);
}

View file

@ -14,6 +14,7 @@ import path from 'path';
import type { ControlplaneApiOptions } from './controlplane-api.js';
import {
deriveAgentApiKeyLookupHash,
generateAgentApiKey,
hashPassword,
type Agent,
@ -102,9 +103,10 @@ const validAgentApiKey = generateAgentApiKey();
const validAgentAuth = `Bearer ${validAgentApiKey}`;
const hashedTestPass = await hashPassword('testpass');
const hashedAgentApiKey = await hashPassword(validAgentApiKey);
const validAgentApiKeyLookupHash = deriveAgentApiKeyLookupHash(validAgentApiKey);
const defaultAgents: Agent[] = [
{ id: 'clawdie', role: 'orchestrator', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin', role: 'sysadmin', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: hashedAgentApiKey, created_at: new Date() },
{ id: 'sysadmin', role: 'sysadmin', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: hashedAgentApiKey, api_key_lookup_hash: validAgentApiKeyLookupHash, created_at: new Date() },
];
interface SetupOptions {
@ -138,7 +140,12 @@ function defaultMockQuery(
if (/SELECT.*FROM operators/i.test(sql) && params?.[0] === 'admin') {
return { rows: [{ username: 'admin', hashed_password: hashedTestPass, created_at: new Date() }] };
}
if (/FROM agents/i.test(sql)) return { rows: agents };
if (/FROM agents/i.test(sql)) {
if (/api_key_lookup_hash\s*=\s*\$1/i.test(sql)) {
return { rows: agents.filter((agent) => agent.api_key_lookup_hash === params?.[0]) };
}
return { rows: agents };
}
if (/FROM agent_budgets/i.test(sql)) {
if (params?.[0]) {
return { rows: budgets.filter((b) => b.agent_id === params[0]) };

View file

@ -16,6 +16,7 @@ import os from 'os';
import {
getDefaultAgents,
DEFAULT_AGENTS,
deriveAgentApiKeyLookupHash,
generateAgentApiKey,
getAgentByApiKey,
hashPassword,
@ -211,6 +212,7 @@ describe('getAgentByApiKey', () => {
it('returns the matching agent when a hash matches', async () => {
const apiKey = generateAgentApiKey();
const apiKeyHash = await hashPassword(apiKey);
const apiKeyLookupHash = deriveAgentApiKeyLookupHash(apiKey);
const pool = {
query: vi.fn().mockResolvedValue({
rows: [
@ -222,6 +224,7 @@ describe('getAgentByApiKey', () => {
heartbeat_interval_sec: 86400,
budget_allocation_pct: 10,
api_key_hash: apiKeyHash,
api_key_lookup_hash: apiKeyLookupHash,
created_at: new Date(),
},
],
@ -232,10 +235,15 @@ describe('getAgentByApiKey', () => {
id: 'sysadmin',
role: 'sysadmin',
});
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE api_key_lookup_hash = $1'),
[apiKeyLookupHash],
);
});
it('returns null when no agent hash matches the token', async () => {
const otherApiKeyHash = await hashPassword('different-token');
const otherApiKeyLookupHash = deriveAgentApiKeyLookupHash('different-token');
const pool = {
query: vi.fn().mockResolvedValue({
rows: [
@ -247,6 +255,7 @@ describe('getAgentByApiKey', () => {
heartbeat_interval_sec: 86400,
budget_allocation_pct: 10,
api_key_hash: otherApiKeyHash,
api_key_lookup_hash: otherApiKeyLookupHash,
created_at: new Date(),
},
],

View file

@ -31,6 +31,7 @@ export interface Agent {
heartbeat_interval_sec: number | null;
budget_allocation_pct: number;
api_key_hash: string | null;
api_key_lookup_hash?: string | null;
created_at: Date;
}
@ -170,6 +171,7 @@ CREATE TABLE IF NOT EXISTS agents (
heartbeat_interval_sec INTEGER,
budget_allocation_pct INTEGER NOT NULL,
api_key_hash TEXT,
api_key_lookup_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@ -233,6 +235,7 @@ export async function runSchemaMigration(pool: pg.Pool): Promise<void> {
await ensureRoleConstraint(pool);
await ensureApprovalDecisionColumn(pool);
await ensureParentTaskId(pool);
await ensureAgentApiKeyLookupColumn(pool);
await ensureModelCatalogSchema(pool);
// Seed default agents (idempotent via upsert)
@ -356,6 +359,17 @@ async function ensureParentTaskId(pool: pg.Pool): Promise<void> {
);
}
async function ensureAgentApiKeyLookupColumn(pool: pg.Pool): Promise<void> {
await pool.query(
'ALTER TABLE agents ADD COLUMN IF NOT EXISTS api_key_lookup_hash TEXT',
);
await pool.query(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_api_key_lookup_hash
ON agents(api_key_lookup_hash)
WHERE api_key_lookup_hash IS NOT NULL`,
);
}
// ── Agent queries ──────────────────────────────────────────────────────────
export async function upsertAgent(
@ -403,16 +417,21 @@ export function generateAgentApiKey(length = 48): string {
return generatePassword(length);
}
export function deriveAgentApiKeyLookupHash(apiKey: string): string {
return crypto.createHash('sha256').update(apiKey).digest('base64url');
}
export async function setAgentApiKey(
pool: pg.Pool,
agentId: string,
apiKey: string,
): Promise<void> {
const hashed = await hashPassword(apiKey);
await pool.query('UPDATE agents SET api_key_hash = $2 WHERE id = $1', [
agentId,
hashed,
]);
const lookupHash = deriveAgentApiKeyLookupHash(apiKey);
await pool.query(
'UPDATE agents SET api_key_hash = $2, api_key_lookup_hash = $3 WHERE id = $1',
[agentId, hashed, lookupHash],
);
}
export async function getAgentByApiKey(
@ -420,16 +439,16 @@ export async function getAgentByApiKey(
apiKey: string,
): Promise<Agent | null> {
if (!apiKey.trim()) return null;
const lookupHash = deriveAgentApiKeyLookupHash(apiKey);
const result = await pool.query<Agent>(
'SELECT * FROM agents WHERE api_key_hash IS NOT NULL ORDER BY id',
`SELECT * FROM agents
WHERE api_key_lookup_hash = $1
LIMIT 1`,
[lookupHash],
);
for (const agent of result.rows) {
if (!agent.api_key_hash) continue;
if (await verifyPassword(apiKey, agent.api_key_hash)) {
return agent;
}
}
return null;
const agent = result.rows[0];
if (!agent?.api_key_hash) return null;
return (await verifyPassword(apiKey, agent.api_key_hash)) ? agent : null;
}
// ── Budget queries ─────────────────────────────────────────────────────────

View file

@ -4,6 +4,7 @@ import path from 'path';
import {
ensureControlplaneAgentApiKey,
getControlplaneOrchestratorId,
syncControlplaneAgentApiKeysFromDisk,
} from './controlplane-agent-keys.js';
import {
AGENT_ENGINE,
@ -1170,6 +1171,7 @@ async function main(): Promise<void> {
// Controlplane uses the memory pool — ensure schema + agents + budgets exist.
await runSchemaMigration(getMemoryPool());
await syncControlplaneAgentApiKeysFromDisk(getMemoryPool());
const sessionDir = getControlplaneSessionDir(TMP_DIR);
fs.mkdirSync(sessionDir, { recursive: true });