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.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- 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
|