companion-oauth-pkce-security.test.mjs
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