bridge-internal-hmac.mjs
145 lines 6.2 KB
Raw
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