feat(security): implement JWT secret rotation mechanism

This commit is contained in:
Brad Groux 2026-01-28 12:09:20 -06:00
parent 9d0f5cae47
commit d77f5dfa17
4 changed files with 95 additions and 9 deletions

View file

@ -1797,3 +1797,65 @@
{"type":"task.created","taskId":"task_20260128__MeCW1","project":"project-b","status":"todo","id":"evt_r4uHTWxguKEW","timestamp":"2026-01-28T18:08:08.502Z"}
{"type":"task.created","taskId":"task_20260128_nmhW6e","status":"todo","id":"evt_rHh1Qe7ijCn0","timestamp":"2026-01-28T18:08:08.517Z"}
{"type":"task.status_changed","taskId":"task_20260128_VxOHXP","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_bVKyHndjSs3o","timestamp":"2026-01-28T18:08:31.860Z"}
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_Qpb7BFExzGUE","timestamp":"2026-01-28T18:09:16.418Z"}
{"type":"task.created","taskId":"task_20260128_5Lu_em","status":"todo","id":"evt_b5ZtD3jzrTxb","timestamp":"2026-01-28T18:09:16.424Z"}
{"type":"task.created","taskId":"task_20260128_yfigJr","status":"todo","id":"evt_0AxRQpbsxYJl","timestamp":"2026-01-28T18:09:16.447Z"}
{"type":"task.status_changed","taskId":"task_20260128_yfigJr","status":"in-progress","previousStatus":"todo","id":"evt_yWHP7x7i2Dwm","timestamp":"2026-01-28T18:09:16.453Z"}
{"type":"task.created","taskId":"task_20260128_XEWZgR","status":"todo","id":"evt_urM1e3ZfuyXD","timestamp":"2026-01-28T18:09:16.463Z"}
{"type":"task.status_changed","taskId":"task_20260128_XEWZgR","status":"blocked","previousStatus":"todo","id":"evt_A_vF9k5-A62f","timestamp":"2026-01-28T18:09:16.471Z"}
{"type":"task.created","taskId":"task_20260128_tqx20I","status":"todo","id":"evt_LgzkV8lvDpIx","timestamp":"2026-01-28T18:09:16.472Z"}
{"type":"task.created","taskId":"task_20260128_W60YVG","project":"my-project","status":"todo","id":"evt_IyKYbz7fQAA1","timestamp":"2026-01-28T18:09:16.474Z"}
{"type":"task.status_changed","taskId":"task_20260128_tqx20I","status":"done","previousStatus":"todo","id":"evt_uX9nu3ku_Ok4","timestamp":"2026-01-28T18:09:16.475Z"}
{"type":"task.created","taskId":"task_20260128_88_N8c","status":"todo","id":"evt_JVBmwsgaSdhL","timestamp":"2026-01-28T18:09:16.478Z"}
{"type":"task.created","taskId":"task_20260128_JUdbHR","status":"todo","id":"evt_zCgD2QeDSY5J","timestamp":"2026-01-28T18:09:16.481Z"}
{"type":"task.created","taskId":"task_20260128_NrGG_n","status":"todo","id":"evt_bXxevOO_BB5R","timestamp":"2026-01-28T18:09:16.485Z"}
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_FyBlwr_2cK5V","timestamp":"2026-01-28T18:09:16.498Z"}
{"type":"task.status_changed","taskId":"task_20260128_NrGG_n","status":"in-progress","previousStatus":"todo","id":"evt_pYq3ofWR0nPM","timestamp":"2026-01-28T18:09:16.500Z"}
{"type":"task.created","taskId":"task_20260128_iO98kh","status":"todo","id":"evt_ilRivf0t32CC","timestamp":"2026-01-28T18:09:16.507Z"}
{"type":"task.created","taskId":"task_20260128_6X8cvE","status":"todo","id":"evt_GbUvLJQ5n1aZ","timestamp":"2026-01-28T18:09:16.512Z"}
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_L38zTSrXlZqp","timestamp":"2026-01-28T18:09:16.513Z"}
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_7mDXkL1t_cms","timestamp":"2026-01-28T18:09:16.515Z"}
{"type":"task.created","taskId":"task_20260128_cuHDhS","status":"todo","id":"evt_Ya6COThGSYWF","timestamp":"2026-01-28T18:09:16.533Z"}
{"type":"task.archived","taskId":"task_20260128_cuHDhS","status":"todo","id":"evt_OQbkv1pwejU3","timestamp":"2026-01-28T18:09:16.536Z"}
{"type":"task.created","taskId":"task_20260128_45L6O_","status":"todo","id":"evt_JfOzI5PzANbp","timestamp":"2026-01-28T18:09:16.541Z"}
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_YdM_rAoRj_jm","timestamp":"2026-01-28T18:09:16.547Z"}
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_jKCaFnAUw0mt","timestamp":"2026-01-28T18:09:16.549Z"}
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_b1usKTjGzDh4","timestamp":"2026-01-28T18:09:16.551Z"}
{"type":"task.created","taskId":"task_20260128_QY6xuP","status":"todo","id":"evt_5o1J5eAkaaHM","timestamp":"2026-01-28T18:09:16.551Z"}
{"type":"task.created","taskId":"task_20260128_cPLtZw","status":"todo","id":"evt_5G165e0OBAQw","timestamp":"2026-01-28T18:09:16.557Z"}
{"type":"task.created","taskId":"task_20260128_qbvgbD","status":"todo","id":"evt_62Ut-hvSQSZ2","timestamp":"2026-01-28T18:09:16.563Z"}
{"type":"task.created","taskId":"task_20260128_pOtThw","status":"todo","id":"evt_-RP2iOl7GS9A","timestamp":"2026-01-28T18:09:16.582Z"}
{"type":"task.created","taskId":"task_20260128_86qMBD","status":"todo","id":"evt_7SdZhXlgm3ca","timestamp":"2026-01-28T18:09:16.582Z"}
{"type":"task.created","taskId":"task_20260128_7tMVuL","status":"todo","id":"evt_h3kD_Cii2wyk","timestamp":"2026-01-28T18:09:16.592Z"}
{"type":"task.created","taskId":"task_20260128_yS5mXT","project":"test-project","status":"todo","id":"evt_hY3Fv60TXoSe","timestamp":"2026-01-28T18:09:16.594Z"}
{"type":"task.created","taskId":"task_20260128_1pz27_","status":"todo","id":"evt_QnxFQ5IGPxru","timestamp":"2026-01-28T18:09:16.596Z"}
{"type":"task.created","taskId":"task_20260128_ziFh-e","status":"todo","id":"evt_aCUfsqShIXuv","timestamp":"2026-01-28T18:09:16.602Z"}
{"type":"task.created","taskId":"task_20260128_ZHISuU","status":"todo","id":"evt_7bd4Vowa7Y5D","timestamp":"2026-01-28T18:09:16.618Z"}
{"type":"task.created","taskId":"task_20260128_9jikAS","status":"todo","id":"evt_s2PNEryux3n1","timestamp":"2026-01-28T18:09:16.619Z"}
{"type":"task.status_changed","taskId":"task_20260128_9jikAS","status":"in-progress","previousStatus":"todo","id":"evt_DzM8tqhHPY0d","timestamp":"2026-01-28T18:09:16.623Z"}
{"type":"task.created","taskId":"task_20260128__NAubi","status":"todo","id":"evt_KDlaF1bc4S-x","timestamp":"2026-01-28T18:09:16.629Z"}
{"type":"task.created","taskId":"task_20260128_gJuMBJ","status":"todo","id":"evt_xL-ssjcnoRpE","timestamp":"2026-01-28T18:09:16.629Z"}
{"type":"task.created","taskId":"task_20260128_0kgh5Y","status":"todo","id":"evt_aLqX9rTEP_zZ","timestamp":"2026-01-28T18:09:16.634Z"}
{"type":"task.created","taskId":"task_20260128_FXaQlh","status":"todo","id":"evt_vvsL_omYiBP-","timestamp":"2026-01-28T18:09:16.638Z"}
{"type":"task.created","taskId":"task_20260128_gOcY8H","status":"todo","id":"evt_LdvUl5W7qClP","timestamp":"2026-01-28T18:09:16.640Z"}
{"type":"task.created","taskId":"task_20260128_epYmqU","status":"todo","id":"evt_53tVHgyjJdhE","timestamp":"2026-01-28T18:09:16.645Z"}
{"type":"task.created","taskId":"task_20260128_yMhYrx","status":"todo","id":"evt_k1k10cVLyJzQ","timestamp":"2026-01-28T18:09:16.646Z"}
{"type":"task.created","taskId":"task_20260128_RT0jmZ","status":"todo","id":"evt_8KvtsOqyKSex","timestamp":"2026-01-28T18:09:16.653Z"}
{"type":"task.created","taskId":"task_20260128_G-_yl2","status":"todo","id":"evt_9H8Wg7f4mItL","timestamp":"2026-01-28T18:09:16.664Z"}
{"type":"task.created","taskId":"task_20260128_J09NBV","status":"todo","id":"evt_TbdT5oXdrOpO","timestamp":"2026-01-28T18:09:16.670Z"}
{"type":"task.created","taskId":"task_20260128_uucDAU","status":"todo","id":"evt_5fV-MpbHpbRC","timestamp":"2026-01-28T18:09:16.670Z"}
{"type":"task.created","taskId":"task_20260128_Xovvy1","status":"todo","id":"evt_yScb7A50qYWg","timestamp":"2026-01-28T18:09:16.678Z"}
{"type":"task.created","taskId":"task_20260128_INQ8yt","status":"todo","id":"evt_RkSaYk3sVSgf","timestamp":"2026-01-28T18:09:16.678Z"}
{"type":"task.created","taskId":"task_20260128_43H0qx","status":"todo","id":"evt_61KDkfVN89p4","timestamp":"2026-01-28T18:09:16.679Z"}
{"type":"task.created","taskId":"task_20260128_mMTk-8","status":"todo","id":"evt_3biIDHZNwm6w","timestamp":"2026-01-28T18:09:16.680Z"}
{"type":"task.created","taskId":"task_20260128_snGFll","status":"todo","id":"evt_kDmyyl36tcCT","timestamp":"2026-01-28T18:09:16.680Z"}
{"type":"task.created","taskId":"task_20260128_PI4zJ5","status":"todo","id":"evt_4eaZROeNyKQG","timestamp":"2026-01-28T18:09:16.690Z"}
{"type":"task.created","taskId":"task_20260128_BKI9Fk","status":"todo","id":"evt_iqrXhaHadKIw","timestamp":"2026-01-28T18:09:16.698Z"}
{"type":"task.created","taskId":"task_20260128_qsWjxE","status":"todo","id":"evt_ATrT-kaid79A","timestamp":"2026-01-28T18:09:16.704Z"}
{"type":"task.created","taskId":"task_20260128_JYjisO","status":"todo","id":"evt_vpepi3GtQfb7","timestamp":"2026-01-28T18:09:16.708Z"}
{"type":"task.created","taskId":"task_20260128_evvJc1","status":"todo","id":"evt_-xgCP8O0xm31","timestamp":"2026-01-28T18:09:16.720Z"}
{"type":"task.created","taskId":"task_20260128_cNpeKv","project":"project-a","status":"todo","id":"evt_2L58jPTJb2Mf","timestamp":"2026-01-28T18:09:16.729Z"}
{"type":"task.created","taskId":"task_20260128_B5K54H","project":"project-a","status":"todo","id":"evt_1YjG_klICRb6","timestamp":"2026-01-28T18:09:16.729Z"}
{"type":"task.created","taskId":"task_20260128_eDAP99","status":"todo","id":"evt_cwMs41pu6DbA","timestamp":"2026-01-28T18:09:16.729Z"}
{"type":"task.created","taskId":"task_20260128_kAmcUg","project":"project-b","status":"todo","id":"evt_UUJ844HuX3xQ","timestamp":"2026-01-28T18:09:16.730Z"}
{"type":"task.created","taskId":"task_20260128_Y3HGTt","status":"todo","id":"evt_7Nw9EKlZ8vu3","timestamp":"2026-01-28T18:09:16.744Z"}

