@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.
@@ -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
+ });