@sideband/secure-relay 0.1.0 → 0.2.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/README.md +70 -11
- package/dist/LICENSE +190 -694
- package/package.json +1 -4
- package/src/constants.ts +29 -4
- package/src/crypto.test.ts +644 -0
- package/src/crypto.ts +1 -2
- package/src/frame.test.ts +820 -0
- package/src/frame.ts +771 -0
- package/src/handshake.test.ts +325 -0
- package/src/handshake.ts +7 -2
- package/src/index.ts +44 -2
- package/src/integration.test.ts +1025 -0
- package/src/replay.test.ts +306 -0
- package/src/replay.ts +1 -2
- package/src/session.test.ts +767 -0
- package/src/session.ts +1 -2
- package/src/types.ts +85 -18
- package/LICENSE +0 -190
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { randomBytes } from "./crypto.js";
|
|
5
|
+
import {
|
|
6
|
+
clearClientSession,
|
|
7
|
+
clearDaemonSession,
|
|
8
|
+
createClientSession,
|
|
9
|
+
createDaemonSession,
|
|
10
|
+
decryptClientToDaemon,
|
|
11
|
+
decryptDaemonToClient,
|
|
12
|
+
encryptClientToDaemon,
|
|
13
|
+
encryptDaemonToClient,
|
|
14
|
+
} from "./session.js";
|
|
15
|
+
import type { EncryptedMessage, SessionKeys } from "./types.js";
|
|
16
|
+
import { asClientId, SbrpError, SbrpErrorCode } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/** Create a pair of session keys for testing */
|
|
19
|
+
function createTestSessionKeys(): SessionKeys {
|
|
20
|
+
return {
|
|
21
|
+
clientToDaemon: randomBytes(32),
|
|
22
|
+
daemonToClient: randomBytes(32),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("session", () => {
|
|
27
|
+
describe("encrypt then decrypt roundtrip (client to daemon)", () => {
|
|
28
|
+
it("decrypts to original plaintext", () => {
|
|
29
|
+
const sessionKeys = createTestSessionKeys();
|
|
30
|
+
const clientId = asClientId("test-client-1");
|
|
31
|
+
|
|
32
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
33
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
34
|
+
|
|
35
|
+
const plaintext = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
36
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
37
|
+
const decrypted = decryptClientToDaemon(clientSession, encrypted);
|
|
38
|
+
|
|
39
|
+
expect(decrypted).toEqual(plaintext);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("works with empty plaintext", () => {
|
|
43
|
+
const sessionKeys = createTestSessionKeys();
|
|
44
|
+
const clientId = asClientId("test-client-2");
|
|
45
|
+
|
|
46
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
47
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
48
|
+
|
|
49
|
+
const plaintext = new Uint8Array(0);
|
|
50
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
51
|
+
const decrypted = decryptClientToDaemon(clientSession, encrypted);
|
|
52
|
+
|
|
53
|
+
expect(decrypted).toEqual(plaintext);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("works with large plaintext", () => {
|
|
57
|
+
const sessionKeys = createTestSessionKeys();
|
|
58
|
+
const clientId = asClientId("test-client-3");
|
|
59
|
+
|
|
60
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
61
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
62
|
+
|
|
63
|
+
const plaintext = randomBytes(65536); // 64 KB
|
|
64
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
65
|
+
const decrypted = decryptClientToDaemon(clientSession, encrypted);
|
|
66
|
+
|
|
67
|
+
expect(decrypted).toEqual(plaintext);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("encrypt then decrypt roundtrip (daemon to client)", () => {
|
|
72
|
+
it("decrypts to original plaintext", () => {
|
|
73
|
+
const sessionKeys = createTestSessionKeys();
|
|
74
|
+
const clientId = asClientId("test-client-4");
|
|
75
|
+
|
|
76
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
77
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
78
|
+
|
|
79
|
+
const plaintext = new Uint8Array([10, 20, 30, 40, 50]);
|
|
80
|
+
const encrypted = encryptDaemonToClient(clientSession, plaintext);
|
|
81
|
+
const decrypted = decryptDaemonToClient(daemonSession, encrypted);
|
|
82
|
+
|
|
83
|
+
expect(decrypted).toEqual(plaintext);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("works with empty plaintext", () => {
|
|
87
|
+
const sessionKeys = createTestSessionKeys();
|
|
88
|
+
const clientId = asClientId("test-client-5");
|
|
89
|
+
|
|
90
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
91
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
92
|
+
|
|
93
|
+
const plaintext = new Uint8Array(0);
|
|
94
|
+
const encrypted = encryptDaemonToClient(clientSession, plaintext);
|
|
95
|
+
const decrypted = decryptDaemonToClient(daemonSession, encrypted);
|
|
96
|
+
|
|
97
|
+
expect(decrypted).toEqual(plaintext);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("sequence numbers", () => {
|
|
102
|
+
it("increment correctly for client to daemon", () => {
|
|
103
|
+
const sessionKeys = createTestSessionKeys();
|
|
104
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
105
|
+
|
|
106
|
+
const msg1 = encryptClientToDaemon(daemonSession, new Uint8Array([1]));
|
|
107
|
+
const msg2 = encryptClientToDaemon(daemonSession, new Uint8Array([2]));
|
|
108
|
+
const msg3 = encryptClientToDaemon(daemonSession, new Uint8Array([3]));
|
|
109
|
+
|
|
110
|
+
expect(msg1.seq).toBe(0n);
|
|
111
|
+
expect(msg2.seq).toBe(1n);
|
|
112
|
+
expect(msg3.seq).toBe(2n);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("increment correctly for daemon to client", () => {
|
|
116
|
+
const sessionKeys = createTestSessionKeys();
|
|
117
|
+
const clientId = asClientId("test-client-6");
|
|
118
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
119
|
+
|
|
120
|
+
const msg1 = encryptDaemonToClient(clientSession, new Uint8Array([1]));
|
|
121
|
+
const msg2 = encryptDaemonToClient(clientSession, new Uint8Array([2]));
|
|
122
|
+
const msg3 = encryptDaemonToClient(clientSession, new Uint8Array([3]));
|
|
123
|
+
|
|
124
|
+
expect(msg1.seq).toBe(0n);
|
|
125
|
+
expect(msg2.seq).toBe(1n);
|
|
126
|
+
expect(msg3.seq).toBe(2n);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("replay detection", () => {
|
|
131
|
+
it("rejects duplicate sequence (client to daemon)", () => {
|
|
132
|
+
const sessionKeys = createTestSessionKeys();
|
|
133
|
+
const clientId = asClientId("test-client-7");
|
|
134
|
+
|
|
135
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
136
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
137
|
+
|
|
138
|
+
const plaintext = new Uint8Array([1, 2, 3]);
|
|
139
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
140
|
+
|
|
141
|
+
// First decryption succeeds
|
|
142
|
+
decryptClientToDaemon(clientSession, encrypted);
|
|
143
|
+
|
|
144
|
+
// Replay attempt fails
|
|
145
|
+
expect(() => decryptClientToDaemon(clientSession, encrypted)).toThrow(
|
|
146
|
+
SbrpError,
|
|
147
|
+
);
|
|
148
|
+
try {
|
|
149
|
+
decryptClientToDaemon(clientSession, encrypted);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
expect(e).toBeInstanceOf(SbrpError);
|
|
152
|
+
expect((e as SbrpError).code).toBe(SbrpErrorCode.SequenceError);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("rejects duplicate sequence (daemon to client)", () => {
|
|
157
|
+
const sessionKeys = createTestSessionKeys();
|
|
158
|
+
const clientId = asClientId("test-client-8");
|
|
159
|
+
|
|
160
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
161
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
162
|
+
|
|
163
|
+
const plaintext = new Uint8Array([4, 5, 6]);
|
|
164
|
+
const encrypted = encryptDaemonToClient(clientSession, plaintext);
|
|
165
|
+
|
|
166
|
+
// First decryption succeeds
|
|
167
|
+
decryptDaemonToClient(daemonSession, encrypted);
|
|
168
|
+
|
|
169
|
+
// Replay attempt fails
|
|
170
|
+
expect(() => decryptDaemonToClient(daemonSession, encrypted)).toThrow(
|
|
171
|
+
SbrpError,
|
|
172
|
+
);
|
|
173
|
+
try {
|
|
174
|
+
decryptDaemonToClient(daemonSession, encrypted);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
expect(e).toBeInstanceOf(SbrpError);
|
|
177
|
+
expect((e as SbrpError).code).toBe(SbrpErrorCode.SequenceError);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects too-old sequence (client to daemon)", () => {
|
|
182
|
+
const sessionKeys = createTestSessionKeys();
|
|
183
|
+
const clientId = asClientId("test-client-9");
|
|
184
|
+
|
|
185
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
186
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
187
|
+
|
|
188
|
+
// Encrypt 200 messages to advance the window (default window is 128)
|
|
189
|
+
const messages: EncryptedMessage[] = [];
|
|
190
|
+
for (let i = 0; i < 200; i++) {
|
|
191
|
+
messages.push(
|
|
192
|
+
encryptClientToDaemon(daemonSession, new Uint8Array([i % 256])),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Decrypt latest 128 (within window)
|
|
197
|
+
for (let i = 199; i >= 72; i--) {
|
|
198
|
+
decryptClientToDaemon(clientSession, messages[i]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Message with seq=0 is now outside the window (too old)
|
|
202
|
+
expect(() => decryptClientToDaemon(clientSession, messages[0])).toThrow(
|
|
203
|
+
SbrpError,
|
|
204
|
+
);
|
|
205
|
+
try {
|
|
206
|
+
decryptClientToDaemon(clientSession, messages[0]);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
expect(e).toBeInstanceOf(SbrpError);
|
|
209
|
+
expect((e as SbrpError).code).toBe(SbrpErrorCode.SequenceError);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects too-old sequence (daemon to client)", () => {
|
|
214
|
+
const sessionKeys = createTestSessionKeys();
|
|
215
|
+
const clientId = asClientId("test-client-10");
|
|
216
|
+
|
|
217
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
218
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
219
|
+
|
|
220
|
+
// Encrypt 200 messages to advance the window (default window is 128)
|
|
221
|
+
const messages: EncryptedMessage[] = [];
|
|
222
|
+
for (let i = 0; i < 200; i++) {
|
|
223
|
+
messages.push(
|
|
224
|
+
encryptDaemonToClient(clientSession, new Uint8Array([i % 256])),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Decrypt latest 128 (within window)
|
|
229
|
+
for (let i = 199; i >= 72; i--) {
|
|
230
|
+
decryptDaemonToClient(daemonSession, messages[i]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Message with seq=0 is now outside the window (too old)
|
|
234
|
+
expect(() => decryptDaemonToClient(daemonSession, messages[0])).toThrow(
|
|
235
|
+
SbrpError,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("accepts out-of-order within window (client to daemon)", () => {
|
|
240
|
+
const sessionKeys = createTestSessionKeys();
|
|
241
|
+
const clientId = asClientId("test-client-11");
|
|
242
|
+
|
|
243
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
244
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
245
|
+
|
|
246
|
+
const msg0 = encryptClientToDaemon(daemonSession, new Uint8Array([0]));
|
|
247
|
+
const msg1 = encryptClientToDaemon(daemonSession, new Uint8Array([1]));
|
|
248
|
+
const msg2 = encryptClientToDaemon(daemonSession, new Uint8Array([2]));
|
|
249
|
+
const msg3 = encryptClientToDaemon(daemonSession, new Uint8Array([3]));
|
|
250
|
+
|
|
251
|
+
// Decrypt out of order: 3, 1, 2, 0
|
|
252
|
+
expect(decryptClientToDaemon(clientSession, msg3)).toEqual(
|
|
253
|
+
new Uint8Array([3]),
|
|
254
|
+
);
|
|
255
|
+
expect(decryptClientToDaemon(clientSession, msg1)).toEqual(
|
|
256
|
+
new Uint8Array([1]),
|
|
257
|
+
);
|
|
258
|
+
expect(decryptClientToDaemon(clientSession, msg2)).toEqual(
|
|
259
|
+
new Uint8Array([2]),
|
|
260
|
+
);
|
|
261
|
+
expect(decryptClientToDaemon(clientSession, msg0)).toEqual(
|
|
262
|
+
new Uint8Array([0]),
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("accepts out-of-order within window (daemon to client)", () => {
|
|
267
|
+
const sessionKeys = createTestSessionKeys();
|
|
268
|
+
const clientId = asClientId("test-client-12");
|
|
269
|
+
|
|
270
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
271
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
272
|
+
|
|
273
|
+
const msg0 = encryptDaemonToClient(clientSession, new Uint8Array([0]));
|
|
274
|
+
const msg1 = encryptDaemonToClient(clientSession, new Uint8Array([1]));
|
|
275
|
+
const msg2 = encryptDaemonToClient(clientSession, new Uint8Array([2]));
|
|
276
|
+
const msg3 = encryptDaemonToClient(clientSession, new Uint8Array([3]));
|
|
277
|
+
|
|
278
|
+
// Decrypt out of order: 2, 0, 3, 1
|
|
279
|
+
expect(decryptDaemonToClient(daemonSession, msg2)).toEqual(
|
|
280
|
+
new Uint8Array([2]),
|
|
281
|
+
);
|
|
282
|
+
expect(decryptDaemonToClient(daemonSession, msg0)).toEqual(
|
|
283
|
+
new Uint8Array([0]),
|
|
284
|
+
);
|
|
285
|
+
expect(decryptDaemonToClient(daemonSession, msg3)).toEqual(
|
|
286
|
+
new Uint8Array([3]),
|
|
287
|
+
);
|
|
288
|
+
expect(decryptDaemonToClient(daemonSession, msg1)).toEqual(
|
|
289
|
+
new Uint8Array([1]),
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("decryption with wrong key", () => {
|
|
295
|
+
it("fails for client to daemon direction", () => {
|
|
296
|
+
const sessionKeys1 = createTestSessionKeys();
|
|
297
|
+
const sessionKeys2 = createTestSessionKeys(); // Different keys
|
|
298
|
+
const clientId = asClientId("test-client-13");
|
|
299
|
+
|
|
300
|
+
// Both sides encrypt with their own keys
|
|
301
|
+
const senderSession = createDaemonSession(sessionKeys1);
|
|
302
|
+
const receiverSession = createClientSession(clientId, sessionKeys2);
|
|
303
|
+
|
|
304
|
+
// First, make the receiver accept seq=0 so replay check passes
|
|
305
|
+
const validMessage = encryptClientToDaemon(
|
|
306
|
+
createDaemonSession(sessionKeys2),
|
|
307
|
+
new Uint8Array([99]),
|
|
308
|
+
);
|
|
309
|
+
decryptClientToDaemon(receiverSession, validMessage);
|
|
310
|
+
|
|
311
|
+
// Now send a message with seq=1 from sender with wrong keys
|
|
312
|
+
const plaintext = new Uint8Array([1, 2, 3]);
|
|
313
|
+
encryptClientToDaemon(senderSession, new Uint8Array([0])); // burn seq=0
|
|
314
|
+
const encrypted = encryptClientToDaemon(senderSession, plaintext);
|
|
315
|
+
expect(encrypted.seq).toBe(1n);
|
|
316
|
+
|
|
317
|
+
// Verify decryption fails with DecryptFailed (call only once)
|
|
318
|
+
let caughtError: SbrpError | null = null;
|
|
319
|
+
try {
|
|
320
|
+
decryptClientToDaemon(receiverSession, encrypted);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
caughtError = e as SbrpError;
|
|
323
|
+
}
|
|
324
|
+
expect(caughtError).toBeInstanceOf(SbrpError);
|
|
325
|
+
expect(caughtError!.code).toBe(SbrpErrorCode.DecryptFailed);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("fails for daemon to client direction", () => {
|
|
329
|
+
const sessionKeys1 = createTestSessionKeys();
|
|
330
|
+
const sessionKeys2 = createTestSessionKeys(); // Different keys
|
|
331
|
+
const clientId = asClientId("test-client-14");
|
|
332
|
+
|
|
333
|
+
const senderSession = createClientSession(clientId, sessionKeys1);
|
|
334
|
+
const receiverSession = createDaemonSession(sessionKeys2);
|
|
335
|
+
|
|
336
|
+
// First, make the receiver accept seq=0 so replay check passes
|
|
337
|
+
const validMessage = encryptDaemonToClient(
|
|
338
|
+
createClientSession(clientId, sessionKeys2),
|
|
339
|
+
new Uint8Array([99]),
|
|
340
|
+
);
|
|
341
|
+
decryptDaemonToClient(receiverSession, validMessage);
|
|
342
|
+
|
|
343
|
+
// Now send a message with seq=1 from sender with wrong keys
|
|
344
|
+
const plaintext = new Uint8Array([4, 5, 6]);
|
|
345
|
+
encryptDaemonToClient(senderSession, new Uint8Array([0])); // burn seq=0
|
|
346
|
+
const encrypted = encryptDaemonToClient(senderSession, plaintext);
|
|
347
|
+
expect(encrypted.seq).toBe(1n);
|
|
348
|
+
|
|
349
|
+
// Verify decryption fails with DecryptFailed (call only once)
|
|
350
|
+
let caughtError: SbrpError | null = null;
|
|
351
|
+
try {
|
|
352
|
+
decryptDaemonToClient(receiverSession, encrypted);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
caughtError = e as SbrpError;
|
|
355
|
+
}
|
|
356
|
+
expect(caughtError).toBeInstanceOf(SbrpError);
|
|
357
|
+
expect(caughtError!.code).toBe(SbrpErrorCode.DecryptFailed);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("tampered ciphertext", () => {
|
|
362
|
+
it("fails decryption for client to daemon", () => {
|
|
363
|
+
const sessionKeys = createTestSessionKeys();
|
|
364
|
+
const clientId = asClientId("test-client-15");
|
|
365
|
+
|
|
366
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
367
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
368
|
+
|
|
369
|
+
// First accept seq=0 to advance the window
|
|
370
|
+
const msg0 = encryptClientToDaemon(daemonSession, new Uint8Array([0]));
|
|
371
|
+
decryptClientToDaemon(clientSession, msg0);
|
|
372
|
+
|
|
373
|
+
// Encrypt a second message with seq=1
|
|
374
|
+
const plaintext = new Uint8Array([1, 2, 3, 4, 5]);
|
|
375
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
376
|
+
expect(encrypted.seq).toBe(1n);
|
|
377
|
+
|
|
378
|
+
// Tamper with the ciphertext (flip a bit after nonce, in the ciphertext area)
|
|
379
|
+
const tamperedData = new Uint8Array(encrypted.data);
|
|
380
|
+
tamperedData[20] ^= 0xff;
|
|
381
|
+
const tamperedMessage: EncryptedMessage = {
|
|
382
|
+
type: "encrypted",
|
|
383
|
+
seq: encrypted.seq,
|
|
384
|
+
data: tamperedData,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Verify decryption fails with DecryptFailed (call only once)
|
|
388
|
+
let caughtError: SbrpError | null = null;
|
|
389
|
+
try {
|
|
390
|
+
decryptClientToDaemon(clientSession, tamperedMessage);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
caughtError = e as SbrpError;
|
|
393
|
+
}
|
|
394
|
+
expect(caughtError).toBeInstanceOf(SbrpError);
|
|
395
|
+
expect(caughtError!.code).toBe(SbrpErrorCode.DecryptFailed);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("fails decryption for daemon to client", () => {
|
|
399
|
+
const sessionKeys = createTestSessionKeys();
|
|
400
|
+
const clientId = asClientId("test-client-16");
|
|
401
|
+
|
|
402
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
403
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
404
|
+
|
|
405
|
+
// First accept seq=0 to advance the window
|
|
406
|
+
const msg0 = encryptDaemonToClient(clientSession, new Uint8Array([0]));
|
|
407
|
+
decryptDaemonToClient(daemonSession, msg0);
|
|
408
|
+
|
|
409
|
+
// Encrypt a second message with seq=1
|
|
410
|
+
const plaintext = new Uint8Array([6, 7, 8, 9, 10]);
|
|
411
|
+
const encrypted = encryptDaemonToClient(clientSession, plaintext);
|
|
412
|
+
expect(encrypted.seq).toBe(1n);
|
|
413
|
+
|
|
414
|
+
// Tamper with the auth tag (last 16 bytes)
|
|
415
|
+
const tamperedData = new Uint8Array(encrypted.data);
|
|
416
|
+
tamperedData[tamperedData.length - 1] ^= 0x01;
|
|
417
|
+
const tamperedMessage: EncryptedMessage = {
|
|
418
|
+
type: "encrypted",
|
|
419
|
+
seq: encrypted.seq,
|
|
420
|
+
data: tamperedData,
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Verify decryption fails with DecryptFailed (call only once)
|
|
424
|
+
let caughtError: SbrpError | null = null;
|
|
425
|
+
try {
|
|
426
|
+
decryptDaemonToClient(daemonSession, tamperedMessage);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
caughtError = e as SbrpError;
|
|
429
|
+
}
|
|
430
|
+
expect(caughtError).toBeInstanceOf(SbrpError);
|
|
431
|
+
expect(caughtError!.code).toBe(SbrpErrorCode.DecryptFailed);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("fails when nonce is tampered", () => {
|
|
435
|
+
const sessionKeys = createTestSessionKeys();
|
|
436
|
+
const clientId = asClientId("test-client-17");
|
|
437
|
+
|
|
438
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
439
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
440
|
+
|
|
441
|
+
// First accept seq=0 to advance the window
|
|
442
|
+
const msg0 = encryptClientToDaemon(daemonSession, new Uint8Array([0]));
|
|
443
|
+
decryptClientToDaemon(clientSession, msg0);
|
|
444
|
+
|
|
445
|
+
// Encrypt a second message with seq=1
|
|
446
|
+
const plaintext = new Uint8Array([11, 12, 13]);
|
|
447
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
448
|
+
expect(encrypted.seq).toBe(1n);
|
|
449
|
+
|
|
450
|
+
// Tamper with the direction bytes in nonce (first 4 bytes)
|
|
451
|
+
// This will change the nonce used for decryption
|
|
452
|
+
const tamperedData = new Uint8Array(encrypted.data);
|
|
453
|
+
tamperedData[0] ^= 0xff;
|
|
454
|
+
const tamperedMessage: EncryptedMessage = {
|
|
455
|
+
type: "encrypted",
|
|
456
|
+
seq: encrypted.seq,
|
|
457
|
+
data: tamperedData,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Nonce tampering will cause decryption to fail with DecryptFailed
|
|
461
|
+
let caughtError: SbrpError | null = null;
|
|
462
|
+
try {
|
|
463
|
+
decryptClientToDaemon(clientSession, tamperedMessage);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
caughtError = e as SbrpError;
|
|
466
|
+
}
|
|
467
|
+
expect(caughtError).toBeInstanceOf(SbrpError);
|
|
468
|
+
expect(caughtError!.code).toBe(SbrpErrorCode.DecryptFailed);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("session state tracks separate sequences per direction", () => {
|
|
473
|
+
it("client and daemon sessions maintain independent sequence counters", () => {
|
|
474
|
+
const sessionKeys = createTestSessionKeys();
|
|
475
|
+
const clientId = asClientId("test-client-18");
|
|
476
|
+
|
|
477
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
478
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
479
|
+
|
|
480
|
+
// Client sends 3 messages
|
|
481
|
+
const c2d1 = encryptClientToDaemon(daemonSession, new Uint8Array([1]));
|
|
482
|
+
const c2d2 = encryptClientToDaemon(daemonSession, new Uint8Array([2]));
|
|
483
|
+
const c2d3 = encryptClientToDaemon(daemonSession, new Uint8Array([3]));
|
|
484
|
+
|
|
485
|
+
expect(c2d1.seq).toBe(0n);
|
|
486
|
+
expect(c2d2.seq).toBe(1n);
|
|
487
|
+
expect(c2d3.seq).toBe(2n);
|
|
488
|
+
|
|
489
|
+
// Daemon sends 2 messages (independent sequence)
|
|
490
|
+
const d2c1 = encryptDaemonToClient(clientSession, new Uint8Array([10]));
|
|
491
|
+
const d2c2 = encryptDaemonToClient(clientSession, new Uint8Array([20]));
|
|
492
|
+
|
|
493
|
+
expect(d2c1.seq).toBe(0n);
|
|
494
|
+
expect(d2c2.seq).toBe(1n);
|
|
495
|
+
|
|
496
|
+
// Verify decryption works for both directions
|
|
497
|
+
expect(decryptClientToDaemon(clientSession, c2d1)).toEqual(
|
|
498
|
+
new Uint8Array([1]),
|
|
499
|
+
);
|
|
500
|
+
expect(decryptClientToDaemon(clientSession, c2d2)).toEqual(
|
|
501
|
+
new Uint8Array([2]),
|
|
502
|
+
);
|
|
503
|
+
expect(decryptClientToDaemon(clientSession, c2d3)).toEqual(
|
|
504
|
+
new Uint8Array([3]),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
expect(decryptDaemonToClient(daemonSession, d2c1)).toEqual(
|
|
508
|
+
new Uint8Array([10]),
|
|
509
|
+
);
|
|
510
|
+
expect(decryptDaemonToClient(daemonSession, d2c2)).toEqual(
|
|
511
|
+
new Uint8Array([20]),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Client sends more (continues from seq=3)
|
|
515
|
+
const c2d4 = encryptClientToDaemon(daemonSession, new Uint8Array([4]));
|
|
516
|
+
expect(c2d4.seq).toBe(3n);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("replay windows are independent per direction", () => {
|
|
520
|
+
const sessionKeys = createTestSessionKeys();
|
|
521
|
+
const clientId = asClientId("test-client-19");
|
|
522
|
+
|
|
523
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
524
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
525
|
+
|
|
526
|
+
// Both directions encrypt a message
|
|
527
|
+
const c2d = encryptClientToDaemon(daemonSession, new Uint8Array([1]));
|
|
528
|
+
const d2c = encryptDaemonToClient(clientSession, new Uint8Array([2]));
|
|
529
|
+
|
|
530
|
+
// Both have seq=0, but different directions
|
|
531
|
+
expect(c2d.seq).toBe(0n);
|
|
532
|
+
expect(d2c.seq).toBe(0n);
|
|
533
|
+
|
|
534
|
+
// Decrypt both successfully (independent windows)
|
|
535
|
+
expect(decryptClientToDaemon(clientSession, c2d)).toEqual(
|
|
536
|
+
new Uint8Array([1]),
|
|
537
|
+
);
|
|
538
|
+
expect(decryptDaemonToClient(daemonSession, d2c)).toEqual(
|
|
539
|
+
new Uint8Array([2]),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// Replays still fail in their own direction
|
|
543
|
+
expect(() => decryptClientToDaemon(clientSession, c2d)).toThrow(
|
|
544
|
+
SbrpError,
|
|
545
|
+
);
|
|
546
|
+
expect(() => decryptDaemonToClient(daemonSession, d2c)).toThrow(
|
|
547
|
+
SbrpError,
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("clearSession zeroes key material", () => {
|
|
553
|
+
it("zeroes client session keys", () => {
|
|
554
|
+
const sessionKeys = createTestSessionKeys();
|
|
555
|
+
const clientId = asClientId("test-client-20");
|
|
556
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
557
|
+
|
|
558
|
+
// Verify keys are non-zero initially
|
|
559
|
+
const c2dSum = clientSession.clientToDaemon.trafficKey.reduce(
|
|
560
|
+
(a, b) => a + b,
|
|
561
|
+
0,
|
|
562
|
+
);
|
|
563
|
+
const d2cSum = clientSession.daemonToClient.trafficKey.reduce(
|
|
564
|
+
(a, b) => a + b,
|
|
565
|
+
0,
|
|
566
|
+
);
|
|
567
|
+
expect(c2dSum).toBeGreaterThan(0);
|
|
568
|
+
expect(d2cSum).toBeGreaterThan(0);
|
|
569
|
+
|
|
570
|
+
clearClientSession(clientSession);
|
|
571
|
+
|
|
572
|
+
// Keys should be zeroed
|
|
573
|
+
expect(
|
|
574
|
+
clientSession.clientToDaemon.trafficKey.every((b) => b === 0),
|
|
575
|
+
).toBe(true);
|
|
576
|
+
expect(
|
|
577
|
+
clientSession.daemonToClient.trafficKey.every((b) => b === 0),
|
|
578
|
+
).toBe(true);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("zeroes daemon session keys", () => {
|
|
582
|
+
const sessionKeys = createTestSessionKeys();
|
|
583
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
584
|
+
|
|
585
|
+
// Verify keys are non-zero initially
|
|
586
|
+
const c2dSum = daemonSession.clientToDaemon.trafficKey.reduce(
|
|
587
|
+
(a, b) => a + b,
|
|
588
|
+
0,
|
|
589
|
+
);
|
|
590
|
+
const d2cSum = daemonSession.daemonToClient.trafficKey.reduce(
|
|
591
|
+
(a, b) => a + b,
|
|
592
|
+
0,
|
|
593
|
+
);
|
|
594
|
+
expect(c2dSum).toBeGreaterThan(0);
|
|
595
|
+
expect(d2cSum).toBeGreaterThan(0);
|
|
596
|
+
|
|
597
|
+
clearDaemonSession(daemonSession);
|
|
598
|
+
|
|
599
|
+
// Keys should be zeroed
|
|
600
|
+
expect(
|
|
601
|
+
daemonSession.clientToDaemon.trafficKey.every((b) => b === 0),
|
|
602
|
+
).toBe(true);
|
|
603
|
+
expect(
|
|
604
|
+
daemonSession.daemonToClient.trafficKey.every((b) => b === 0),
|
|
605
|
+
).toBe(true);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("prevents successful decryption after clearing one side", () => {
|
|
609
|
+
const sessionKeys = createTestSessionKeys();
|
|
610
|
+
const clientId = asClientId("test-client-21");
|
|
611
|
+
|
|
612
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
613
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
614
|
+
|
|
615
|
+
// Encrypt two messages before clearing
|
|
616
|
+
const msg0 = encryptClientToDaemon(daemonSession, new Uint8Array([1]));
|
|
617
|
+
const msg1 = encryptClientToDaemon(daemonSession, new Uint8Array([2]));
|
|
618
|
+
|
|
619
|
+
// Decrypt first message successfully
|
|
620
|
+
expect(decryptClientToDaemon(clientSession, msg0)).toEqual(
|
|
621
|
+
new Uint8Array([1]),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// Clear the receiver's session (client session, which decrypts client→daemon)
|
|
625
|
+
clearClientSession(clientSession);
|
|
626
|
+
|
|
627
|
+
// Trying to decrypt msg1 (encrypted before clear with valid key)
|
|
628
|
+
// should fail because the receiver's key is now zeroed
|
|
629
|
+
let caughtError: SbrpError | null = null;
|
|
630
|
+
try {
|
|
631
|
+
decryptClientToDaemon(clientSession, msg1);
|
|
632
|
+
} catch (e) {
|
|
633
|
+
caughtError = e as SbrpError;
|
|
634
|
+
}
|
|
635
|
+
expect(caughtError).toBeInstanceOf(SbrpError);
|
|
636
|
+
expect(caughtError!.code).toBe(SbrpErrorCode.DecryptFailed);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe("multiple messages exchange", () => {
|
|
641
|
+
it("handles bidirectional conversation", () => {
|
|
642
|
+
const sessionKeys = createTestSessionKeys();
|
|
643
|
+
const clientId = asClientId("test-client-22");
|
|
644
|
+
|
|
645
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
646
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
647
|
+
|
|
648
|
+
const encoder = new TextEncoder();
|
|
649
|
+
const decoder = new TextDecoder();
|
|
650
|
+
|
|
651
|
+
// Simulate a conversation
|
|
652
|
+
const messages = [
|
|
653
|
+
{ from: "client", text: "Hello, daemon!" },
|
|
654
|
+
{ from: "daemon", text: "Hello, client!" },
|
|
655
|
+
{ from: "client", text: "How are you?" },
|
|
656
|
+
{ from: "daemon", text: "I'm running well, thanks!" },
|
|
657
|
+
{ from: "client", text: "Great to hear!" },
|
|
658
|
+
{ from: "daemon", text: "Anything I can help with?" },
|
|
659
|
+
{ from: "client", text: "Just testing encryption." },
|
|
660
|
+
{ from: "daemon", text: "Everything looks good!" },
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
for (const msg of messages) {
|
|
664
|
+
const plaintext = encoder.encode(msg.text);
|
|
665
|
+
|
|
666
|
+
if (msg.from === "client") {
|
|
667
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
668
|
+
const decrypted = decryptClientToDaemon(clientSession, encrypted);
|
|
669
|
+
expect(decoder.decode(decrypted)).toBe(msg.text);
|
|
670
|
+
} else {
|
|
671
|
+
const encrypted = encryptDaemonToClient(clientSession, plaintext);
|
|
672
|
+
const decrypted = decryptDaemonToClient(daemonSession, encrypted);
|
|
673
|
+
expect(decoder.decode(decrypted)).toBe(msg.text);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("handles high message volume", () => {
|
|
679
|
+
const sessionKeys = createTestSessionKeys();
|
|
680
|
+
const clientId = asClientId("test-client-23");
|
|
681
|
+
|
|
682
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
683
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
684
|
+
|
|
685
|
+
const messageCount = 1000;
|
|
686
|
+
|
|
687
|
+
// Client sends many messages
|
|
688
|
+
for (let i = 0; i < messageCount; i++) {
|
|
689
|
+
const plaintext = new Uint8Array([i & 0xff, (i >> 8) & 0xff]);
|
|
690
|
+
const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
691
|
+
const decrypted = decryptClientToDaemon(clientSession, encrypted);
|
|
692
|
+
expect(decrypted).toEqual(plaintext);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Daemon sends many messages
|
|
696
|
+
for (let i = 0; i < messageCount; i++) {
|
|
697
|
+
const plaintext = new Uint8Array([i & 0xff, (i >> 8) & 0xff]);
|
|
698
|
+
const encrypted = encryptDaemonToClient(clientSession, plaintext);
|
|
699
|
+
const decrypted = decryptDaemonToClient(daemonSession, encrypted);
|
|
700
|
+
expect(decrypted).toEqual(plaintext);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("interleaved messages work correctly", () => {
|
|
705
|
+
const sessionKeys = createTestSessionKeys();
|
|
706
|
+
const clientId = asClientId("test-client-24");
|
|
707
|
+
|
|
708
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
709
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
710
|
+
|
|
711
|
+
// Send messages interleaved before decrypting any
|
|
712
|
+
const c2dMessages: EncryptedMessage[] = [];
|
|
713
|
+
const d2cMessages: EncryptedMessage[] = [];
|
|
714
|
+
|
|
715
|
+
for (let i = 0; i < 10; i++) {
|
|
716
|
+
c2dMessages.push(
|
|
717
|
+
encryptClientToDaemon(daemonSession, new Uint8Array([i])),
|
|
718
|
+
);
|
|
719
|
+
d2cMessages.push(
|
|
720
|
+
encryptDaemonToClient(clientSession, new Uint8Array([i + 100])),
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Decrypt in random order
|
|
725
|
+
const c2dOrder = [5, 2, 8, 0, 3, 9, 1, 6, 4, 7];
|
|
726
|
+
for (const i of c2dOrder) {
|
|
727
|
+
expect(decryptClientToDaemon(clientSession, c2dMessages[i])).toEqual(
|
|
728
|
+
new Uint8Array([i]),
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const d2cOrder = [7, 4, 1, 9, 6, 3, 0, 8, 2, 5];
|
|
733
|
+
for (const i of d2cOrder) {
|
|
734
|
+
expect(decryptDaemonToClient(daemonSession, d2cMessages[i])).toEqual(
|
|
735
|
+
new Uint8Array([i + 100]),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe("session creation", () => {
|
|
742
|
+
it("createClientSession stores clientId", () => {
|
|
743
|
+
const sessionKeys = createTestSessionKeys();
|
|
744
|
+
const clientId = asClientId("my-special-client");
|
|
745
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
746
|
+
|
|
747
|
+
expect(clientSession.clientId).toBe(clientId);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("createClientSession initializes sequence counters to zero", () => {
|
|
751
|
+
const sessionKeys = createTestSessionKeys();
|
|
752
|
+
const clientId = asClientId("test-client-25");
|
|
753
|
+
const clientSession = createClientSession(clientId, sessionKeys);
|
|
754
|
+
|
|
755
|
+
expect(clientSession.clientToDaemon.sendSeq).toBe(0n);
|
|
756
|
+
expect(clientSession.daemonToClient.sendSeq).toBe(0n);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("createDaemonSession initializes sequence counters to zero", () => {
|
|
760
|
+
const sessionKeys = createTestSessionKeys();
|
|
761
|
+
const daemonSession = createDaemonSession(sessionKeys);
|
|
762
|
+
|
|
763
|
+
expect(daemonSession.clientToDaemon.sendSeq).toBe(0n);
|
|
764
|
+
expect(daemonSession.daemonToClient.sendSeq).toBe(0n);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
});
|