sec-kn-p6-session-secret-rotation.test.mjs
396 lines 18.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SEC-KN-P6-ROTATE — seven-tier coverage for the dual-secret SESSION_SECRET
3 * rotation helper and its mandatory wire-up (docs/SEC-KN-P6-ROTATE-FREEZE.md
4 * §6.2 / §7 P6-C1–C3, §8 test matrix).
5 *
6 * Frozen requirements:
7 * - `verifyJwtWithSecretRotation(token, primary, previous)` — try primary,
8 * fall back to previous (verify-only), fail closed on missing primary.
9 * - EVERY G10 access-JWT verify site calls the helper (source-scan tier);
10 * missing one host/path during P1–P2 means OLD tokens 401 on that path.
11 * - Signing (jwt.sign, bridge GitHub-token encrypt, HMAC) uses primary only.
12 * - Secrets never appear in thrown messages.
13 *
14 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
15 */
16
17 import { test, describe } from 'node:test';
18 import assert from 'node:assert/strict';
19 import fs from 'node:fs';
20 import path from 'node:path';
21 import { performance } from 'node:perf_hooks';
22 import { fileURLToPath } from 'node:url';
23 import jwt from 'jsonwebtoken';
24 import {
25 verifyJwtWithSecretRotation,
26 resolveSessionSecretPrevious,
27 } from '../hub/lib/session-secret-rotation.mjs';
28
29 const __dirname = path.dirname(fileURLToPath(import.meta.url));
30 const ROOT = path.resolve(__dirname, '..');
31
32 const HELPER_SRC_PATH = path.join(ROOT, 'hub/lib/session-secret-rotation.mjs');
33 const GATEWAY_SERVER = path.join(ROOT, 'hub/gateway/server.mjs');
34 const METADATA_BULK = path.join(ROOT, 'hub/gateway/metadata-bulk-canister.mjs');
35 const MCP_OAUTH = path.join(ROOT, 'hub/gateway/mcp-oauth-provider.mjs');
36 const BRIDGE_SERVER = path.join(ROOT, 'hub/bridge/server.mjs');
37 const FLOW_ROUTES = path.join(ROOT, 'hub/bridge/flow-routes.mjs');
38 const FLOW_CAPTURE_ROUTES = path.join(ROOT, 'hub/bridge/flow-capture-routes.mjs');
39 const TASK_ROUTES = path.join(ROOT, 'hub/bridge/task-routes.mjs');
40
41 const OLD_SECRET = 'test-old-secret-0123456789abcdef0123456789abcdef';
42 const NEW_SECRET = 'test-new-secret-fedcba9876543210fedcba9876543210';
43 const ATTACKER_SECRET = 'attacker-secret-not-in-any-domain-x-x-x-x-x-x-x';
44
45 function sign(secret, claims = {}) {
46 return jwt.sign({ sub: 'google:p6-user', type: 'session', ...claims }, secret, { expiresIn: '5m' });
47 }
48
49 function read(p) {
50 return fs.readFileSync(p, 'utf8');
51 }
52
53 /** Extract a named function block from source (entry to next top-level close). */
54 function fnBlock(src, marker) {
55 const start = src.indexOf(marker);
56 assert.ok(start >= 0, `source contains ${marker}`);
57 const end = src.indexOf('\n}', start);
58 return src.slice(start, end + 2);
59 }
60
61 // ---------------------------------------------------------------------------
62 // Tier 1 — unit (helper contract)
63 // ---------------------------------------------------------------------------
64 describe('P6 unit — verifyJwtWithSecretRotation contract', () => {
65 test('accepts a primary-signed token', () => {
66 const payload = verifyJwtWithSecretRotation(sign(NEW_SECRET), NEW_SECRET, OLD_SECRET);
67 assert.equal(payload?.sub, 'google:p6-user');
68 });
69
70 test('accepts a previous-signed token when previous is set (rotation window)', () => {
71 const payload = verifyJwtWithSecretRotation(sign(OLD_SECRET), NEW_SECRET, OLD_SECRET);
72 assert.equal(payload?.sub, 'google:p6-user');
73 });
74
75 test('rejects a previous-signed token when previous is unset (window closed)', () => {
76 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), NEW_SECRET, null), null);
77 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), NEW_SECRET, undefined), null);
78 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), NEW_SECRET, ''), null);
79 });
80
81 test('rejects garbage tokens and attacker-signed tokens', () => {
82 assert.equal(verifyJwtWithSecretRotation('invalid.invalid.invalid', NEW_SECRET, OLD_SECRET), null);
83 assert.equal(verifyJwtWithSecretRotation(sign(ATTACKER_SECRET), NEW_SECRET, OLD_SECRET), null);
84 assert.equal(verifyJwtWithSecretRotation('', NEW_SECRET, OLD_SECRET), null);
85 assert.equal(verifyJwtWithSecretRotation(null, NEW_SECRET, OLD_SECRET), null);
86 assert.equal(verifyJwtWithSecretRotation(42, NEW_SECRET, OLD_SECRET), null);
87 });
88
89 test('fail-closed: missing/empty primary refuses even a previous-signed token', () => {
90 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), '', OLD_SECRET), null);
91 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), null, OLD_SECRET), null);
92 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), undefined, OLD_SECRET), null);
93 });
94
95 test('previous === primary is a misconfig no-op (no second verify, no weakening)', () => {
96 assert.equal(verifyJwtWithSecretRotation(sign(OLD_SECRET), NEW_SECRET, NEW_SECRET), null);
97 const ok = verifyJwtWithSecretRotation(sign(NEW_SECRET), NEW_SECRET, NEW_SECRET);
98 assert.equal(ok?.sub, 'google:p6-user');
99 });
100
101 test('helper module never signs (no jwt.sign in source)', () => {
102 assert.ok(!read(HELPER_SRC_PATH).includes('jwt.sign'), 'rotation helper must be verify-only');
103 });
104
105 test('resolveSessionSecretPrevious reads only SESSION_SECRET_PREVIOUS and normalizes empty to null', () => {
106 assert.equal(resolveSessionSecretPrevious({ SESSION_SECRET_PREVIOUS: 'x' }), 'x');
107 assert.equal(resolveSessionSecretPrevious({ SESSION_SECRET_PREVIOUS: '' }), null);
108 assert.equal(resolveSessionSecretPrevious({}), null);
109 assert.equal(resolveSessionSecretPrevious({ HUB_JWT_SECRET: 'y' }), null);
110 });
111 });
112
113 // ---------------------------------------------------------------------------
114 // Tier 2 — integration (gateway decodeVerifiedToken path during the window)
115 // ---------------------------------------------------------------------------
116 describe('P6 integration — gateway decodeVerifiedToken path accepts previous during window', () => {
117 test('decodeVerifiedToken delegates to the rotation helper with both secrets', () => {
118 const block = fnBlock(read(GATEWAY_SERVER), 'function decodeVerifiedToken(token)');
119 assert.ok(
120 block.includes('verifyJwtWithSecretRotation(token, SESSION_SECRET, SESSION_SECRET_PREVIOUS)'),
121 'decodeVerifiedToken uses the dual-secret helper',
122 );
123 });
124
125 test('behavioral: gateway-shaped session token signed with OLD verifies while previous=OLD', () => {
126 // Same call shape as decodeVerifiedToken after the wire-up.
127 const token = jwt.sign(
128 { sub: 'google:it-user', provider: 'google', id: 'it-user', name: '', role: 'member', type: 'session' },
129 OLD_SECRET,
130 { expiresIn: '24h' },
131 );
132 const during = verifyJwtWithSecretRotation(token, NEW_SECRET, OLD_SECRET);
133 assert.equal(during?.sub, 'google:it-user');
134 assert.equal(during?.type, 'session');
135 });
136
137 test('verifyToken and resolveHostedActorRole entry also use the helper', () => {
138 const src = read(GATEWAY_SERVER);
139 const vt = fnBlock(src, 'function verifyToken(token)');
140 assert.ok(vt.includes('verifyJwtWithSecretRotation(token, SESSION_SECRET, SESSION_SECRET_PREVIOUS)'));
141 const rhar = src.slice(src.indexOf('async function resolveHostedActorRole'));
142 assert.ok(
143 rhar.slice(0, 2000).includes('verifyJwtWithSecretRotation(token, SESSION_SECRET, SESSION_SECRET_PREVIOUS)'),
144 'resolveHostedActorRole entry verify uses the dual-secret helper',
145 );
146 });
147 });
148
149 // ---------------------------------------------------------------------------
150 // Tier 3 — e2e (simulated cutover P0 → P1 → P3)
151 // ---------------------------------------------------------------------------
152 describe('P6 e2e — simulated cutover', () => {
153 test('sign with OLD → set PREVIOUS+NEW → verify OK → clear PREVIOUS → verify fail', () => {
154 // P0: primary = OLD, previous unset. Existing tokens verify.
155 const oldToken = sign(OLD_SECRET);
156 assert.ok(verifyJwtWithSecretRotation(oldToken, OLD_SECRET, null));
157
158 // P1: primary = NEW, previous = OLD. Old AND new tokens verify.
159 const newToken = sign(NEW_SECRET);
160 assert.ok(verifyJwtWithSecretRotation(oldToken, NEW_SECRET, OLD_SECRET), 'OLD token verifies during window');
161 assert.ok(verifyJwtWithSecretRotation(newToken, NEW_SECRET, OLD_SECRET), 'NEW token verifies during window');
162
163 // P3: previous unset. Only NEW-signed tokens verify; OLD → refuse (expected 401 upstream).
164 assert.equal(verifyJwtWithSecretRotation(oldToken, NEW_SECRET, null), null, 'OLD token refused after window');
165 assert.ok(verifyJwtWithSecretRotation(newToken, NEW_SECRET, null), 'NEW token still verifies after window');
166 });
167
168 test('cutover never accepts a token signed with neither secret', () => {
169 const forged = sign(ATTACKER_SECRET);
170 for (const [primary, previous] of [
171 [OLD_SECRET, null],
172 [NEW_SECRET, OLD_SECRET],
173 [NEW_SECRET, null],
174 ]) {
175 assert.equal(verifyJwtWithSecretRotation(forged, primary, previous), null);
176 }
177 });
178 });
179
180 // ---------------------------------------------------------------------------
181 // Tier 4 — stress (rapid alternating OLD/NEW verifies, concurrent)
182 // ---------------------------------------------------------------------------
183 describe('P6 stress — alternating OLD/NEW verify under concurrency', () => {
184 test('500 interleaved verifications stay correct', async () => {
185 const oldToken = sign(OLD_SECRET);
186 const newToken = sign(NEW_SECRET);
187 const results = await Promise.all(
188 Array.from({ length: 500 }, (_, i) =>
189 Promise.resolve().then(() => {
190 const token = i % 2 === 0 ? oldToken : newToken;
191 const payload = verifyJwtWithSecretRotation(token, NEW_SECRET, OLD_SECRET);
192 return payload?.sub === 'google:p6-user';
193 }),
194 ),
195 );
196 assert.ok(results.every(Boolean), 'every interleaved verify resolved the correct payload');
197 });
198
199 test('concurrent mixed valid/garbage tokens never cross-contaminate', async () => {
200 const valid = sign(NEW_SECRET);
201 const results = await Promise.all(
202 Array.from({ length: 200 }, (_, i) =>
203 Promise.resolve().then(() =>
204 verifyJwtWithSecretRotation(i % 3 === 0 ? 'garbage.garbage.garbage' : valid, NEW_SECRET, OLD_SECRET),
205 ),
206 ),
207 );
208 results.forEach((payload, i) => {
209 if (i % 3 === 0) assert.equal(payload, null);
210 else assert.equal(payload?.sub, 'google:p6-user');
211 });
212 });
213 });
214
215 // ---------------------------------------------------------------------------
216 // Tier 5 — data-integrity (signing/encrypt paths stay primary-only)
217 // ---------------------------------------------------------------------------
218 describe('P6 data-integrity — primary-only signing and encrypt invariants', () => {
219 /** Extract the full argument text of every `jwt.sign(...)` call via paren matching. */
220 function jwtSignCallArgs(src) {
221 const calls = [];
222 let idx = src.indexOf('jwt.sign(');
223 while (idx !== -1) {
224 let depth = 0;
225 let end = idx + 'jwt.sign'.length;
226 for (; end < src.length; end++) {
227 if (src[end] === '(') depth++;
228 else if (src[end] === ')') {
229 depth--;
230 if (depth === 0) break;
231 }
232 }
233 calls.push(src.slice(idx, end + 1));
234 idx = src.indexOf('jwt.sign(', end);
235 }
236 return calls;
237 }
238
239 test('no jwt.sign anywhere uses SESSION_SECRET_PREVIOUS', () => {
240 for (const p of [GATEWAY_SERVER, MCP_OAUTH, BRIDGE_SERVER, METADATA_BULK]) {
241 for (const call of jwtSignCallArgs(read(p))) {
242 assert.ok(
243 !call.includes('SESSION_SECRET_PREVIOUS') && !call.includes('_sessionSecretPrevious'),
244 `${path.basename(p)}: jwt.sign never uses the previous secret`,
245 );
246 }
247 }
248 });
249
250 test('bridge HMAC state signing and GitHub-token encrypt stay on SESSION_SECRET only', () => {
251 const src = read(BRIDGE_SERVER);
252 assert.ok(
253 !/createHmac\([^)]*SESSION_SECRET_PREVIOUS/.test(src),
254 'signState/verifyState HMAC never keyed by the previous secret',
255 );
256 assert.ok(
257 !/(scrypt|pbkdf2|createCipheriv|createDecipheriv)[\s\S]{0,200}?SESSION_SECRET_PREVIOUS/.test(src),
258 'GitHub-token encrypt/decrypt never keyed by the previous secret (freeze §6.4: re-connect, not dual-decrypt)',
259 );
260 });
261
262 test('MCP OAuth provider signs access tokens with the primary secret only', () => {
263 const src = read(MCP_OAUTH);
264 const signs = src.match(/jwt\.sign\([\s\S]*?\)/g) || [];
265 assert.ok(signs.length >= 1, 'provider still signs mcp_access tokens');
266 for (const s of signs) {
267 assert.ok(s.includes('this._sessionSecret'), 'mcp_access signing uses primary');
268 assert.ok(!s.includes('Previous'), 'mcp_access signing never uses previous');
269 }
270 });
271 });
272
273 // ---------------------------------------------------------------------------
274 // Tier 6 — performance (dual verify overhead bound)
275 // ---------------------------------------------------------------------------
276 describe('P6 performance — dual verify overhead', () => {
277 test('worst case (primary miss → previous hit) p95 under 5ms per verify', () => {
278 const oldToken = sign(OLD_SECRET);
279 // warm-up
280 for (let i = 0; i < 50; i++) verifyJwtWithSecretRotation(oldToken, NEW_SECRET, OLD_SECRET);
281 const samples = [];
282 for (let i = 0; i < 300; i++) {
283 const t0 = performance.now();
284 const payload = verifyJwtWithSecretRotation(oldToken, NEW_SECRET, OLD_SECRET);
285 samples.push(performance.now() - t0);
286 assert.ok(payload, 'verify succeeded via previous');
287 }
288 samples.sort((a, b) => a - b);
289 const p95 = samples[Math.floor(samples.length * 0.95)];
290 assert.ok(p95 < 5, `dual-verify p95 ${p95.toFixed(3)}ms must stay under 5ms (two HS256 verifies)`);
291 });
292
293 test('no retry/sleep storms in the helper (single fall-through, no loops/timers)', () => {
294 const src = read(HELPER_SRC_PATH);
295 assert.ok(!/setTimeout|setInterval|while\s*\(|for\s*\(/.test(src), 'helper is straight-line verify logic');
296 });
297 });
298
299 // ---------------------------------------------------------------------------
300 // Tier 7 — security (regression + no secret leakage + G10 source scan)
301 // ---------------------------------------------------------------------------
302 describe('P6 security — regression and leakage', () => {
303 test('REGRESSION: single-secret-only verify fails the previous-signed case during the window', () => {
304 // Pre-P6 shape: jwt.verify(token, SESSION_SECRET) with primary = NEW only.
305 const oldToken = sign(OLD_SECRET);
306 let singleSecretPayload = null;
307 try {
308 singleSecretPayload = jwt.verify(oldToken, NEW_SECRET);
309 } catch (_) {
310 singleSecretPayload = null;
311 }
312 assert.equal(singleSecretPayload, null, 'single-secret verify drops OLD tokens (the outage the helper prevents)');
313 assert.ok(
314 verifyJwtWithSecretRotation(oldToken, NEW_SECRET, OLD_SECRET),
315 'dual-secret helper keeps OLD tokens alive during the drain window',
316 );
317 });
318
319 test('helper never throws and never echoes secret material', () => {
320 let threw = null;
321 try {
322 verifyJwtWithSecretRotation(sign(ATTACKER_SECRET), NEW_SECRET, OLD_SECRET);
323 verifyJwtWithSecretRotation('x.y.z', NEW_SECRET, OLD_SECRET);
324 verifyJwtWithSecretRotation(sign(OLD_SECRET), '', OLD_SECRET);
325 } catch (e) {
326 threw = e;
327 }
328 assert.equal(threw, null, 'helper swallows verify errors to null');
329 const src = read(HELPER_SRC_PATH);
330 assert.ok(!/console\.(log|error|warn)/.test(src), 'helper never logs (no secret echo path)');
331 });
332
333 test('mcp-oauth verifyAccessToken error text carries no secret values', () => {
334 const block = fnBlock(read(MCP_OAUTH), 'async verifyAccessToken(token)');
335 assert.ok(!block.includes('_sessionSecret}'), 'no secret interpolation in error messages');
336 assert.ok(block.includes('Invalid access token'), 'stable generic error prefix retained');
337 });
338
339 test('SEC-KN-3 role-cap suite file still present and asserting (companion gate)', () => {
340 const p = path.join(ROOT, 'test/sec-kn-3-mcp-access-role-cap.test.mjs');
341 assert.ok(fs.existsSync(p), 'SEC-KN-3 suite must stay in-tree (run in the same test pass)');
342 });
343 });
344
345 // ---------------------------------------------------------------------------
346 // Tier 7b — G10 source scan (frozen: every access-JWT verify site uses helper)
347 // ---------------------------------------------------------------------------
348 describe('P6 security — G10 source scan: every access-JWT verify site calls the helper', () => {
349 const HELPER_CALL = 'verifyJwtWithSecretRotation(';
350
351 test('gateway server.mjs: verifyToken, decodeVerifiedToken, resolveHostedActorRole', () => {
352 const src = read(GATEWAY_SERVER);
353 assert.ok(fnBlock(src, 'function verifyToken(token)').includes(HELPER_CALL), 'G10 verifyToken');
354 assert.ok(fnBlock(src, 'function decodeVerifiedToken(token)').includes(HELPER_CALL), 'G10 decodeVerifiedToken');
355 const rhar = src.slice(src.indexOf('async function resolveHostedActorRole'));
356 assert.ok(rhar.slice(0, 2000).includes(HELPER_CALL), 'G10 resolveHostedActorRole bearer verify');
357 assert.ok(
358 !src.includes('jwt.verify(token, SESSION_SECRET)'),
359 'no single-secret jwt.verify(token, SESSION_SECRET) remains in gateway server.mjs',
360 );
361 });
362
363 test('gateway metadata-bulk-canister.mjs resolveRole', () => {
364 const src = read(METADATA_BULK);
365 assert.ok(src.includes(HELPER_CALL), 'G10 metadata bulk role resolve');
366 assert.ok(!src.includes('jwt.verify('), 'no raw jwt.verify remains in metadata-bulk-canister.mjs');
367 });
368
369 test('gateway mcp-oauth-provider.mjs verifyAccessToken', () => {
370 const src = read(MCP_OAUTH);
371 assert.ok(fnBlock(src, 'async verifyAccessToken(token)').includes(HELPER_CALL), 'G10 MCP OAuth access verify');
372 assert.ok(!src.includes('jwt.verify('), 'no raw jwt.verify remains in mcp-oauth-provider.mjs');
373 });
374
375 test('bridge server.mjs userIdFromJwt', () => {
376 const src = read(BRIDGE_SERVER);
377 assert.ok(fnBlock(src, 'function userIdFromJwt(token)').includes(HELPER_CALL), 'G10 bridge Bearer verify');
378 assert.ok(!src.includes('jwt.verify('), 'no raw jwt.verify remains in bridge server.mjs');
379 });
380
381 test('bridge flow / capture / task route sessionBoundFromReq', () => {
382 for (const p of [FLOW_ROUTES, FLOW_CAPTURE_ROUTES, TASK_ROUTES]) {
383 const src = read(p);
384 assert.ok(
385 fnBlock(src, 'function sessionBoundFromReq(req)').includes(HELPER_CALL),
386 `G10 ${path.basename(p)} sessionBoundFromReq`,
387 );
388 assert.ok(!src.includes('jwt.verify('), `no raw jwt.verify remains in ${path.basename(p)}`);
389 }
390 });
391
392 test('both servers resolve SESSION_SECRET_PREVIOUS via the shared resolver', () => {
393 assert.ok(read(GATEWAY_SERVER).includes('resolveSessionSecretPrevious()'), 'gateway boot-time previous resolve');
394 assert.ok(read(BRIDGE_SERVER).includes('resolveSessionSecretPrevious()'), 'bridge boot-time previous resolve');
395 });
396 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago