gateway-authorized.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago
| 1 | /** |
| 2 | * Pure JS mirror of Motoko gateway / operator-export secret auth in |
| 3 | * `hub/icp/src/hub/main.mo` (SEC-KN-1 fail-closed + SEC-KN-6 constant-time compare). |
| 4 | * |
| 5 | * Contract (Pass 2 P1 / fail-closed): |
| 6 | * - Empty or missing `gateway_auth_secret` → DENY (never allow). |
| 7 | * - Missing / wrong-length / wrong `X-Gateway-Auth` → DENY. |
| 8 | * - Exact match → ALLOW. |
| 9 | * |
| 10 | * Contract (Pass 2 P14 / constant-time): |
| 11 | * - Equal-length secrets are compared by OR-of-XOR over every code point — no early |
| 12 | * exit on the first mismatch (mirrors Motoko `constantTimeTextEqual`). |
| 13 | * |
| 14 | * Health and OPTIONS bypass this check in the canister `http_request` handler |
| 15 | * (they return before calling `gatewayAuthorized`). Use |
| 16 | * {@link httpRequestRequiresGatewayAuth} to model that routing. |
| 17 | */ |
| 18 | |
| 19 | /** |
| 20 | * Constant-time Text equality (Motoko `constantTimeTextEqual` mirror). |
| 21 | * Length mismatch returns false without scanning content. Equal-length inputs always |
| 22 | * scan every Unicode scalar; result is whether the OR of pairwise XORs is zero. |
| 23 | * |
| 24 | * @param {unknown} a |
| 25 | * @param {unknown} b |
| 26 | * @returns {boolean} |
| 27 | */ |
| 28 | export function constantTimeTextEqual(a, b) { |
| 29 | if (typeof a !== 'string' || typeof b !== 'string') return false; |
| 30 | const aa = [...a]; |
| 31 | const bb = [...b]; |
| 32 | if (aa.length !== bb.length) return false; |
| 33 | let acc = 0; |
| 34 | for (let i = 0; i < aa.length; i++) { |
| 35 | acc |= aa[i].codePointAt(0) ^ bb[i].codePointAt(0); |
| 36 | } |
| 37 | return acc === 0; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Pre-fix P14 compare — Motoko `got == expected` after a length check. |
| 42 | * Short-circuits on the first differing character (timing oracle). |
| 43 | * |
| 44 | * @param {string} a |
| 45 | * @param {string} b |
| 46 | * @returns {boolean} |
| 47 | */ |
| 48 | export function textEqualEarlyExitLegacy(a, b) { |
| 49 | if (typeof a !== 'string' || typeof b !== 'string') return false; |
| 50 | if (a.length !== b.length) return false; |
| 51 | for (let i = 0; i < a.length; i++) { |
| 52 | if (a.charCodeAt(i) !== b.charCodeAt(i)) return false; |
| 53 | } |
| 54 | return true; |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * @param {string} gatewayAuthSecret — canister `storage.gateway_auth_secret` |
| 59 | * @param {string|null|undefined} headerValue — raw `X-Gateway-Auth` header value |
| 60 | * @returns {boolean} |
| 61 | */ |
| 62 | export function gatewayAuthorized(gatewayAuthSecret, headerValue) { |
| 63 | const expected = typeof gatewayAuthSecret === 'string' ? gatewayAuthSecret : ''; |
| 64 | if (expected.length === 0) return false; |
| 65 | if (headerValue === undefined || headerValue === null) return false; |
| 66 | if (typeof headerValue !== 'string') return false; |
| 67 | return constantTimeTextEqual(headerValue, expected); |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Mirror of Motoko `operatorExportAuthorized` (same fail-closed + constant-time contract). |
| 72 | * |
| 73 | * @param {string} operatorExportSecret |
| 74 | * @param {string|null|undefined} headerValue — raw `X-Operator-Export-Key` |
| 75 | * @returns {boolean} |
| 76 | */ |
| 77 | export function operatorExportAuthorized(operatorExportSecret, headerValue) { |
| 78 | const expected = typeof operatorExportSecret === 'string' ? operatorExportSecret : ''; |
| 79 | if (expected.length === 0) return false; |
| 80 | if (headerValue === undefined || headerValue === null) return false; |
| 81 | if (typeof headerValue !== 'string') return false; |
| 82 | return constantTimeTextEqual(headerValue, expected); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Whether a canister HTTP request must pass `gatewayAuthorized` before serving data. |
| 87 | * Mirrors order in `http_request`: health → OPTIONS → gatewayAuthorized → … |
| 88 | * |
| 89 | * @param {string} method — HTTP method |
| 90 | * @param {string} pathKind — first element of Motoko `parsePath` result (`health`, `vaults`, …) |
| 91 | * @returns {boolean} |
| 92 | */ |
| 93 | export function httpRequestRequiresGatewayAuth(method, pathKind) { |
| 94 | if (pathKind === 'health') return false; |
| 95 | if (String(method || '').toUpperCase() === 'OPTIONS') return false; |
| 96 | return true; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Loud health payload when gateway auth is unset — status remains 200 / ok:true. |
| 101 | * |
| 102 | * @param {string} gatewayAuthSecret |
| 103 | * @returns {{ ok: true, gateway_auth_configured: boolean }} |
| 104 | */ |
| 105 | export function healthPayload(gatewayAuthSecret) { |
| 106 | const configured = |
| 107 | typeof gatewayAuthSecret === 'string' && gatewayAuthSecret.length > 0; |
| 108 | return { ok: true, gateway_auth_configured: configured }; |
| 109 | } |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago