companion-token-custody-security.test.mjs
128 lines 6.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tier 7 — SECURITY: custody-at-rest properties (Phase 3 threat model, attacker capability F:
3 * JWT/refresh-token theft at rest, and the loopback-token lifecycle).
4 *
5 * Asserts:
6 * - secrets are persisted ONLY through the injected keychain adapter (never a file, never env);
7 * - the module NEVER logs a secret (console is captured during a full session);
8 * - thrown errors never contain a secret;
9 * - clearSession (logout / refresh-reuse) actually removes both tokens;
10 * - the loopback token has an independent lifecycle and account from the JWT.
11 */
12 import { describe, it, beforeEach, afterEach } from 'node:test';
13 import assert from 'node:assert/strict';
14 import {
15 KEYCHAIN_ACCOUNTS,
16 buildSessionMeta,
17 createTokenCustody,
18 } from '../lib/companion-token-custody.mjs';
19 import { makeSyncKeychain, makeAsyncKeychain } from './helpers/companion-keychain-fake.mjs';
20
21 const JWT = 'eyJhbGciOiJIUzI1NiJ9.PRIVATE-JWT-' + 'A'.repeat(40) + '.sig';
22 const REFRESH = 'refresh-SECRET-' + 'B'.repeat(40);
23 const LOOPBACK = 'kc_loopback_' + 'C'.repeat(40);
24
25 describe('Security — secrets persist ONLY via the injected adapter', () => {
26 it('every secret written lands in the keychain store and nowhere else the test can see', async () => {
27 const kc = makeSyncKeychain();
28 const custody = createTokenCustody(kc);
29 const meta = buildSessionMeta({ expiresIn: 3600, refreshToken: REFRESH, scope: 'vault:read', tokenType: 'Bearer' }, { now: 0, refreshTtlMs: 1_000_000 });
30 await custody.storeSession({ accessToken: JWT, refreshToken: REFRESH, meta });
31 await custody.storeLoopbackToken(LOOPBACK);
32
33 // The only place the secrets exist is the adapter store, under the documented accounts.
34 assert.equal(kc._store.get(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN), JWT);
35 assert.equal(kc._store.get(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN), REFRESH);
36 assert.equal(kc._store.get(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN), LOOPBACK);
37 // Metadata is non-secret: it must NOT contain either token.
38 const metaBlob = kc._store.get(KEYCHAIN_ACCOUNTS.SESSION_META);
39 assert.ok(!metaBlob.includes(JWT));
40 assert.ok(!metaBlob.includes(REFRESH));
41 });
42 });
43
44 describe('Security — the custody module never logs a secret', () => {
45 /** @type {string[]} */
46 let logged;
47 /** @type {Record<string, Function>} */
48 let original;
49 beforeEach(() => {
50 logged = [];
51 original = { log: console.log, error: console.error, warn: console.warn, info: console.info, debug: console.debug };
52 for (const k of Object.keys(original)) {
53 console[k] = (...args) => { logged.push(args.map(String).join(' ')); };
54 }
55 });
56 afterEach(() => {
57 for (const k of Object.keys(original)) console[k] = original[k];
58 });
59
60 it('a full session lifecycle emits no log line containing a secret', async () => {
61 const kc = makeAsyncKeychain();
62 const custody = createTokenCustody(kc);
63 const meta = buildSessionMeta({ expiresIn: 3600, refreshToken: REFRESH, scope: null, tokenType: 'Bearer' }, { now: 0, refreshTtlMs: 1_000_000 });
64 await custody.storeSession({ accessToken: JWT, refreshToken: REFRESH, meta });
65 await custody.loadSession();
66 await custody.decide({ now: 0 });
67 await custody.storeLoopbackToken(LOOPBACK);
68 await custody.getLoopbackToken();
69 await custody.updateAccessToken({ accessToken: JWT + '2', meta });
70 await custody.clearSession();
71 await custody.clearLoopbackToken();
72
73 const all = logged.join('\n');
74 assert.equal(logged.length, 0, 'custody should emit no logs at all');
75 assert.ok(!all.includes(JWT));
76 assert.ok(!all.includes(REFRESH));
77 assert.ok(!all.includes(LOOPBACK));
78 });
79 });
80
81 describe('Security — thrown errors never carry a secret', () => {
82 it('an oversized secret is rejected without echoing it', async () => {
83 const custody = createTokenCustody(makeSyncKeychain());
84 const huge = 'SECRET-' + 'Z'.repeat(9000);
85 const meta = buildSessionMeta({ expiresIn: 60, refreshToken: null, scope: null, tokenType: 'Bearer' }, { now: 0 });
86 await assert.rejects(
87 () => custody.storeSession({ accessToken: huge, meta }),
88 (e) => { assert.ok(!String(e.message).includes(huge)); return true; },
89 );
90 });
91 });
92
93 describe('Security — clearSession truly removes both tokens (logout / refresh-reuse)', () => {
94 it('after clearSession the JWT and refresh token are gone', async () => {
95 const kc = makeSyncKeychain();
96 const custody = createTokenCustody(kc);
97 const meta = buildSessionMeta({ expiresIn: 60, refreshToken: REFRESH, scope: null, tokenType: 'Bearer' }, { now: 0, refreshTtlMs: 1000 });
98 await custody.storeSession({ accessToken: JWT, refreshToken: REFRESH, meta });
99 await custody.clearSession();
100 assert.equal(kc._store.has(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN), false);
101 assert.equal(kc._store.has(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN), false);
102 assert.equal(kc._store.has(KEYCHAIN_ACCOUNTS.SESSION_META), false);
103 });
104 });
105
106 describe('Security — loopback token is isolated from the OAuth session', () => {
107 it('the loopback token uses a distinct account and survives an OAuth logout', async () => {
108 const kc = makeSyncKeychain();
109 const custody = createTokenCustody(kc);
110 await custody.storeLoopbackToken(LOOPBACK);
111 const meta = buildSessionMeta({ expiresIn: 60, refreshToken: REFRESH, scope: null, tokenType: 'Bearer' }, { now: 0, refreshTtlMs: 1000 });
112 await custody.storeSession({ accessToken: JWT, refreshToken: REFRESH, meta });
113 assert.notEqual(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, KEYCHAIN_ACCOUNTS.ACCESS_TOKEN);
114 await custody.clearSession();
115 assert.equal(await custody.getLoopbackToken(), LOOPBACK);
116 });
117 });
118
119 describe('Security — fail-closed load on a corrupt store', () => {
120 it('a corrupt metadata blob yields no session (no partial trust)', async () => {
121 const kc = makeSyncKeychain();
122 kc._store.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, JWT);
123 kc._store.set(KEYCHAIN_ACCOUNTS.SESSION_META, '{"expiresAt":"not-a-number"}');
124 const custody = createTokenCustody(kc);
125 assert.equal(await custody.loadSession(), null);
126 assert.equal(await custody.decide({ now: 0 }), 'reauth');
127 });
128 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago