bridge-internal-hmac.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Inter-function HMAC authentication for the bridge. |
| 3 | * |
| 4 | * The synchronous bridge function (`netlify/functions/bridge.mjs`) needs to kick |
| 5 | * off the background indexing function (`netlify/functions/bridge-index-background.mjs`) |
| 6 | * via an HTTP POST to its public Netlify endpoint. Because that endpoint is |
| 7 | * publicly addressable on the internet, we MUST require proof that the request |
| 8 | * actually came from the sync bridge — otherwise an attacker who finds the URL |
| 9 | * could trigger arbitrary background re-indexes against any vaultId they can guess |
| 10 | * (and burn the operator's DeepInfra budget). |
| 11 | * |
| 12 | * Defense in depth: |
| 13 | * 1. The user JWT is forwarded in the Authorization header (so the background |
| 14 | * function still runs `requireBridgeAuth` and the user must be a real one). |
| 15 | * 2. THIS module's HMAC adds a second signature proving the request came from |
| 16 | * the bridge sync function — an attacker would need both the user's JWT |
| 17 | * AND the bridge's `SESSION_SECRET` to forge a request, which means they |
| 18 | * have to compromise the operator's Netlify env, in which case all bets |
| 19 | * are off anyway. |
| 20 | * 3. The signature includes a UNIX timestamp; we reject signatures > 60 s old |
| 21 | * to prevent replay of an intercepted request. |
| 22 | * |
| 23 | * Pure module: no I/O, no fetch. Tests inject `now` for deterministic clock. |
| 24 | */ |
| 25 | |
| 26 | import crypto from 'crypto'; |
| 27 | |
| 28 | /** |
| 29 | * Replay window. 60 s is generous for inter-function HTTP latency (P99 ~1 s |
| 30 | * inside the same Netlify region) but small enough that a captured signature |
| 31 | * cannot be replayed hours later. |
| 32 | */ |
| 33 | export const HMAC_REPLAY_WINDOW_MS = 60 * 1000; |
| 34 | |
| 35 | /** |
| 36 | * Build the canonical message that gets signed. Centralized so signer + verifier |
| 37 | * cannot drift out of sync. |
| 38 | * |
| 39 | * @param {{ canisterUid: string, vaultId: string, jobId: string, ts: number }} payload |
| 40 | * @returns {string} |
| 41 | */ |
| 42 | export function canonicalMessage(payload) { |
| 43 | if (payload == null || typeof payload !== 'object') { |
| 44 | throw new TypeError('canonicalMessage: payload is required'); |
| 45 | } |
| 46 | const { canisterUid, vaultId, jobId, ts } = payload; |
| 47 | if (typeof canisterUid !== 'string' || canisterUid === '') { |
| 48 | throw new TypeError('canonicalMessage: canisterUid must be a non-empty string'); |
| 49 | } |
| 50 | if (typeof vaultId !== 'string' || vaultId === '') { |
| 51 | throw new TypeError('canonicalMessage: vaultId must be a non-empty string'); |
| 52 | } |
| 53 | if (typeof jobId !== 'string' || jobId === '') { |
| 54 | throw new TypeError('canonicalMessage: jobId must be a non-empty string'); |
| 55 | } |
| 56 | if (!Number.isFinite(ts)) { |
| 57 | throw new TypeError('canonicalMessage: ts must be a finite number (epoch ms)'); |
| 58 | } |
| 59 | return `bridge-index-background\n${canisterUid}\n${vaultId}\n${jobId}\n${ts}`; |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Sign the canonical message with HMAC-SHA256 using the bridge `SESSION_SECRET`. |
| 64 | * Returns hex digest. |
| 65 | * |
| 66 | * @param {string} secret - Bridge `SESSION_SECRET` (must be the same in both |
| 67 | * the sync function and the background function — Netlify's per-site env vars |
| 68 | * guarantee this in production). |
| 69 | * @param {{ canisterUid: string, vaultId: string, jobId: string, ts: number }} payload |
| 70 | * @returns {string} 64-char hex. |
| 71 | */ |
| 72 | export function signInternalRequest(secret, payload) { |
| 73 | if (typeof secret !== 'string' || secret === '') { |
| 74 | throw new TypeError('signInternalRequest: secret must be a non-empty string'); |
| 75 | } |
| 76 | return crypto.createHmac('sha256', secret).update(canonicalMessage(payload)).digest('hex'); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Verify a signature on the receiving end (background function). Returns |
| 81 | * `{ ok: true, payload }` on success, or `{ ok: false, reason }` for one of: |
| 82 | * - `'missing_secret'` — server misconfiguration; the operator must set |
| 83 | * SESSION_SECRET on the bridge background site (same value as sync site). |
| 84 | * - `'missing_header'` — caller did not include the required headers. |
| 85 | * - `'bad_timestamp'` — `ts` is not a finite number. |
| 86 | * - `'expired'` — the signature is older than the replay window. |
| 87 | * - `'bad_signature'` — HMAC mismatch (forged or wrong secret). |
| 88 | * |
| 89 | * Uses `crypto.timingSafeEqual` to avoid leaking signature bytes via timing. |
| 90 | * |
| 91 | * @param {string|undefined|null} secret - Bridge `SESSION_SECRET`. |
| 92 | * @param {{ |
| 93 | * canisterUid?: string, |
| 94 | * vaultId?: string, |
| 95 | * jobId?: string, |
| 96 | * ts?: string|number, |
| 97 | * sig?: string, |
| 98 | * now?: () => number, |
| 99 | * replayWindowMs?: number, |
| 100 | * }} headers |
| 101 | * @returns {{ ok: true, payload: { canisterUid: string, vaultId: string, jobId: string, ts: number } } | { ok: false, reason: string }} |
| 102 | */ |
| 103 | export function verifyInternalRequest(secret, headers) { |
| 104 | if (typeof secret !== 'string' || secret === '') { |
| 105 | return { ok: false, reason: 'missing_secret' }; |
| 106 | } |
| 107 | if (headers == null || typeof headers !== 'object') { |
| 108 | return { ok: false, reason: 'missing_header' }; |
| 109 | } |
| 110 | const canisterUid = stringOrEmpty(headers.canisterUid); |
| 111 | const vaultId = stringOrEmpty(headers.vaultId); |
| 112 | const jobId = stringOrEmpty(headers.jobId); |
| 113 | const sig = stringOrEmpty(headers.sig); |
| 114 | if (canisterUid === '' || vaultId === '' || jobId === '' || sig === '') { |
| 115 | return { ok: false, reason: 'missing_header' }; |
| 116 | } |
| 117 | const tsRaw = headers.ts; |
| 118 | const ts = typeof tsRaw === 'number' ? tsRaw : parseInt(String(tsRaw || ''), 10); |
| 119 | if (!Number.isFinite(ts)) return { ok: false, reason: 'bad_timestamp' }; |
| 120 | |
| 121 | const now = typeof headers.now === 'function' ? headers.now : Date.now; |
| 122 | const replayWindowMs = Number.isFinite(headers.replayWindowMs) |
| 123 | ? headers.replayWindowMs |
| 124 | : HMAC_REPLAY_WINDOW_MS; |
| 125 | const drift = Math.abs(now() - ts); |
| 126 | if (drift > replayWindowMs) return { ok: false, reason: 'expired' }; |
| 127 | |
| 128 | const expected = signInternalRequest(secret, { canisterUid, vaultId, jobId, ts }); |
| 129 | // Both buffers must be the same length for timingSafeEqual; sig length should |
| 130 | // equal expected length for HMAC-SHA256 hex (64 chars), but reject early if not. |
| 131 | if (sig.length !== expected.length) return { ok: false, reason: 'bad_signature' }; |
| 132 | let ok; |
| 133 | try { |
| 134 | ok = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex')); |
| 135 | } catch (_) { |
| 136 | return { ok: false, reason: 'bad_signature' }; |
| 137 | } |
| 138 | if (!ok) return { ok: false, reason: 'bad_signature' }; |
| 139 | return { ok: true, payload: { canisterUid, vaultId, jobId, ts } }; |
| 140 | } |
| 141 | |
| 142 | function stringOrEmpty(value) { |
| 143 | if (value == null) return ''; |
| 144 | return String(value); |
| 145 | } |
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
1 day ago