View file

@ -195,7 +195,7 @@ describe('JWT Secret Rotation', () => {
expect(config.jwtSecrets!.find(s => s.version === 1)).toBeUndefined();
});
it('should support custom grace period', () => {
it('should support custom grace period (short)', () => {
const secret1 = crypto.randomBytes(32).toString('hex');
setConfig({
jwtSecrets: [
@ -204,16 +204,39 @@ describe('JWT Secret Rotation', () => {
jwtSecretVersion: 1,
});
// 0ms grace period
const result = rotateJwtSecret(0);
// 1 hour grace period
const oneHourMs = 60 * 60 * 1000;
const result = rotateJwtSecret(oneHourMs);
expect(result.success).toBe(true);
const config = getSecurityConfig();
const oldEntry = config.jwtSecrets!.find(s => s.version === 1);
expect(oldEntry).toBeDefined();
expect(oldEntry!.expiresAt).toBeDefined();
// With 0 grace, the expiresAt should be approximately now
// expiresAt should be approximately 1 hour from now
const expiresAt = new Date(oldEntry!.expiresAt!).getTime();
expect(Math.abs(expiresAt - Date.now())).toBeLessThan(5000); // within 5 seconds
const expectedExpiry = Date.now() + oneHourMs;
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(5000); // within 5 seconds
});
it('should immediately prune with 0ms grace period', () => {
const secret1 = crypto.randomBytes(32).toString('hex');
setConfig({
jwtSecrets: [
{ secret: secret1, version: 1, createdAt: '2026-01-01T00:00:00Z' },
],
jwtSecretVersion: 1,
});
// 0ms grace period — old secret should be immediately pruned
const result = rotateJwtSecret(0);
expect(result.success).toBe(true);
expect(result.prunedCount).toBe(1);
const config = getSecurityConfig();
// Only the new current secret should remain
expect(config.jwtSecrets).toHaveLength(1);
expect(config.jwtSecrets![0].version).toBe(2);
});
});

View file

@ -229,9 +229,10 @@ export function AgentStatusIndicator({ className = '' }: AgentStatusIndicatorPro
if (error) return 'error';
if (!data) return 'idle';
if (data.status === 'error') return 'error';
if (data.subAgentCount > 0 || data.status === 'sub-agent') return 'subagents';
if (data.status === 'sub-agent') return 'subagents';
return data.status as AgentState;
if (data.subAgentCount > 0 || data.status === ('sub-agent' as string)) return 'subagents';
const s = data.status as string;
if (s === 'idle' || s === 'working' || s === 'thinking' || s === 'error') return s as AgentState;
return 'idle';
}, [data, error]);
const config = STATE_CONFIG[state];

View file

@ -11,7 +11,7 @@ const STALE_THRESHOLD_MS = 5 * 60 * 1000;
/** How often to check for staleness (ms) */
const STALE_CHECK_INTERVAL_MS = 30_000;
export type AgentStatusState = 'idle' | 'working' | 'thinking' | 'error';
export type AgentStatusState = 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error';
export interface SubAgent {
id: string;