companion-oauth-pkce-security.test.mjs
297 lines 14.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tier 7 — SECURITY (the centerpiece): adversarial properties of the Phase 3 OAuth/PKCE core.
3 *
4 * Each block maps to an attacker capability from the Phase 3 threat model and asserts the exact
5 * control that stops it. A subtle deviation here is an account-compromise path, so these tests
6 * are the proof the protocol core is correct BEFORE any socket/network/keychain I/O exists
7 * (deferred to Phase 5).
8 *
9 * Mandatory coverage (Phase 3 prompt + gate §10 security tier):
10 * - code_challenge is the correct S256 of the verifier (RFC 7636 §4.1) + verifier entropy/length
11 * - 'plain' method is rejected (no downgrade)
12 * - state mismatch → reject (constant-time, no oracle)
13 * - an authorization-server error is surfaced without leaking
14 * - a non-loopback / wildcard / foreign redirect_uri is rejected (RFC 8252)
15 * - the authorization URL never contains a client secret and uses response_type=code + S256
16 * - the token request carries the code_verifier (PKCE binding) and no client secret
17 * - a malformed / oversized token response fails closed
18 * - replay (reused state) is rejected
19 * - NO secret (code, code_verifier, state, access/refresh token, JWT) appears in any output,
20 * reason, log, or thrown error
21 *
22 * Reference: RFC 7636 (PKCE), RFC 8252 (native apps), RFC 9207 (iss); design doc threat model.
23 */
24 import { describe, it } from 'node:test';
25 import assert from 'node:assert/strict';
26 import crypto from 'node:crypto';
27 import {
28 OAUTH_PKCE_REASONS,
29 computeCodeChallenge,
30 createPkcePair,
31 createOAuthState,
32 constantTimeEqual,
33 validateRedirectUri,
34 buildAuthorizationUrl,
35 validateAuthorizationResponse,
36 buildTokenRequest,
37 validateTokenResponse,
38 } from '../lib/companion-oauth-pkce.mjs';
39
40 const AUTH_EP = 'https://knowtation.store/authorize';
41 const TOKEN_EP = 'https://knowtation.store/token';
42 const CLIENT_ID = 'companion-public-client';
43 const REDIRECT = 'http://127.0.0.1:49321/callback';
44 const SCOPES = ['vault:read', 'vault:write'];
45
46 // ── Attacker A — code interception on the loopback redirect (PKCE S256 binds code↔verifier) ──
47 describe('Security — PKCE S256 correctly binds the code to the verifier (RFC 7636 §4.1/§4.2)', () => {
48 it('code_challenge is exactly base64url(SHA-256(verifier)) for fresh pairs', () => {
49 for (let i = 0; i < 1000; i++) {
50 const { codeVerifier, codeChallenge } = createPkcePair();
51 const expected = crypto.createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
52 assert.equal(codeChallenge, expected);
53 }
54 });
55 it('the verifier has sufficient entropy/length (≥ 43 chars, unreserved charset)', () => {
56 const seen = new Set();
57 for (let i = 0; i < 5000; i++) {
58 const { codeVerifier } = createPkcePair();
59 assert.ok(codeVerifier.length >= 43, 'RFC 7636 §4.1 minimum length');
60 assert.match(codeVerifier, /^[A-Za-z0-9\-._~]+$/);
61 seen.add(codeVerifier);
62 }
63 assert.equal(seen.size, 5000, 'every verifier is unique (CSPRNG, no collisions)');
64 });
65 it('a wrong verifier does NOT reproduce the challenge (a stolen code is useless without it)', () => {
66 const { codeVerifier, codeChallenge } = createPkcePair();
67 const other = createPkcePair().codeVerifier;
68 assert.notEqual(computeCodeChallenge(other), codeChallenge);
69 assert.equal(computeCodeChallenge(codeVerifier), codeChallenge);
70 });
71 });
72
73 // ── Attacker D — PKCE downgrade to 'plain' ────────────────────────────────────
74 describe("Security — 'plain' PKCE is rejected (no downgrade) (RFC 7636 §7.2)", () => {
75 it('buildAuthorizationUrl refuses any method other than S256', () => {
76 for (const method of ['plain', 'PLAIN', 'none', 's256', '']) {
77 assert.throws(() => buildAuthorizationUrl({
78 authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT,
79 scopes: SCOPES, state: 's', codeChallenge: 'c', codeChallengeMethod: method,
80 }), `method ${JSON.stringify(method)} must be rejected`);
81 }
82 });
83 it('a built URL always advertises S256, never plain', () => {
84 const url = new URL(buildAuthorizationUrl({
85 authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT,
86 scopes: SCOPES, state: 's', codeChallenge: 'c',
87 }));
88 assert.equal(url.searchParams.get('code_challenge_method'), 'S256');
89 });
90 });
91
92 // ── Attacker B — CSRF / session-fixation on the callback (state, constant-time) ──
93 describe('Security — state defends CSRF/fixation; compare is constant-time (RFC 6749 §10.12)', () => {
94 it('a forged/mismatched state is rejected', () => {
95 const r = validateAuthorizationResponse({ params: { code: 'c', state: 'attacker' }, expectedState: createOAuthState() });
96 assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISMATCH);
97 });
98 it('an absent state is rejected (fail-closed)', () => {
99 const r = validateAuthorizationResponse({ params: { code: 'c' }, expectedState: 'legit' });
100 assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISSING);
101 });
102 it('the state compare is constant-time (no early-exit position oracle)', () => {
103 const state = 'S'.repeat(43);
104 const early = '!' + state.slice(1);
105 const late = state.slice(0, -1) + '!';
106 const ITER = 150_000;
107 for (let i = 0; i < 10_000; i++) { constantTimeEqual(early, state); constantTimeEqual(late, state); }
108 const t0 = performance.now();
109 for (let i = 0; i < ITER; i++) constantTimeEqual(early, state);
110 const earlyT = performance.now() - t0;
111 const t1 = performance.now();
112 for (let i = 0; i < ITER; i++) constantTimeEqual(late, state);
113 const lateT = performance.now() - t1;
114 const ratio = earlyT / lateT;
115 assert.ok(ratio > 0.25 && ratio < 4, `timing ratio ${ratio.toFixed(3)} suggests non-constant-time compare`);
116 });
117 });
118
119 // ── Attacker C — authorization-server / redirect mix-up (RFC 9207 iss) ────────
120 describe('Security — issuer mix-up defense (RFC 9207)', () => {
121 it('a present-but-foreign iss is rejected even with a valid state', () => {
122 const state = createOAuthState();
123 const r = validateAuthorizationResponse({
124 params: { code: 'c', state, iss: 'https://attacker.example' },
125 expectedState: state, expectedIssuer: 'https://knowtation.store',
126 });
127 assert.equal(r.reason, OAUTH_PKCE_REASONS.ISSUER_MISMATCH);
128 });
129 });
130
131 // ── Attacker E — open-redirect / redirect_uri manipulation (RFC 8252) ─────────
132 describe('Security — strict loopback redirect allowlist, no wildcard (RFC 8252 §7.3/§8.3)', () => {
133 it('rejects foreign, LAN, https, wildcard, and schemeless redirect targets', () => {
134 const bad = [
135 'https://evil.example/cb',
136 'http://evil.example:80/cb',
137 'http://192.168.1.50:8080/cb',
138 'http://127.0.0.1.evil.example:49321/cb',
139 'http://0.0.0.0:49321/cb',
140 'http://*.127.0.0.1:49321/cb',
141 'https://127.0.0.1:49321/cb',
142 'ftp://127.0.0.1:49321/cb',
143 'javascript:alert(1)',
144 ];
145 for (const uri of bad) {
146 assert.equal(validateRedirectUri(uri).ok, false, `${uri} must be rejected`);
147 }
148 });
149 it('build* functions refuse to emit a request to a non-loopback redirect', () => {
150 assert.throws(() => buildAuthorizationUrl({
151 authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: 'https://evil.example/cb',
152 scopes: SCOPES, state: 's', codeChallenge: 'c',
153 }));
154 assert.throws(() => buildTokenRequest({
155 tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: 'c',
156 codeVerifier: createPkcePair().codeVerifier, redirectUri: 'http://evil.example:1/cb',
157 }));
158 });
159 });
160
161 // ── Attacker G — client-secret extraction from the distributed binary ─────────
162 describe('Security — public client: NO client secret anywhere (RFC 8252 §8.5)', () => {
163 it('the authorization URL never carries a client secret and uses response_type=code + S256', () => {
164 const url = buildAuthorizationUrl({
165 authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT,
166 scopes: SCOPES, state: createOAuthState(), codeChallenge: createPkcePair().codeChallenge,
167 extraParams: { client_secret: 'should-be-dropped' },
168 });
169 assert.ok(!/client_secret/i.test(url), 'no client_secret param in the authorization URL');
170 const u = new URL(url);
171 assert.equal(u.searchParams.get('response_type'), 'code');
172 assert.equal(u.searchParams.get('code_challenge_method'), 'S256');
173 });
174 it('the token request carries the code_verifier (PKCE proof) and no client secret', () => {
175 const { codeVerifier } = createPkcePair();
176 const req = buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: 'authcode', codeVerifier, redirectUri: REDIRECT });
177 assert.equal(req.bodyParams.code_verifier, codeVerifier);
178 assert.ok(!/client_secret/i.test(req.body));
179 assert.equal(req.bodyParams.client_secret, undefined);
180 });
181 });
182
183 // ── Attacker — authorization-server error response leakage ────────────────────
184 describe('Security — auth-server errors surface without leaking free text', () => {
185 it('a known error code is surfaced; error_description is never echoed', () => {
186 const r = validateAuthorizationResponse({
187 params: { error: 'invalid_scope', error_description: 'leak <img src=x onerror=alert(1)>', state: 'x' },
188 expectedState: 's',
189 });
190 assert.equal(r.reason, OAUTH_PKCE_REASONS.AUTHORIZATION_SERVER_ERROR);
191 assert.equal(r.errorCode, 'invalid_scope');
192 assert.ok(!JSON.stringify(r).includes('leak'));
193 assert.ok(!JSON.stringify(r).includes('onerror'));
194 });
195 });
196
197 // ── Attacker H — authorization-response replay (one-time state, single-use code) ──
198 describe('Security — replay of a reused state is rejected (single-use contract)', () => {
199 it('once the caller has consumed (cleared) the expected state, a replayed callback fails closed', () => {
200 const state = createOAuthState();
201 const first = validateAuthorizationResponse({ params: { code: 'c1', state }, expectedState: state });
202 assert.equal(first.ok, true);
203 // Caller discards the one-time state after success → a replayed callback has no expectedState.
204 const replay = validateAuthorizationResponse({ params: { code: 'c1', state }, expectedState: '' });
205 assert.equal(replay.ok, false);
206 assert.equal(replay.reason, OAUTH_PKCE_REASONS.STATE_MISSING);
207 });
208 it('a different (attacker) state never validates against the pending one', () => {
209 const pending = createOAuthState();
210 const attacker = createOAuthState();
211 const r = validateAuthorizationResponse({ params: { code: 'c', state: attacker }, expectedState: pending });
212 assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISMATCH);
213 });
214 });
215
216 // ── Malformed / oversized token response fails closed ─────────────────────────
217 describe('Security — malformed/oversized token response fails closed', () => {
218 it('rejects non-objects, arrays, and missing fields', () => {
219 for (const bad of [null, undefined, 'str', 42, [], {}, { access_token: 'x' }, { token_type: 'Bearer' }]) {
220 assert.equal(validateTokenResponse(bad).ok, false);
221 }
222 });
223 it('rejects an oversized access token', () => {
224 const huge = 'a'.repeat(9000);
225 assert.equal(validateTokenResponse({ access_token: huge, token_type: 'Bearer', expires_in: 60 }).ok, false);
226 });
227 it('rejects an oversized refresh token', () => {
228 assert.equal(validateTokenResponse({ access_token: 'jwt', token_type: 'Bearer', expires_in: 60, refresh_token: 'r'.repeat(9000) }).ok, false);
229 });
230 });
231
232 // ── Attacker F — token theft at rest is out of scope for THIS module (custody owns it) ──
233 // (Covered by companion-token-custody-security.test.mjs: keychain-only, never plaintext/log.)
234
235 // ── No secret in any output / reason / thrown error ───────────────────────────
236 describe('Security — no secret in any output, reason, or thrown error', () => {
237 const SECRET_CODE = 'AUTHCODE-' + 'C'.repeat(40);
238 const SECRET_STATE = 'STATE-' + 'S'.repeat(40);
239
240 it('a state-mismatch verdict never echoes either state value', () => {
241 const r = validateAuthorizationResponse({ params: { code: SECRET_CODE, state: SECRET_STATE }, expectedState: 'expected-different' });
242 const s = JSON.stringify(r);
243 assert.ok(!s.includes(SECRET_STATE));
244 assert.ok(!s.includes(SECRET_CODE));
245 assert.ok(!s.includes('expected-different'));
246 });
247
248 it('the success channel returns the code (legitimate) but reasons never carry it', () => {
249 const ok = validateAuthorizationResponse({ params: { code: SECRET_CODE, state: SECRET_STATE }, expectedState: SECRET_STATE });
250 assert.equal(ok.code, SECRET_CODE); // legitimate return channel
251 const deny = validateAuthorizationResponse({ params: { state: SECRET_STATE }, expectedState: SECRET_STATE });
252 assert.ok(!JSON.stringify(deny).includes(SECRET_CODE));
253 });
254
255 it('thrown configuration errors never contain the verifier or code', () => {
256 const verifier = createPkcePair().codeVerifier;
257 try {
258 buildTokenRequest({ tokenEndpoint: 'http://insecure/token', clientId: CLIENT_ID, code: SECRET_CODE, codeVerifier: verifier, redirectUri: REDIRECT });
259 assert.fail('should have thrown');
260 } catch (e) {
261 assert.ok(!String(e.message).includes(verifier));
262 assert.ok(!String(e.message).includes(SECRET_CODE));
263 }
264 });
265
266 it('computeCodeChallenge throws without leaking the verifier', () => {
267 const bad = 'short-but-secret-' + 'Z'.repeat(5);
268 try {
269 computeCodeChallenge(bad);
270 assert.fail('should have thrown');
271 } catch (e) {
272 assert.ok(!String(e.message).includes(bad));
273 }
274 });
275
276 it('every validator reason is a fixed constant (never attacker-controlled text)', () => {
277 const reasons = new Set(Object.values(OAUTH_PKCE_REASONS));
278 const probes = [
279 validateAuthorizationResponse({ params: { error: '"; DROP TABLE x; --', state: 'x' }, expectedState: 's' }),
280 validateAuthorizationResponse({ params: 'not-an-object', expectedState: 's' }),
281 validateTokenResponse({ error: '<script>' }),
282 validateRedirectUri('http://evil.example:1/x'),
283 ];
284 for (const p of probes) {
285 assert.ok(reasons.has(p.reason), `reason ${p.reason} must be a fixed constant`);
286 }
287 });
288
289 it('validators never throw on hostile input (fail-closed, no leak)', () => {
290 const hostile = [undefined, null, {}, { params: 42 }, { params: { code: {} } }, 'x', 123];
291 for (const h of hostile) {
292 let r;
293 assert.doesNotThrow(() => { r = validateAuthorizationResponse(h); });
294 assert.equal(r.ok, false);
295 }
296 });
297 });
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 2 days ago