sec-kn-6-constant-time-secret-compare.test.mjs
362 lines 14.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SEC-KN-6 — seven-tier coverage for constant-time gateway / operator-export secret compare.
3 *
4 * Frozen requirement: Pass 2 P14
5 * (`~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md`) —
6 * gateway auth + operator export must not use early-exit `==` after the length check.
7 *
8 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
9 */
10
11 import { test, describe } from 'node:test';
12 import assert from 'node:assert/strict';
13 import fs from 'node:fs';
14 import path from 'node:path';
15 import { performance } from 'node:perf_hooks';
16 import { fileURLToPath } from 'node:url';
17 import {
18 constantTimeTextEqual,
19 gatewayAuthorized,
20 healthPayload,
21 httpRequestRequiresGatewayAuth,
22 operatorExportAuthorized,
23 textEqualEarlyExitLegacy,
24 } from '../lib/gateway-authorized.mjs';
25
26 const __dirname = path.dirname(fileURLToPath(import.meta.url));
27 const ROOT = path.resolve(__dirname, '..');
28 const MAIN_MO = path.join(ROOT, 'hub/icp/src/hub/main.mo');
29 const MIRROR_JS = path.join(ROOT, 'lib/gateway-authorized.mjs');
30
31 function readMainMo() {
32 return fs.readFileSync(MAIN_MO, 'utf8');
33 }
34
35 /**
36 * Extract a top-level Motoko `func name(...) { ... };` by brace depth.
37 * (Naive `indexOf('\\n};')` truncates on nested while/switch closers.)
38 *
39 * @param {string} src
40 * @param {string} funcName
41 */
42 function extractFuncBlock(src, funcName) {
43 const start = src.indexOf(`func ${funcName}(`);
44 assert.ok(start >= 0, `${funcName} must exist in main.mo`);
45 const braceOpen = src.indexOf('{', start);
46 assert.ok(braceOpen > start, `${funcName} must open a body`);
47 let depth = 0;
48 for (let i = braceOpen; i < src.length; i++) {
49 const ch = src[i];
50 if (ch === '{') depth += 1;
51 else if (ch === '}') {
52 depth -= 1;
53 if (depth === 0) {
54 const end = i + 1;
55 const withSemi = src[end] === ';' ? end + 1 : end;
56 return src.slice(start, withSemi);
57 }
58 }
59 }
60 assert.fail(`${funcName} block never closed`);
61 }
62
63 /**
64 * Pre-fix Motoko shape: length gate then `got == expected`.
65 * Security tier asserts current Motoko blocks no longer contain this early-exit compare.
66 *
67 * @param {string} got
68 * @param {string} expected
69 */
70 function motokoStyleEqualLegacy(got, expected) {
71 if (got.length !== expected.length) return false;
72 return textEqualEarlyExitLegacy(got, expected);
73 }
74
75 // ---------------------------------------------------------------------------
76 // Tier 1 — unit
77 // ---------------------------------------------------------------------------
78 describe('SEC-KN-6 unit — constantTimeTextEqual', () => {
79 test('equal strings accept; mismatch / length / type deny', () => {
80 assert.equal(constantTimeTextEqual('secret', 'secret'), true);
81 assert.equal(constantTimeTextEqual('secret', 'secreT'), false);
82 assert.equal(constantTimeTextEqual('secret', 'secre'), false);
83 assert.equal(constantTimeTextEqual('secret', 'secrets'), false);
84 assert.equal(constantTimeTextEqual('', ''), true);
85 assert.equal(constantTimeTextEqual('a', ''), false);
86 assert.equal(constantTimeTextEqual(null, 'a'), false);
87 assert.equal(constantTimeTextEqual('a', undefined), false);
88 });
89
90 test('gatewayAuthorized stays fail-closed and uses constant-time match', () => {
91 assert.equal(gatewayAuthorized('', 'x'), false);
92 assert.equal(gatewayAuthorized('sec', undefined), false);
93 assert.equal(gatewayAuthorized('sec', 'sec'), true);
94 assert.equal(gatewayAuthorized('sec', 'seX'), false);
95 });
96
97 test('operatorExportAuthorized mirrors the same contract', () => {
98 assert.equal(operatorExportAuthorized('', 'k'), false);
99 assert.equal(operatorExportAuthorized('key', null), false);
100 assert.equal(operatorExportAuthorized('key', 'key'), true);
101 assert.equal(operatorExportAuthorized('key', 'kez'), false);
102 });
103
104 test('first-char vs last-char mismatch both deny (no position privilege)', () => {
105 const secret = 'ABCDEFGH';
106 assert.equal(constantTimeTextEqual(secret, 'XBCDEFGH'), false);
107 assert.equal(constantTimeTextEqual(secret, 'ABCDEFGX'), false);
108 });
109 });
110
111 // ---------------------------------------------------------------------------
112 // Tier 2 — integration (Motoko source + JS mirror)
113 // ---------------------------------------------------------------------------
114 describe('SEC-KN-6 integration — Motoko + mirror contract', () => {
115 test('Motoko defines constantTimeTextEqual with OR-of-XOR scan', () => {
116 const src = readMainMo();
117 const block = extractFuncBlock(src, 'constantTimeTextEqual');
118 assert.ok(block.includes('Text.toArray(a)'));
119 assert.ok(block.includes('Text.toArray(b)'));
120 assert.match(block, /acc\s*:=\s*acc\s*\|\s*\(/);
121 assert.match(block, /Char\.toNat32/);
122 assert.ok(block.includes('acc == 0'));
123 assert.doesNotMatch(block, /\ba\s*==\s*b\b/);
124 });
125
126 test('gatewayAuthorized and operatorExportAuthorized call constantTimeTextEqual', () => {
127 const src = readMainMo();
128 const gw = extractFuncBlock(src, 'gatewayAuthorized');
129 const op = extractFuncBlock(src, 'operatorExportAuthorized');
130 assert.ok(gw.includes('constantTimeTextEqual(got, expected)'));
131 assert.ok(op.includes('constantTimeTextEqual(got, expected)'));
132 assert.doesNotMatch(gw, /got\s*==\s*expected/);
133 assert.doesNotMatch(op, /got\s*==\s*expected/);
134 });
135
136 test('JS mirror exports constantTimeTextEqual and both auth wrappers', () => {
137 const src = fs.readFileSync(MIRROR_JS, 'utf8');
138 assert.ok(src.includes('export function constantTimeTextEqual'));
139 assert.ok(src.includes('export function gatewayAuthorized'));
140 assert.ok(src.includes('export function operatorExportAuthorized'));
141 assert.ok(src.includes('acc |= '));
142 });
143
144 test('fail-closed empty secret still holds after P14 change', () => {
145 assert.equal(gatewayAuthorized('', undefined), false);
146 assert.equal(operatorExportAuthorized('', undefined), false);
147 const gw = extractFuncBlock(readMainMo(), 'gatewayAuthorized');
148 assert.ok(gw.includes('if (Text.size(expected) == 0) { return false }'));
149 });
150 });
151
152 // ---------------------------------------------------------------------------
153 // Tier 3 — e2e (fixture request matrix through routing + auth)
154 // ---------------------------------------------------------------------------
155 describe('SEC-KN-6 e2e — request matrix with constant-time auth', () => {
156 function decide(secret, method, pathKind, gatewayHeader) {
157 if (!httpRequestRequiresGatewayAuth(method, pathKind)) {
158 return { status: 200, body: healthPayload(secret) };
159 }
160 if (!gatewayAuthorized(secret, gatewayHeader)) {
161 return {
162 status: 403,
163 body: {
164 error: 'Gateway authentication required',
165 code: 'GATEWAY_AUTH_REQUIRED',
166 },
167 };
168 }
169 return { status: 200, body: { ok: true, pathKind } };
170 }
171
172 test('health bypasses auth; vaults requires constant-time match', () => {
173 const secret = 'e2e-gateway-secret-32chars!!!!';
174 const health = decide(secret, 'GET', 'health', undefined);
175 assert.equal(health.status, 200);
176 assert.equal(health.body.ok, true);
177
178 const wrong = decide(secret, 'GET', 'vaults', secret.slice(0, -1) + 'x');
179 assert.equal(wrong.status, 403);
180 assert.equal(wrong.body.code, 'GATEWAY_AUTH_REQUIRED');
181
182 const ok = decide(secret, 'GET', 'vaults', secret);
183 assert.equal(ok.status, 200);
184
185 const options = decide(secret, 'OPTIONS', 'vaults', undefined);
186 assert.equal(options.status, 200);
187 });
188
189 test('operator export: empty secret denies; wrong key denies; match allows', () => {
190 assert.equal(operatorExportAuthorized('', 'any'), false);
191 assert.equal(operatorExportAuthorized('export-key', 'wrong-key!!'), false);
192 assert.equal(operatorExportAuthorized('export-key', 'export-key'), true);
193 });
194 });
195
196 // ---------------------------------------------------------------------------
197 // Tier 4 — stress
198 // ---------------------------------------------------------------------------
199 describe('SEC-KN-6 stress — many compares at varying mismatch positions', () => {
200 test('10_000 compares across first/middle/last mismatch stay correct', () => {
201 const secret = 'S'.repeat(64);
202 for (let i = 0; i < 10_000; i++) {
203 const pos = i % 64;
204 const wrong = secret.slice(0, pos) + 'X' + secret.slice(pos + 1);
205 assert.equal(constantTimeTextEqual(secret, secret), true);
206 assert.equal(constantTimeTextEqual(secret, wrong), false);
207 assert.equal(gatewayAuthorized(secret, i % 2 === 0 ? secret : wrong), i % 2 === 0);
208 }
209 });
210 });
211
212 // ---------------------------------------------------------------------------
213 // Tier 5 — data-integrity
214 // ---------------------------------------------------------------------------
215 describe('SEC-KN-6 data-integrity — idempotent decisions + Motoko call sites', () => {
216 test('same inputs always yield the same allow/deny', () => {
217 const cases = [
218 ['', ''],
219 ['abc', 'abc'],
220 ['abc', 'abd'],
221 ['abc', 'ab'],
222 ['αβγ', 'αβγ'],
223 ['αβγ', 'αβδ'],
224 ];
225 for (const [a, b] of cases) {
226 assert.equal(constantTimeTextEqual(a, b), constantTimeTextEqual(a, b));
227 }
228 });
229
230 test('Motoko auth call sites still fail-closed on empty secret', () => {
231 const src = readMainMo();
232 for (const name of ['gatewayAuthorized', 'operatorExportAuthorized']) {
233 const block = extractFuncBlock(src, name);
234 assert.match(block, /Text\.size\(expected\) == 0\) \{ return false \}/);
235 assert.ok(block.includes('constantTimeTextEqual(got, expected)'));
236 }
237 });
238 });
239
240 // ---------------------------------------------------------------------------
241 // Tier 6 — performance
242 // ---------------------------------------------------------------------------
243 describe('SEC-KN-6 performance — bounded compare time', () => {
244 test('100k constant-time compares complete under 1500ms', () => {
245 const secret = 'perf-secret-value-32-chars!!!!';
246 const wrongEarly = 'X' + secret.slice(1);
247 const wrongLate = secret.slice(0, -1) + 'X';
248 const t0 = performance.now();
249 for (let i = 0; i < 100_000; i++) {
250 constantTimeTextEqual(secret, i % 3 === 0 ? secret : i % 3 === 1 ? wrongEarly : wrongLate);
251 }
252 const ms = performance.now() - t0;
253 assert.ok(ms < 1500, `expected <1500ms, got ${ms.toFixed(1)}ms`);
254 });
255 });
256
257 // ---------------------------------------------------------------------------
258 // Tier 7 — security (regression must FAIL against pre-fix early-exit compare)
259 // ---------------------------------------------------------------------------
260 describe('SEC-KN-6 security — regression vs early-exit ==', () => {
261 test('security regression: Motoko auth blocks must not use got == expected', () => {
262 const src = readMainMo();
263 const gw = extractFuncBlock(src, 'gatewayAuthorized');
264 const op = extractFuncBlock(src, 'operatorExportAuthorized');
265
266 // Sanity: legacy Motoko shape (length then ==) is what P14 flagged.
267 const legacySnippet = 'if (Text.size(got) != Text.size(expected)) { false } else { got == expected }';
268 assert.ok(
269 legacySnippet.includes('got == expected'),
270 'sanity: legacy snippet models the audited early-exit compare'
271 );
272
273 assert.doesNotMatch(gw, /got\s*==\s*expected/);
274 assert.doesNotMatch(op, /got\s*==\s*expected/);
275 assert.ok(gw.includes('constantTimeTextEqual(got, expected)'));
276 assert.ok(op.includes('constantTimeTextEqual(got, expected)'));
277
278 // If a build silently restored `got == expected`, this would fail.
279 assert.ok(
280 !gw.includes(legacySnippet) && !op.includes(legacySnippet),
281 'auth blocks must not restore the audited length-then-== shape'
282 );
283 });
284
285 test('security regression: fixed compare diverges structurally from early-exit legacy', () => {
286 const secret = 'ABCDEFGH';
287 const earlyMismatch = 'XBCDEFGH';
288 const lateMismatch = 'ABCDEFGX';
289
290 // Correctness: both paths agree on accept/deny outcomes.
291 assert.equal(constantTimeTextEqual(secret, secret), motokoStyleEqualLegacy(secret, secret));
292 assert.equal(constantTimeTextEqual(secret, earlyMismatch), motokoStyleEqualLegacy(secret, earlyMismatch));
293 assert.equal(constantTimeTextEqual(secret, lateMismatch), motokoStyleEqualLegacy(secret, lateMismatch));
294
295 // Discrimination: legacy helper still short-circuits; fixed always scans.
296 // Instrumentable proxy: early-exit returns on first miss without reading later chars.
297 let legacyReads = 0;
298 function earlyExitInstrumented(a, b) {
299 if (a.length !== b.length) return false;
300 for (let i = 0; i < a.length; i++) {
301 legacyReads += 1;
302 if (a.charCodeAt(i) !== b.charCodeAt(i)) return false;
303 }
304 return true;
305 }
306 let fixedReads = 0;
307 function fixedInstrumented(a, b) {
308 const aa = [...a];
309 const bb = [...b];
310 if (aa.length !== bb.length) return false;
311 let acc = 0;
312 for (let i = 0; i < aa.length; i++) {
313 fixedReads += 1;
314 acc |= aa[i].codePointAt(0) ^ bb[i].codePointAt(0);
315 }
316 return acc === 0;
317 }
318
319 assert.equal(earlyExitInstrumented(secret, earlyMismatch), false);
320 assert.equal(fixedInstrumented(secret, earlyMismatch), false);
321 assert.equal(legacyReads, 1, 'legacy early-exit reads only until first mismatch');
322 assert.equal(fixedReads, secret.length, 'fixed compare always reads every character');
323 assert.notEqual(
324 legacyReads,
325 fixedReads,
326 'fixed behavior must diverge from early-exit on first-char mismatch work'
327 );
328 });
329
330 test('timing ratio for first vs last mismatch stays within bound (no position oracle)', () => {
331 const secret = 'T'.repeat(256);
332 const early = 'X' + secret.slice(1);
333 const late = secret.slice(0, -1) + 'X';
334 const rounds = 8000;
335
336 // Warmup
337 for (let i = 0; i < 500; i++) {
338 constantTimeTextEqual(secret, early);
339 constantTimeTextEqual(secret, late);
340 }
341
342 const t0 = performance.now();
343 for (let i = 0; i < rounds; i++) constantTimeTextEqual(secret, early);
344 const earlyMs = performance.now() - t0;
345
346 const t1 = performance.now();
347 for (let i = 0; i < rounds; i++) constantTimeTextEqual(secret, late);
348 const lateMs = performance.now() - t1;
349
350 const ratio = earlyMs / lateMs;
351 assert.ok(
352 ratio > 0.25 && ratio < 4,
353 `timing ratio ${ratio.toFixed(3)} (early=${earlyMs.toFixed(2)}ms late=${lateMs.toFixed(2)}ms) suggests position-dependent compare`
354 );
355 });
356
357 test('SEC-KN-1 empty-secret deny still holds (P14 must not reopen P1)', () => {
358 assert.equal(gatewayAuthorized('', undefined), false);
359 assert.equal(gatewayAuthorized('', ''), false);
360 assert.equal(gatewayAuthorized('', 'forged'), false);
361 });
362 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago