sec-kn-6-constant-time-secret-compare.test.mjs
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