bridge-internal-hmac.test.mjs
223 lines 6.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Unit tests for `lib/bridge-internal-hmac.mjs`.
3 *
4 * The HMAC is the only thing keeping a publicly-addressable Netlify background
5 * function endpoint from being abused by anyone who finds the URL. A regression
6 * here either:
7 * - lets a forged request trigger arbitrary background re-indexes (security),
8 * - rejects legitimate sync→background calls (availability).
9 *
10 * Both are covered explicitly.
11 */
12
13 import test from 'node:test';
14 import assert from 'node:assert/strict';
15 import {
16 signInternalRequest,
17 verifyInternalRequest,
18 canonicalMessage,
19 HMAC_REPLAY_WINDOW_MS,
20 } from '../lib/bridge-internal-hmac.mjs';
21
22 const SECRET = 'test-bridge-session-secret-do-not-deploy';
23
24 test('canonicalMessage: stable, includes scheme prefix to prevent cross-purpose reuse', () => {
25 const msg = canonicalMessage({
26 canisterUid: 'user_1',
27 vaultId: 'Business',
28 jobId: 'abc',
29 ts: 1700000000000,
30 });
31 assert.strictEqual(
32 msg,
33 'bridge-index-background\nuser_1\nBusiness\nabc\n1700000000000',
34 'message format must be canonical for cross-process verification',
35 );
36 });
37
38 test('canonicalMessage: rejects missing fields (caller bug, must not silently sign empty)', () => {
39 assert.throws(
40 () => canonicalMessage({ vaultId: 'v', jobId: 'j', ts: 1 }),
41 /canisterUid must be a non-empty string/,
42 );
43 assert.throws(
44 () => canonicalMessage({ canisterUid: 'u', jobId: 'j', ts: 1 }),
45 /vaultId must be a non-empty string/,
46 );
47 assert.throws(
48 () => canonicalMessage({ canisterUid: 'u', vaultId: 'v', ts: 1 }),
49 /jobId must be a non-empty string/,
50 );
51 assert.throws(
52 () => canonicalMessage({ canisterUid: 'u', vaultId: 'v', jobId: 'j' }),
53 /ts must be a finite number/,
54 );
55 });
56
57 test('signInternalRequest + verifyInternalRequest: round-trip valid request', () => {
58 const ts = 1700000000000;
59 const sig = signInternalRequest(SECRET, {
60 canisterUid: 'user_1',
61 vaultId: 'Business',
62 jobId: 'job-1',
63 ts,
64 });
65 const verified = verifyInternalRequest(SECRET, {
66 canisterUid: 'user_1',
67 vaultId: 'Business',
68 jobId: 'job-1',
69 ts,
70 sig,
71 now: () => ts + 1000, // 1 s later, well within replay window
72 });
73 assert.strictEqual(verified.ok, true);
74 assert.deepStrictEqual(verified.payload, {
75 canisterUid: 'user_1',
76 vaultId: 'Business',
77 jobId: 'job-1',
78 ts,
79 });
80 });
81
82 test('verifyInternalRequest: tampered field → bad_signature', () => {
83 const ts = 1700000000000;
84 const sig = signInternalRequest(SECRET, {
85 canisterUid: 'user_1',
86 vaultId: 'Business',
87 jobId: 'job-1',
88 ts,
89 });
90 // Attacker swaps to a different vault but reuses the signature.
91 const verified = verifyInternalRequest(SECRET, {
92 canisterUid: 'user_1',
93 vaultId: 'Personal',
94 jobId: 'job-1',
95 ts,
96 sig,
97 now: () => ts,
98 });
99 assert.deepStrictEqual(verified, { ok: false, reason: 'bad_signature' });
100 });
101
102 test('verifyInternalRequest: wrong secret → bad_signature (cannot forge without env access)', () => {
103 const ts = 1700000000000;
104 const sig = signInternalRequest('wrong-secret', {
105 canisterUid: 'user_1',
106 vaultId: 'Business',
107 jobId: 'job-1',
108 ts,
109 });
110 const verified = verifyInternalRequest(SECRET, {
111 canisterUid: 'user_1',
112 vaultId: 'Business',
113 jobId: 'job-1',
114 ts,
115 sig,
116 now: () => ts,
117 });
118 assert.deepStrictEqual(verified, { ok: false, reason: 'bad_signature' });
119 });
120
121 test('verifyInternalRequest: signature older than replay window → expired', () => {
122 const ts = 1700000000000;
123 const sig = signInternalRequest(SECRET, {
124 canisterUid: 'user_1',
125 vaultId: 'Business',
126 jobId: 'job-1',
127 ts,
128 });
129 const verified = verifyInternalRequest(SECRET, {
130 canisterUid: 'user_1',
131 vaultId: 'Business',
132 jobId: 'job-1',
133 ts,
134 sig,
135 now: () => ts + HMAC_REPLAY_WINDOW_MS + 1,
136 });
137 assert.deepStrictEqual(verified, { ok: false, reason: 'expired' });
138 });
139
140 test('verifyInternalRequest: future signature beyond window → expired', () => {
141 const ts = 1700000000000;
142 const sig = signInternalRequest(SECRET, {
143 canisterUid: 'user_1',
144 vaultId: 'Business',
145 jobId: 'job-1',
146 ts,
147 });
148 const verified = verifyInternalRequest(SECRET, {
149 canisterUid: 'user_1',
150 vaultId: 'Business',
151 jobId: 'job-1',
152 ts,
153 sig,
154 // Receiver clock is way behind sender's → still must reject (clock skew limit).
155 now: () => ts - HMAC_REPLAY_WINDOW_MS - 1,
156 });
157 assert.deepStrictEqual(verified, { ok: false, reason: 'expired' });
158 });
159
160 test('verifyInternalRequest: missing/empty headers → missing_header', () => {
161 const verified = verifyInternalRequest(SECRET, {
162 canisterUid: 'user_1',
163 vaultId: '',
164 jobId: 'j',
165 ts: 1,
166 sig: 'abc',
167 });
168 assert.strictEqual(verified.ok, false);
169 assert.strictEqual(verified.reason, 'missing_header');
170 });
171
172 test('verifyInternalRequest: missing secret on receiver → missing_secret (server misconfig)', () => {
173 const verified = verifyInternalRequest('', {
174 canisterUid: 'u',
175 vaultId: 'v',
176 jobId: 'j',
177 ts: 1,
178 sig: 'abc',
179 });
180 assert.deepStrictEqual(verified, { ok: false, reason: 'missing_secret' });
181 });
182
183 test('verifyInternalRequest: non-numeric timestamp string → bad_timestamp', () => {
184 const verified = verifyInternalRequest(SECRET, {
185 canisterUid: 'u',
186 vaultId: 'v',
187 jobId: 'j',
188 ts: 'not-a-number',
189 sig: 'a'.repeat(64),
190 });
191 assert.deepStrictEqual(verified, { ok: false, reason: 'bad_timestamp' });
192 });
193
194 test('verifyInternalRequest: numeric-string timestamp parses correctly', () => {
195 const ts = 1700000000000;
196 const sig = signInternalRequest(SECRET, {
197 canisterUid: 'u',
198 vaultId: 'v',
199 jobId: 'j',
200 ts,
201 });
202 const verified = verifyInternalRequest(SECRET, {
203 canisterUid: 'u',
204 vaultId: 'v',
205 jobId: 'j',
206 ts: String(ts), // headers come as strings
207 sig,
208 now: () => ts,
209 });
210 assert.strictEqual(verified.ok, true);
211 });
212
213 test('verifyInternalRequest: malformed sig (wrong length) → bad_signature without timing leak', () => {
214 const verified = verifyInternalRequest(SECRET, {
215 canisterUid: 'u',
216 vaultId: 'v',
217 jobId: 'j',
218 ts: 1700000000000,
219 sig: 'too-short',
220 now: () => 1700000000000,
221 });
222 assert.deepStrictEqual(verified, { ok: false, reason: 'bad_signature' });
223 });
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 2 days ago