spck 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,372 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ /**
3
+ * Tests for HMAC message signing validation
4
+ */
5
+ import * as crypto from 'crypto';
6
+ import { validateHMAC, requireValidHMAC, clearNonces, getNonceStats } from '../hmac.js';
7
+ import { ErrorCode } from '../../types.js';
8
+ describe('HMAC Validation', () => {
9
+ const signingKey = 'test-signing-key-12345';
10
+ // Clear nonces before each test
11
+ beforeEach(() => {
12
+ clearNonces();
13
+ });
14
+ function createSignedMessage(method, params, timestamp, customHmac, nonce, deviceId) {
15
+ const ts = timestamp || Date.now();
16
+ const nonceValue = nonce || crypto.randomBytes(16).toString('hex');
17
+ const payload = {
18
+ jsonrpc: '2.0',
19
+ method,
20
+ params,
21
+ id: 1,
22
+ nonce: nonceValue,
23
+ };
24
+ // Include deviceId if provided (must be in payload for HMAC calculation)
25
+ if (deviceId) {
26
+ payload.deviceId = deviceId;
27
+ }
28
+ const messageToSign = ts + JSON.stringify(payload);
29
+ const hmac = customHmac ||
30
+ crypto.createHmac('sha256', signingKey).update(messageToSign).digest('hex');
31
+ return {
32
+ ...payload,
33
+ timestamp: ts,
34
+ hmac,
35
+ nonce: nonceValue,
36
+ };
37
+ }
38
+ describe('validateHMAC', () => {
39
+ it('should validate correctly signed message', () => {
40
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
41
+ const isValid = validateHMAC(message, signingKey);
42
+ expect(isValid).toBe(true);
43
+ });
44
+ it('should reject message with invalid HMAC', () => {
45
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, Date.now(), 'invalid-hmac-signature');
46
+ const isValid = validateHMAC(message, signingKey);
47
+ expect(isValid).toBe(false);
48
+ });
49
+ it('should reject message with missing HMAC', () => {
50
+ const message = {
51
+ jsonrpc: '2.0',
52
+ method: 'fs.readFile',
53
+ params: { path: '/test.txt' },
54
+ id: 1,
55
+ timestamp: Date.now(),
56
+ nonce: crypto.randomBytes(16).toString('hex'),
57
+ // hmac intentionally missing
58
+ };
59
+ const isValid = validateHMAC(message, signingKey);
60
+ expect(isValid).toBe(false);
61
+ });
62
+ it('should reject message with missing timestamp', () => {
63
+ const message = {
64
+ jsonrpc: '2.0',
65
+ method: 'fs.readFile',
66
+ params: { path: '/test.txt' },
67
+ id: 1,
68
+ hmac: 'some-hmac',
69
+ nonce: crypto.randomBytes(16).toString('hex'),
70
+ // timestamp intentionally missing
71
+ };
72
+ const isValid = validateHMAC(message, signingKey);
73
+ expect(isValid).toBe(false);
74
+ });
75
+ it('should reject message signed with wrong key', () => {
76
+ const wrongKey = 'different-signing-key';
77
+ const nonce = crypto.randomBytes(16).toString('hex');
78
+ const payload = {
79
+ jsonrpc: '2.0',
80
+ method: 'fs.readFile',
81
+ params: { path: '/test.txt' },
82
+ id: 1,
83
+ nonce,
84
+ };
85
+ const timestamp = Date.now();
86
+ const messageToSign = timestamp + JSON.stringify(payload);
87
+ const hmac = crypto
88
+ .createHmac('sha256', wrongKey)
89
+ .update(messageToSign)
90
+ .digest('hex');
91
+ const message = {
92
+ ...payload,
93
+ timestamp,
94
+ hmac,
95
+ nonce,
96
+ };
97
+ const isValid = validateHMAC(message, signingKey);
98
+ expect(isValid).toBe(false);
99
+ });
100
+ it('should reject message with tampered params', () => {
101
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
102
+ // Tamper with params after signing
103
+ message.params = { path: '/tampered.txt' };
104
+ const isValid = validateHMAC(message, signingKey);
105
+ expect(isValid).toBe(false);
106
+ });
107
+ it('should reject message with tampered method', () => {
108
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
109
+ // Tamper with method after signing
110
+ message.method = 'fs.deleteFile';
111
+ const isValid = validateHMAC(message, signingKey);
112
+ expect(isValid).toBe(false);
113
+ });
114
+ it('should validate message with deviceId', () => {
115
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, undefined, 'device-123');
116
+ const isValid = validateHMAC(message, signingKey);
117
+ expect(isValid).toBe(true);
118
+ });
119
+ it('should reject message when deviceId is tampered', () => {
120
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, undefined, 'device-123');
121
+ // Tamper with deviceId after signing
122
+ message.deviceId = 'device-456';
123
+ const isValid = validateHMAC(message, signingKey);
124
+ expect(isValid).toBe(false);
125
+ });
126
+ it('should use constant-time comparison for HMAC', () => {
127
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
128
+ // Measure time for correct HMAC
129
+ const startCorrect = process.hrtime.bigint();
130
+ validateHMAC(message, signingKey);
131
+ const correctTime = process.hrtime.bigint() - startCorrect;
132
+ // Measure time for incorrect HMAC (same length)
133
+ message.hmac = 'a'.repeat(message.hmac.length);
134
+ const startIncorrect = process.hrtime.bigint();
135
+ validateHMAC(message, signingKey);
136
+ const incorrectTime = process.hrtime.bigint() - startIncorrect;
137
+ // Time difference should be minimal (constant-time comparison)
138
+ // Allow up to 10x difference for timing variance
139
+ const timeDiff = Number(incorrectTime - correctTime);
140
+ const avgTime = Number((correctTime + incorrectTime) / BigInt(2));
141
+ const relativeDiff = Math.abs(timeDiff) / avgTime;
142
+ expect(relativeDiff).toBeLessThan(10);
143
+ });
144
+ });
145
+ describe('requireValidHMAC', () => {
146
+ it('should pass for valid HMAC', () => {
147
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
148
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
149
+ });
150
+ it('should throw error for invalid HMAC', () => {
151
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, Date.now(), 'invalid-hmac');
152
+ expect(() => requireValidHMAC(message, signingKey)).toThrow(expect.objectContaining({
153
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
154
+ message: expect.stringContaining('HMAC validation failed'),
155
+ }));
156
+ });
157
+ it('should reject message with old timestamp', () => {
158
+ const threeMinutesAgo = Date.now() - 3 * 60 * 1000;
159
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, threeMinutesAgo);
160
+ expect(() => requireValidHMAC(message, signingKey)).toThrow(expect.objectContaining({
161
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
162
+ message: expect.stringContaining('timestamp too old'),
163
+ }));
164
+ });
165
+ it('should accept message within 2 minute window', () => {
166
+ const oneMinuteAgo = Date.now() - 1 * 60 * 1000;
167
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, oneMinuteAgo);
168
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
169
+ });
170
+ it('should allow 1 minute clock skew for future timestamps', () => {
171
+ const thirtySecondsInFuture = Date.now() + 30 * 1000;
172
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, thirtySecondsInFuture);
173
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
174
+ });
175
+ it('should reject timestamp more than 1 minute in future', () => {
176
+ const twoMinutesInFuture = Date.now() + 2 * 60 * 1000;
177
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, twoMinutesInFuture);
178
+ expect(() => requireValidHMAC(message, signingKey)).toThrow(expect.objectContaining({
179
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
180
+ message: expect.stringContaining('timestamp too old or invalid'),
181
+ }));
182
+ });
183
+ it('should include timestamp details in error', () => {
184
+ const threeMinutesAgo = Date.now() - 3 * 60 * 1000;
185
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, threeMinutesAgo);
186
+ try {
187
+ requireValidHMAC(message, signingKey);
188
+ expect.unreachable('Should have thrown error');
189
+ }
190
+ catch (error) {
191
+ expect(error.data).toMatchObject({
192
+ timestamp: threeMinutesAgo,
193
+ serverTime: expect.any(Number),
194
+ });
195
+ }
196
+ });
197
+ });
198
+ describe('Edge Cases', () => {
199
+ it('should handle empty params', () => {
200
+ const message = createSignedMessage('terminal.create', {});
201
+ const isValid = validateHMAC(message, signingKey);
202
+ expect(isValid).toBe(true);
203
+ });
204
+ it('should handle params with special characters', () => {
205
+ const message = createSignedMessage('fs.writeFile', {
206
+ path: '/test.txt',
207
+ content: 'Special chars: 你好 émojis 🚀',
208
+ });
209
+ const isValid = validateHMAC(message, signingKey);
210
+ expect(isValid).toBe(true);
211
+ });
212
+ it('should handle params with nested objects', () => {
213
+ const message = createSignedMessage('git.commit', {
214
+ dir: '/project',
215
+ message: 'Test commit',
216
+ author: {
217
+ name: 'Test User',
218
+ email: 'test@example.com',
219
+ timestamp: 1234567890,
220
+ },
221
+ });
222
+ const isValid = validateHMAC(message, signingKey);
223
+ expect(isValid).toBe(true);
224
+ });
225
+ it('should handle params with arrays', () => {
226
+ const message = createSignedMessage('git.add', {
227
+ dir: '/project',
228
+ filepaths: ['file1.txt', 'file2.txt', 'file3.txt'],
229
+ });
230
+ const isValid = validateHMAC(message, signingKey);
231
+ expect(isValid).toBe(true);
232
+ });
233
+ it('should handle very long signing keys', () => {
234
+ const longKey = 'x'.repeat(1000);
235
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
236
+ // Re-sign with long key
237
+ const payload = {
238
+ jsonrpc: message.jsonrpc,
239
+ method: message.method,
240
+ params: message.params,
241
+ id: message.id,
242
+ nonce: message.nonce,
243
+ };
244
+ const messageToSign = message.timestamp + JSON.stringify(payload);
245
+ message.hmac = crypto
246
+ .createHmac('sha256', longKey)
247
+ .update(messageToSign)
248
+ .digest('hex');
249
+ const isValid = validateHMAC(message, longKey);
250
+ expect(isValid).toBe(true);
251
+ });
252
+ });
253
+ describe('Replay Attack Prevention', () => {
254
+ it('should accept message with nonce on first use', () => {
255
+ const nonce = crypto.randomBytes(16).toString('hex');
256
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
257
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
258
+ });
259
+ it('should reject duplicate nonce (replay attack)', () => {
260
+ const nonce = crypto.randomBytes(16).toString('hex');
261
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
262
+ // First use should succeed
263
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
264
+ // Second use should fail (replay attack)
265
+ const replayMessage = createSignedMessage('fs.readFile', { path: '/test.txt' }, message.timestamp, undefined, nonce);
266
+ expect(() => requireValidHMAC(replayMessage, signingKey)).toThrow(expect.objectContaining({
267
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
268
+ message: expect.stringContaining('Duplicate nonce'),
269
+ }));
270
+ });
271
+ it('should include nonce in error data when rejecting replay', () => {
272
+ const nonce = crypto.randomBytes(16).toString('hex');
273
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
274
+ // First use
275
+ requireValidHMAC(message, signingKey);
276
+ // Second use (replay)
277
+ const replayMessage = createSignedMessage('fs.readFile', { path: '/test.txt' }, message.timestamp, undefined, nonce);
278
+ try {
279
+ requireValidHMAC(replayMessage, signingKey);
280
+ expect.unreachable('Should have thrown error');
281
+ }
282
+ catch (error) {
283
+ expect(error.data).toMatchObject({
284
+ nonce: nonce,
285
+ });
286
+ }
287
+ });
288
+ it('should accept different nonces', () => {
289
+ const nonce1 = crypto.randomBytes(16).toString('hex');
290
+ const nonce2 = crypto.randomBytes(16).toString('hex');
291
+ const message1 = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce1);
292
+ const message2 = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce2);
293
+ expect(() => requireValidHMAC(message1, signingKey)).not.toThrow();
294
+ expect(() => requireValidHMAC(message2, signingKey)).not.toThrow();
295
+ });
296
+ it('should validate nonce signature correctly', () => {
297
+ const nonce = crypto.randomBytes(16).toString('hex');
298
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
299
+ const isValid = validateHMAC(message, signingKey);
300
+ expect(isValid).toBe(true);
301
+ });
302
+ it('should reject message with tampered nonce', () => {
303
+ const nonce = crypto.randomBytes(16).toString('hex');
304
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
305
+ // Tamper with nonce after signing
306
+ message.nonce = crypto.randomBytes(16).toString('hex');
307
+ const isValid = validateHMAC(message, signingKey);
308
+ expect(isValid).toBe(false);
309
+ });
310
+ it('should track nonce statistics correctly', () => {
311
+ clearNonces();
312
+ const nonce1 = crypto.randomBytes(16).toString('hex');
313
+ const nonce2 = crypto.randomBytes(16).toString('hex');
314
+ const message1 = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce1);
315
+ const message2 = createSignedMessage('fs.writeFile', { path: '/test.txt' }, undefined, undefined, nonce2);
316
+ requireValidHMAC(message1, signingKey);
317
+ requireValidHMAC(message2, signingKey);
318
+ const stats = getNonceStats();
319
+ expect(stats.total).toBe(2);
320
+ expect(stats.active).toBe(2);
321
+ });
322
+ it('should prevent replay flood attack', () => {
323
+ const nonce = crypto.randomBytes(16).toString('hex');
324
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
325
+ // First request succeeds
326
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
327
+ // Flood with replays (all should fail)
328
+ for (let i = 0; i < 10; i++) {
329
+ const replayMessage = createSignedMessage('fs.readFile', { path: '/test.txt' }, message.timestamp, undefined, nonce);
330
+ expect(() => requireValidHMAC(replayMessage, signingKey)).toThrow(expect.objectContaining({
331
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
332
+ message: expect.stringContaining('Duplicate nonce'),
333
+ }));
334
+ }
335
+ });
336
+ it('should handle concurrent requests with different nonces', () => {
337
+ const nonces = Array.from({ length: 100 }, () => crypto.randomBytes(16).toString('hex'));
338
+ // All should succeed (different nonces)
339
+ for (const nonce of nonces) {
340
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
341
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
342
+ }
343
+ const stats = getNonceStats();
344
+ expect(stats.total).toBe(100);
345
+ expect(stats.active).toBe(100);
346
+ });
347
+ it('should clean up nonces when map grows too large', () => {
348
+ clearNonces();
349
+ // Add old nonces (expired)
350
+ const oldTimestamp = Date.now() - 3 * 60 * 1000; // 3 minutes ago
351
+ for (let i = 0; i < 5; i++) {
352
+ const nonce = `old-nonce-${i}`;
353
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, oldTimestamp, undefined, nonce);
354
+ // These will fail due to old timestamp, but that's ok for this test
355
+ try {
356
+ requireValidHMAC(message, signingKey);
357
+ }
358
+ catch { }
359
+ }
360
+ // Add many new nonces to trigger cleanup
361
+ for (let i = 0; i < 10001; i++) {
362
+ const nonce = `new-nonce-${i}`;
363
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
364
+ requireValidHMAC(message, signingKey);
365
+ }
366
+ // Emergency cleanup should have been triggered
367
+ const stats = getNonceStats();
368
+ expect(stats.active).toBeLessThanOrEqual(10001);
369
+ });
370
+ });
371
+ });
372
+ //# sourceMappingURL=hmac.test.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Firebase JWT authentication with public key verification
3
+ */
4
+ import { JWTPayload } from '../types.js';
5
+ /**
6
+ * Verify Firebase JWT token
7
+ */
8
+ export declare function verifyFirebaseToken(token: string, firebaseProjectId: string, allowedUids: string[]): Promise<JWTPayload>;
9
+ /**
10
+ * Clear public keys cache (for testing)
11
+ */
12
+ export declare function clearPublicKeysCache(): void;
13
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Firebase JWT authentication with public key verification
3
+ */
4
+ import jwt from 'jsonwebtoken';
5
+ import fetch from 'node-fetch';
6
+ import { ErrorCode, createRPCError } from '../types.js';
7
+ const FIREBASE_KEYS_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
8
+ let publicKeysCache = null;
9
+ /**
10
+ * Fetch Firebase public keys with caching
11
+ */
12
+ async function getFirebasePublicKeys() {
13
+ const now = Date.now();
14
+ // Return cached keys if still valid
15
+ if (publicKeysCache && publicKeysCache.expiresAt > now) {
16
+ return publicKeysCache.keys;
17
+ }
18
+ // Fetch new keys
19
+ const response = await fetch(FIREBASE_KEYS_URL);
20
+ if (!response.ok) {
21
+ throw new Error(`Failed to fetch Firebase public keys: ${response.statusText}`);
22
+ }
23
+ const keys = await response.json();
24
+ // Parse cache-control header for expiration
25
+ const cacheControl = response.headers.get('cache-control');
26
+ let maxAge = 3600; // Default: 1 hour
27
+ if (cacheControl) {
28
+ const match = cacheControl.match(/max-age=(\d+)/);
29
+ if (match) {
30
+ maxAge = parseInt(match[1], 10);
31
+ }
32
+ }
33
+ // Cache the keys
34
+ publicKeysCache = {
35
+ keys,
36
+ expiresAt: now + maxAge * 1000,
37
+ };
38
+ return keys;
39
+ }
40
+ /**
41
+ * Verify Firebase JWT token
42
+ */
43
+ export async function verifyFirebaseToken(token, firebaseProjectId, allowedUids) {
44
+ try {
45
+ // Fetch public keys
46
+ const publicKeys = await getFirebasePublicKeys();
47
+ // Decode token header to get kid
48
+ const decoded = jwt.decode(token, { complete: true });
49
+ if (!decoded || typeof decoded === 'string') {
50
+ throw createRPCError(ErrorCode.AUTHENTICATION_FAILED, 'Invalid token format');
51
+ }
52
+ const kid = decoded.header.kid;
53
+ if (!kid || !publicKeys[kid]) {
54
+ throw createRPCError(ErrorCode.AUTHENTICATION_FAILED, 'Invalid token key ID');
55
+ }
56
+ // Verify token signature and claims
57
+ const publicKey = publicKeys[kid];
58
+ const payload = jwt.verify(token, publicKey, {
59
+ algorithms: ['RS256'],
60
+ audience: firebaseProjectId,
61
+ issuer: `https://securetoken.google.com/${firebaseProjectId}`,
62
+ });
63
+ // Validate UID is in allowed list (if list is not empty)
64
+ if (allowedUids.length > 0 && !allowedUids.includes(payload.sub)) {
65
+ throw createRPCError(ErrorCode.UID_NOT_AUTHORIZED, `UID not authorized: ${payload.sub}`, { uid: payload.uid });
66
+ }
67
+ return payload;
68
+ }
69
+ catch (error) {
70
+ // Handle JWT errors
71
+ if (error.name === 'TokenExpiredError') {
72
+ throw createRPCError(ErrorCode.JWT_EXPIRED, 'JWT token expired', { expiredAt: error.expiredAt });
73
+ }
74
+ if (error.name === 'JsonWebTokenError') {
75
+ throw createRPCError(ErrorCode.AUTHENTICATION_FAILED, `JWT verification failed: ${error.message}`);
76
+ }
77
+ // Re-throw if already an RPC error
78
+ if (error.code && error.message) {
79
+ throw error;
80
+ }
81
+ // Generic error
82
+ throw createRPCError(ErrorCode.AUTHENTICATION_FAILED, `Authentication failed: ${error.message || 'Unknown error'}`);
83
+ }
84
+ }
85
+ /**
86
+ * Clear public keys cache (for testing)
87
+ */
88
+ export function clearPublicKeysCache() {
89
+ publicKeysCache = null;
90
+ }
91
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Firebase authentication with local callback server
3
+ * Opens browser for OAuth flow, captures token via localhost POST
4
+ *
5
+ * Security features:
6
+ * - Token sent via POST body (not in URL) to prevent leaking via browser history/referrer
7
+ * - State parameter for CSRF protection
8
+ * - Localhost-only callback server
9
+ */
10
+ import { FirebaseCredentials, StoredCredentials } from '../types.js';
11
+ /**
12
+ * Abort any in-progress authentication (e.g., on SIGINT).
13
+ * Cancels the pending fetch and closes the local callback server.
14
+ */
15
+ export declare function abortCurrentAuth(): void;
16
+ /**
17
+ * Authenticate with Firebase using secure local callback server
18
+ * Token is received via POST to prevent exposure in URLs
19
+ */
20
+ export declare function authenticateWithFirebase(): Promise<FirebaseCredentials>;
21
+ /**
22
+ * Refresh Firebase ID token using refresh token
23
+ *
24
+ * Firebase Token Refresh Protocol:
25
+ * - Endpoint: https://securetoken.googleapis.com/v1/token?key={API_KEY}
26
+ * - Method: POST with application/x-www-form-urlencoded
27
+ * - Body: grant_type=refresh_token&refresh_token={REFRESH_TOKEN}
28
+ * - Response: { id_token, refresh_token, expires_in, token_type, user_id }
29
+ * - ID tokens expire after 1 hour (3600 seconds)
30
+ * - Refresh tokens are long-lived but can be revoked
31
+ *
32
+ * @see https://firebase.google.com/docs/reference/rest/auth#section-refresh-token
33
+ */
34
+ export declare function refreshFirebaseToken(storedCredentials: StoredCredentials): Promise<FirebaseCredentials>;
35
+ /**
36
+ * Get a valid Firebase ID token by refreshing from stored credentials
37
+ * Always generates a fresh ID token using the refresh token
38
+ */
39
+ export declare function getValidFirebaseToken(storedCredentials: StoredCredentials): Promise<FirebaseCredentials>;
40
+ //# sourceMappingURL=firebase-auth.d.ts.map