@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,1025 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Integration tests for Sideband Relay Protocol (SBRP) E2EE flow.
5
+ *
6
+ * Tests the complete handshake and encryption/decryption cycle
7
+ * between client and daemon, including wire format integration.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { generateIdentityKeyPair } from "./crypto.js";
12
+ import {
13
+ decodeData,
14
+ decodeFrame,
15
+ decodeHandshakeAccept,
16
+ decodeHandshakeInit,
17
+ encodeData,
18
+ encodeHandshakeAccept,
19
+ encodeHandshakeInit,
20
+ FrameType,
21
+ } from "./frame.js";
22
+ import {
23
+ createHandshakeInit,
24
+ processHandshakeAccept,
25
+ processHandshakeInit,
26
+ } from "./handshake.js";
27
+ import {
28
+ clearDaemonSession,
29
+ createClientSession,
30
+ createDaemonSession,
31
+ decryptClientToDaemon,
32
+ decryptDaemonToClient,
33
+ encryptClientToDaemon,
34
+ encryptDaemonToClient,
35
+ } from "./session.js";
36
+ import { asDaemonId, asClientId, SbrpError, SbrpErrorCode } from "./types.js";
37
+
38
+ const textEncoder = new TextEncoder();
39
+ const textDecoder = new TextDecoder();
40
+
41
+ describe("SBRP E2EE integration", () => {
42
+ describe("complete E2EE flow", () => {
43
+ it("performs full handshake and bidirectional encryption", () => {
44
+ // Setup: Daemon generates identity keypair
45
+ const daemonId = asDaemonId("daemon-001");
46
+ const daemonIdentity = generateIdentityKeyPair();
47
+
48
+ // Step 1: Client initiates handshake
49
+ const { message: initMessage, ephemeralKeyPair: clientEphemeral } =
50
+ createHandshakeInit();
51
+ expect(initMessage.type).toBe("handshake.init");
52
+ expect(initMessage.initPublicKey.length).toBe(32);
53
+
54
+ // Step 2: Daemon processes init and creates accept
55
+ const { message: acceptMessage, result: daemonResult } =
56
+ processHandshakeInit(initMessage, daemonId, daemonIdentity);
57
+ expect(acceptMessage.type).toBe("handshake.accept");
58
+ expect(acceptMessage.acceptPublicKey.length).toBe(32);
59
+ expect(acceptMessage.signature.length).toBe(64);
60
+
61
+ // Step 3: Client verifies signature and derives keys (TOFU - first connection)
62
+ const clientResult = processHandshakeAccept(
63
+ acceptMessage,
64
+ daemonId,
65
+ daemonIdentity.publicKey, // Pinned identity key
66
+ clientEphemeral,
67
+ );
68
+
69
+ // Verify both sides derived the same session keys
70
+ expect(clientResult.sessionKeys.clientToDaemon).toEqual(
71
+ daemonResult.sessionKeys.clientToDaemon,
72
+ );
73
+ expect(clientResult.sessionKeys.daemonToClient).toEqual(
74
+ daemonResult.sessionKeys.daemonToClient,
75
+ );
76
+
77
+ // Step 4: Create sessions
78
+ const clientId = asClientId("client-session-001");
79
+ const clientSession = createClientSession(
80
+ clientId,
81
+ daemonResult.sessionKeys,
82
+ );
83
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
84
+
85
+ // Step 5: Client encrypts message to daemon
86
+ const clientMessage = textEncoder.encode("Hello from client!");
87
+ const encryptedFromClient = encryptClientToDaemon(
88
+ daemonSession,
89
+ clientMessage,
90
+ );
91
+ expect(encryptedFromClient.type).toBe("encrypted");
92
+ expect(encryptedFromClient.seq).toBe(0n);
93
+
94
+ // Step 6: Daemon decrypts client message
95
+ const decryptedByDaemon = decryptClientToDaemon(
96
+ clientSession,
97
+ encryptedFromClient,
98
+ );
99
+ expect(textDecoder.decode(decryptedByDaemon)).toBe("Hello from client!");
100
+
101
+ // Step 7: Daemon encrypts response to client
102
+ const daemonMessage = textEncoder.encode("Hello from daemon!");
103
+ const encryptedFromDaemon = encryptDaemonToClient(
104
+ clientSession,
105
+ daemonMessage,
106
+ );
107
+ expect(encryptedFromDaemon.type).toBe("encrypted");
108
+ expect(encryptedFromDaemon.seq).toBe(0n);
109
+
110
+ // Step 8: Client decrypts daemon message
111
+ const decryptedByClient = decryptDaemonToClient(
112
+ daemonSession,
113
+ encryptedFromDaemon,
114
+ );
115
+ expect(textDecoder.decode(decryptedByClient)).toBe("Hello from daemon!");
116
+ });
117
+
118
+ it("handles multiple messages with incrementing sequence numbers", () => {
119
+ const daemonId = asDaemonId("daemon-002");
120
+ const daemonIdentity = generateIdentityKeyPair();
121
+ const clientId = asClientId("client-002");
122
+
123
+ // Complete handshake
124
+ const { message: initMessage, ephemeralKeyPair: clientEphemeral } =
125
+ createHandshakeInit();
126
+ const { message: acceptMessage, result: daemonResult } =
127
+ processHandshakeInit(initMessage, daemonId, daemonIdentity);
128
+ const clientResult = processHandshakeAccept(
129
+ acceptMessage,
130
+ daemonId,
131
+ daemonIdentity.publicKey,
132
+ clientEphemeral,
133
+ );
134
+
135
+ const clientSession = createClientSession(
136
+ clientId,
137
+ daemonResult.sessionKeys,
138
+ );
139
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
140
+
141
+ // Send multiple messages from client to daemon
142
+ for (let i = 0; i < 5; i++) {
143
+ const message = textEncoder.encode(`Message ${i}`);
144
+ const encrypted = encryptClientToDaemon(daemonSession, message);
145
+ expect(encrypted.seq).toBe(BigInt(i));
146
+
147
+ const decrypted = decryptClientToDaemon(clientSession, encrypted);
148
+ expect(textDecoder.decode(decrypted)).toBe(`Message ${i}`);
149
+ }
150
+
151
+ // Send multiple messages from daemon to client
152
+ for (let i = 0; i < 5; i++) {
153
+ const message = textEncoder.encode(`Response ${i}`);
154
+ const encrypted = encryptDaemonToClient(clientSession, message);
155
+ expect(encrypted.seq).toBe(BigInt(i));
156
+
157
+ const decrypted = decryptDaemonToClient(daemonSession, encrypted);
158
+ expect(textDecoder.decode(decrypted)).toBe(`Response ${i}`);
159
+ }
160
+ });
161
+
162
+ it("handles empty messages", () => {
163
+ const daemonId = asDaemonId("daemon-empty");
164
+ const daemonIdentity = generateIdentityKeyPair();
165
+ const clientId = asClientId("client-empty");
166
+
167
+ const { message: initMessage, ephemeralKeyPair: clientEphemeral } =
168
+ createHandshakeInit();
169
+ const { message: acceptMessage, result: daemonResult } =
170
+ processHandshakeInit(initMessage, daemonId, daemonIdentity);
171
+ const clientResult = processHandshakeAccept(
172
+ acceptMessage,
173
+ daemonId,
174
+ daemonIdentity.publicKey,
175
+ clientEphemeral,
176
+ );
177
+
178
+ const clientSession = createClientSession(
179
+ clientId,
180
+ daemonResult.sessionKeys,
181
+ );
182
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
183
+
184
+ // Empty message from client
185
+ const emptyMessage = new Uint8Array(0);
186
+ const encrypted = encryptClientToDaemon(daemonSession, emptyMessage);
187
+ const decrypted = decryptClientToDaemon(clientSession, encrypted);
188
+ expect(decrypted.length).toBe(0);
189
+ });
190
+
191
+ it("handles large messages", () => {
192
+ const daemonId = asDaemonId("daemon-large");
193
+ const daemonIdentity = generateIdentityKeyPair();
194
+ const clientId = asClientId("client-large");
195
+
196
+ const { message: initMessage, ephemeralKeyPair: clientEphemeral } =
197
+ createHandshakeInit();
198
+ const { message: acceptMessage, result: daemonResult } =
199
+ processHandshakeInit(initMessage, daemonId, daemonIdentity);
200
+ const clientResult = processHandshakeAccept(
201
+ acceptMessage,
202
+ daemonId,
203
+ daemonIdentity.publicKey,
204
+ clientEphemeral,
205
+ );
206
+
207
+ const clientSession = createClientSession(
208
+ clientId,
209
+ daemonResult.sessionKeys,
210
+ );
211
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
212
+
213
+ // 32KB message
214
+ const largeMessage = new Uint8Array(32 * 1024);
215
+ for (let i = 0; i < largeMessage.length; i++) {
216
+ largeMessage[i] = i % 256;
217
+ }
218
+
219
+ const encrypted = encryptClientToDaemon(daemonSession, largeMessage);
220
+ const decrypted = decryptClientToDaemon(clientSession, encrypted);
221
+ expect(decrypted).toEqual(largeMessage);
222
+ });
223
+ });
224
+
225
+ describe("multiple sessions", () => {
226
+ it("handles multiple clients with different session keys", () => {
227
+ const daemonId = asDaemonId("daemon-multi");
228
+ const daemonIdentity = generateIdentityKeyPair();
229
+
230
+ // Client A initiates handshake
231
+ const { message: initA, ephemeralKeyPair: ephemeralA } =
232
+ createHandshakeInit();
233
+ const { message: acceptA, result: daemonResultA } = processHandshakeInit(
234
+ initA,
235
+ daemonId,
236
+ daemonIdentity,
237
+ );
238
+ const clientResultA = processHandshakeAccept(
239
+ acceptA,
240
+ daemonId,
241
+ daemonIdentity.publicKey,
242
+ ephemeralA,
243
+ );
244
+
245
+ // Client B initiates handshake
246
+ const { message: initB, ephemeralKeyPair: ephemeralB } =
247
+ createHandshakeInit();
248
+ const { message: acceptB, result: daemonResultB } = processHandshakeInit(
249
+ initB,
250
+ daemonId,
251
+ daemonIdentity,
252
+ );
253
+ const clientResultB = processHandshakeAccept(
254
+ acceptB,
255
+ daemonId,
256
+ daemonIdentity.publicKey,
257
+ ephemeralB,
258
+ );
259
+
260
+ // Verify different session keys for each client
261
+ expect(clientResultA.sessionKeys.clientToDaemon).not.toEqual(
262
+ clientResultB.sessionKeys.clientToDaemon,
263
+ );
264
+ expect(clientResultA.sessionKeys.daemonToClient).not.toEqual(
265
+ clientResultB.sessionKeys.daemonToClient,
266
+ );
267
+
268
+ // Create sessions
269
+ const clientSessionA = createClientSession(
270
+ asClientId("client-A"),
271
+ daemonResultA.sessionKeys,
272
+ );
273
+ const clientSessionB = createClientSession(
274
+ asClientId("client-B"),
275
+ daemonResultB.sessionKeys,
276
+ );
277
+ const daemonSessionA = createDaemonSession(clientResultA.sessionKeys);
278
+ const daemonSessionB = createDaemonSession(clientResultB.sessionKeys);
279
+
280
+ // Client A sends message
281
+ const messageA = textEncoder.encode("From client A");
282
+ const encryptedA = encryptClientToDaemon(daemonSessionA, messageA);
283
+
284
+ // Client B sends message
285
+ const messageB = textEncoder.encode("From client B");
286
+ const encryptedB = encryptClientToDaemon(daemonSessionB, messageB);
287
+
288
+ // Daemon decrypts each with correct session
289
+ const decryptedA = decryptClientToDaemon(clientSessionA, encryptedA);
290
+ const decryptedB = decryptClientToDaemon(clientSessionB, encryptedB);
291
+
292
+ expect(textDecoder.decode(decryptedA)).toBe("From client A");
293
+ expect(textDecoder.decode(decryptedB)).toBe("From client B");
294
+ });
295
+
296
+ it("prevents message cross-session decryption", () => {
297
+ const daemonId = asDaemonId("daemon-cross");
298
+ const daemonIdentity = generateIdentityKeyPair();
299
+
300
+ // Two separate sessions
301
+ const { message: init1, ephemeralKeyPair: eph1 } = createHandshakeInit();
302
+ const { message: accept1, result: daemonResult1 } = processHandshakeInit(
303
+ init1,
304
+ daemonId,
305
+ daemonIdentity,
306
+ );
307
+ const clientResult1 = processHandshakeAccept(
308
+ accept1,
309
+ daemonId,
310
+ daemonIdentity.publicKey,
311
+ eph1,
312
+ );
313
+
314
+ const { message: init2, ephemeralKeyPair: eph2 } = createHandshakeInit();
315
+ const { message: accept2, result: daemonResult2 } = processHandshakeInit(
316
+ init2,
317
+ daemonId,
318
+ daemonIdentity,
319
+ );
320
+ const clientResult2 = processHandshakeAccept(
321
+ accept2,
322
+ daemonId,
323
+ daemonIdentity.publicKey,
324
+ eph2,
325
+ );
326
+
327
+ const clientSession1 = createClientSession(
328
+ asClientId("session-1"),
329
+ daemonResult1.sessionKeys,
330
+ );
331
+ const clientSession2 = createClientSession(
332
+ asClientId("session-2"),
333
+ daemonResult2.sessionKeys,
334
+ );
335
+ const daemonSession1 = createDaemonSession(clientResult1.sessionKeys);
336
+
337
+ // Encrypt with session 1
338
+ const message = textEncoder.encode("Secret message");
339
+ const encrypted = encryptClientToDaemon(daemonSession1, message);
340
+
341
+ // Attempt to decrypt with session 2 should fail
342
+ expect(() => decryptClientToDaemon(clientSession2, encrypted)).toThrow(
343
+ SbrpError,
344
+ );
345
+ });
346
+ });
347
+
348
+ describe("wire format integration", () => {
349
+ it("performs full roundtrip through wire format", () => {
350
+ const sessionId = 12345n;
351
+ const daemonId = asDaemonId("daemon-wire");
352
+ const daemonIdentity = generateIdentityKeyPair();
353
+
354
+ // Step 1: Client creates HandshakeInit and encodes to wire
355
+ const { message: initMessage, ephemeralKeyPair: clientEphemeral } =
356
+ createHandshakeInit();
357
+ const initWireFrame = encodeHandshakeInit(sessionId, initMessage);
358
+
359
+ // Simulate relay: decode and re-encode (or just forward)
360
+ const initFrame = decodeFrame(initWireFrame);
361
+ expect(initFrame.type).toBe(FrameType.HandshakeInit);
362
+ expect(initFrame.sessionId).toBe(sessionId);
363
+
364
+ const decodedInit = decodeHandshakeInit(initFrame);
365
+ expect(decodedInit.initPublicKey).toEqual(initMessage.initPublicKey);
366
+
367
+ // Step 2: Daemon receives wire frame, processes, creates accept
368
+ const { message: acceptMessage, result: daemonResult } =
369
+ processHandshakeInit(decodedInit, daemonId, daemonIdentity);
370
+ const acceptWireFrame = encodeHandshakeAccept(sessionId, acceptMessage);
371
+
372
+ // Simulate relay forward
373
+ const acceptFrame = decodeFrame(acceptWireFrame);
374
+ expect(acceptFrame.type).toBe(FrameType.HandshakeAccept);
375
+ expect(acceptFrame.sessionId).toBe(sessionId);
376
+
377
+ const decodedAccept = decodeHandshakeAccept(acceptFrame);
378
+
379
+ // Step 3: Client receives accept, verifies, derives keys
380
+ const clientResult = processHandshakeAccept(
381
+ decodedAccept,
382
+ daemonId,
383
+ daemonIdentity.publicKey,
384
+ clientEphemeral,
385
+ );
386
+
387
+ // Create sessions
388
+ const clientSession = createClientSession(
389
+ asClientId("wire-client"),
390
+ daemonResult.sessionKeys,
391
+ );
392
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
393
+
394
+ // Step 4: Client sends encrypted data frame
395
+ const clientMessage = textEncoder.encode("Wire format test!");
396
+ const encryptedFromClient = encryptClientToDaemon(
397
+ daemonSession,
398
+ clientMessage,
399
+ );
400
+ const dataWireFrame = encodeData(sessionId, encryptedFromClient);
401
+
402
+ // Simulate relay forward
403
+ const dataFrame = decodeFrame(dataWireFrame);
404
+ expect(dataFrame.type).toBe(FrameType.Data);
405
+ expect(dataFrame.sessionId).toBe(sessionId);
406
+
407
+ const decodedData = decodeData(dataFrame);
408
+ expect(decodedData.seq).toBe(0n);
409
+
410
+ // Step 5: Daemon decrypts
411
+ const decryptedByDaemon = decryptClientToDaemon(
412
+ clientSession,
413
+ decodedData,
414
+ );
415
+ expect(textDecoder.decode(decryptedByDaemon)).toBe("Wire format test!");
416
+
417
+ // Step 6: Daemon responds
418
+ const daemonMessage = textEncoder.encode("Wire format reply!");
419
+ const encryptedFromDaemon = encryptDaemonToClient(
420
+ clientSession,
421
+ daemonMessage,
422
+ );
423
+ const replyWireFrame = encodeData(sessionId, encryptedFromDaemon);
424
+
425
+ const replyFrame = decodeFrame(replyWireFrame);
426
+ const decodedReply = decodeData(replyFrame);
427
+
428
+ // Step 7: Client decrypts
429
+ const decryptedByClient = decryptDaemonToClient(
430
+ daemonSession,
431
+ decodedReply,
432
+ );
433
+ expect(textDecoder.decode(decryptedByClient)).toBe("Wire format reply!");
434
+ });
435
+
436
+ it("handles wire format with different session IDs", () => {
437
+ const daemonId = asDaemonId("daemon-multi-wire");
438
+ const daemonIdentity = generateIdentityKeyPair();
439
+
440
+ // Session 1 with sessionId 100
441
+ const { message: init1, ephemeralKeyPair: eph1 } = createHandshakeInit();
442
+ const initWire1 = encodeHandshakeInit(100n, init1);
443
+ const initFrame1 = decodeFrame(initWire1);
444
+ expect(initFrame1.sessionId).toBe(100n);
445
+
446
+ // Session 2 with sessionId 200
447
+ const { message: init2, ephemeralKeyPair: eph2 } = createHandshakeInit();
448
+ const initWire2 = encodeHandshakeInit(200n, init2);
449
+ const initFrame2 = decodeFrame(initWire2);
450
+ expect(initFrame2.sessionId).toBe(200n);
451
+
452
+ // Different sessionIds mean different routing at relay level
453
+ expect(initFrame1.sessionId).not.toBe(initFrame2.sessionId);
454
+
455
+ // Process both handshakes
456
+ const { message: accept1, result: dr1 } = processHandshakeInit(
457
+ decodeHandshakeInit(initFrame1),
458
+ daemonId,
459
+ daemonIdentity,
460
+ );
461
+ const { message: accept2, result: dr2 } = processHandshakeInit(
462
+ decodeHandshakeInit(initFrame2),
463
+ daemonId,
464
+ daemonIdentity,
465
+ );
466
+
467
+ const cr1 = processHandshakeAccept(
468
+ accept1,
469
+ daemonId,
470
+ daemonIdentity.publicKey,
471
+ eph1,
472
+ );
473
+ const cr2 = processHandshakeAccept(
474
+ accept2,
475
+ daemonId,
476
+ daemonIdentity.publicKey,
477
+ eph2,
478
+ );
479
+
480
+ // Create sessions
481
+ const cs1 = createClientSession(asClientId("c1"), dr1.sessionKeys);
482
+ const cs2 = createClientSession(asClientId("c2"), dr2.sessionKeys);
483
+ const ds1 = createDaemonSession(cr1.sessionKeys);
484
+ const ds2 = createDaemonSession(cr2.sessionKeys);
485
+
486
+ // Messages on session 100
487
+ const msg1 = encryptClientToDaemon(
488
+ ds1,
489
+ textEncoder.encode("Session 100"),
490
+ );
491
+ const wire1 = encodeData(100n, msg1);
492
+ const frame1 = decodeFrame(wire1);
493
+ expect(frame1.sessionId).toBe(100n);
494
+
495
+ // Messages on session 200
496
+ const msg2 = encryptClientToDaemon(
497
+ ds2,
498
+ textEncoder.encode("Session 200"),
499
+ );
500
+ const wire2 = encodeData(200n, msg2);
501
+ const frame2 = decodeFrame(wire2);
502
+ expect(frame2.sessionId).toBe(200n);
503
+
504
+ // Decrypt with correct sessions
505
+ const dec1 = decryptClientToDaemon(cs1, decodeData(frame1));
506
+ const dec2 = decryptClientToDaemon(cs2, decodeData(frame2));
507
+ expect(textDecoder.decode(dec1)).toBe("Session 100");
508
+ expect(textDecoder.decode(dec2)).toBe("Session 200");
509
+ });
510
+ });
511
+
512
+ describe("TOFU identity verification", () => {
513
+ it("accepts same identity key on reconnect", () => {
514
+ const daemonId = asDaemonId("daemon-tofu");
515
+ const daemonIdentity = generateIdentityKeyPair();
516
+
517
+ // First connection: Pin identity key
518
+ const { message: init1, ephemeralKeyPair: eph1 } = createHandshakeInit();
519
+ const { message: accept1 } = processHandshakeInit(
520
+ init1,
521
+ daemonId,
522
+ daemonIdentity,
523
+ );
524
+ const pinnedKey = daemonIdentity.publicKey;
525
+
526
+ // Verify first connection succeeds
527
+ expect(() =>
528
+ processHandshakeAccept(accept1, daemonId, pinnedKey, eph1),
529
+ ).not.toThrow();
530
+
531
+ // Second connection with same identity (simulating reconnect)
532
+ const { message: init2, ephemeralKeyPair: eph2 } = createHandshakeInit();
533
+ const { message: accept2 } = processHandshakeInit(
534
+ init2,
535
+ daemonId,
536
+ daemonIdentity, // Same identity keypair
537
+ );
538
+
539
+ // Verify with pinned key from first connection
540
+ expect(() =>
541
+ processHandshakeAccept(accept2, daemonId, pinnedKey, eph2),
542
+ ).not.toThrow();
543
+ });
544
+
545
+ it("rejects different identity key (MITM detection)", () => {
546
+ const daemonId = asDaemonId("daemon-mitm");
547
+ const realDaemonIdentity = generateIdentityKeyPair();
548
+ const attackerIdentity = generateIdentityKeyPair();
549
+
550
+ // First connection: Client pins real daemon's identity key
551
+ const { message: init1, ephemeralKeyPair: eph1 } = createHandshakeInit();
552
+ const { message: accept1 } = processHandshakeInit(
553
+ init1,
554
+ daemonId,
555
+ realDaemonIdentity,
556
+ );
557
+ const pinnedKey = realDaemonIdentity.publicKey;
558
+
559
+ // Verify first connection succeeds
560
+ const result1 = processHandshakeAccept(
561
+ accept1,
562
+ daemonId,
563
+ pinnedKey,
564
+ eph1,
565
+ );
566
+ expect(result1.sessionKeys).toBeDefined();
567
+
568
+ // Second connection: Attacker tries to impersonate daemon
569
+ const { message: init2, ephemeralKeyPair: eph2 } = createHandshakeInit();
570
+ const { message: attackerAccept } = processHandshakeInit(
571
+ init2,
572
+ daemonId,
573
+ attackerIdentity, // Different identity!
574
+ );
575
+
576
+ // Verify with original pinned key should FAIL
577
+ expect(() =>
578
+ processHandshakeAccept(attackerAccept, daemonId, pinnedKey, eph2),
579
+ ).toThrow(SbrpError);
580
+ expect(() =>
581
+ processHandshakeAccept(attackerAccept, daemonId, pinnedKey, eph2),
582
+ ).toThrow(/Signature verification failed/);
583
+ });
584
+
585
+ it("detects identity key change scenario", () => {
586
+ const daemonId = asDaemonId("daemon-keychange");
587
+
588
+ // Original daemon identity
589
+ const originalIdentity = generateIdentityKeyPair();
590
+ const { message: init1, ephemeralKeyPair: eph1 } = createHandshakeInit();
591
+ const { message: accept1 } = processHandshakeInit(
592
+ init1,
593
+ daemonId,
594
+ originalIdentity,
595
+ );
596
+ const pinnedKey = originalIdentity.publicKey;
597
+
598
+ // First connection succeeds and pins key
599
+ processHandshakeAccept(accept1, daemonId, pinnedKey, eph1);
600
+
601
+ // Later: Daemon regenerates identity (key rotation or compromise)
602
+ const newIdentity = generateIdentityKeyPair();
603
+ const { message: init2, ephemeralKeyPair: eph2 } = createHandshakeInit();
604
+ const { message: accept2 } = processHandshakeInit(
605
+ init2,
606
+ daemonId,
607
+ newIdentity,
608
+ );
609
+
610
+ // Client still has old pinned key - verification fails
611
+ // This is the "identity_key_changed" scenario
612
+ expect(() =>
613
+ processHandshakeAccept(accept2, daemonId, pinnedKey, eph2),
614
+ ).toThrow(SbrpError);
615
+ });
616
+ });
617
+
618
+ describe("session resumption state", () => {
619
+ it("fails decryption after session cleared", () => {
620
+ const daemonId = asDaemonId("daemon-clear");
621
+ const daemonIdentity = generateIdentityKeyPair();
622
+
623
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
624
+ const { message: accept, result: daemonResult } = processHandshakeInit(
625
+ init,
626
+ daemonId,
627
+ daemonIdentity,
628
+ );
629
+ const clientResult = processHandshakeAccept(
630
+ accept,
631
+ daemonId,
632
+ daemonIdentity.publicKey,
633
+ ephemeralKeyPair,
634
+ );
635
+
636
+ const clientSession = createClientSession(
637
+ asClientId("clear-test"),
638
+ daemonResult.sessionKeys,
639
+ );
640
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
641
+
642
+ // Encrypt before clearing
643
+ const message = textEncoder.encode("Before clear");
644
+ const encrypted = encryptClientToDaemon(daemonSession, message);
645
+
646
+ // Clear daemon session (simulates session expiration or cleanup)
647
+ clearDaemonSession(daemonSession);
648
+
649
+ // Old encrypted messages can still be decrypted by daemon
650
+ // because clientSession wasn't cleared
651
+ const decrypted = decryptClientToDaemon(clientSession, encrypted);
652
+ expect(textDecoder.decode(decrypted)).toBe("Before clear");
653
+
654
+ // But new messages from cleared session fail (keys zeroed)
655
+ const newMessage = textEncoder.encode("After clear");
656
+ // Encryption will produce garbage since keys are zeroed
657
+ const garbageEncrypted = encryptClientToDaemon(daemonSession, newMessage);
658
+
659
+ // Decryption should fail with invalid auth tag
660
+ expect(() =>
661
+ decryptClientToDaemon(clientSession, garbageEncrypted),
662
+ ).toThrow(SbrpError);
663
+ });
664
+
665
+ it("requires new handshake after session clear", () => {
666
+ const daemonId = asDaemonId("daemon-newhs");
667
+ const daemonIdentity = generateIdentityKeyPair();
668
+
669
+ // First session
670
+ const { message: init1, ephemeralKeyPair: eph1 } = createHandshakeInit();
671
+ const { message: accept1, result: dr1 } = processHandshakeInit(
672
+ init1,
673
+ daemonId,
674
+ daemonIdentity,
675
+ );
676
+ const cr1 = processHandshakeAccept(
677
+ accept1,
678
+ daemonId,
679
+ daemonIdentity.publicKey,
680
+ eph1,
681
+ );
682
+
683
+ const clientSession1 = createClientSession(
684
+ asClientId("session-old"),
685
+ dr1.sessionKeys,
686
+ );
687
+ const daemonSession1 = createDaemonSession(cr1.sessionKeys);
688
+
689
+ // Clear sessions
690
+ clearDaemonSession(daemonSession1);
691
+
692
+ // New handshake creates new session with new keys
693
+ const { message: init2, ephemeralKeyPair: eph2 } = createHandshakeInit();
694
+ const { message: accept2, result: dr2 } = processHandshakeInit(
695
+ init2,
696
+ daemonId,
697
+ daemonIdentity,
698
+ );
699
+ const cr2 = processHandshakeAccept(
700
+ accept2,
701
+ daemonId,
702
+ daemonIdentity.publicKey,
703
+ eph2,
704
+ );
705
+
706
+ const clientSession2 = createClientSession(
707
+ asClientId("session-new"),
708
+ dr2.sessionKeys,
709
+ );
710
+ const daemonSession2 = createDaemonSession(cr2.sessionKeys);
711
+
712
+ // New session works
713
+ const message = textEncoder.encode("New session message");
714
+ const encrypted = encryptClientToDaemon(daemonSession2, message);
715
+ const decrypted = decryptClientToDaemon(clientSession2, encrypted);
716
+ expect(textDecoder.decode(decrypted)).toBe("New session message");
717
+
718
+ // But old session cannot decrypt new messages
719
+ expect(() => decryptClientToDaemon(clientSession1, encrypted)).toThrow(
720
+ SbrpError,
721
+ );
722
+ });
723
+ });
724
+
725
+ describe("error scenarios", () => {
726
+ it("rejects MITM with modified ephemeral key", () => {
727
+ const daemonId = asDaemonId("daemon-mitm-eph");
728
+ const daemonIdentity = generateIdentityKeyPair();
729
+
730
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
731
+ const { message: accept } = processHandshakeInit(
732
+ init,
733
+ daemonId,
734
+ daemonIdentity,
735
+ );
736
+
737
+ // Attacker modifies the accept's ephemeral public key
738
+ const modifiedAccept = {
739
+ ...accept,
740
+ acceptPublicKey: new Uint8Array(32).fill(0xaa), // Fake key
741
+ };
742
+
743
+ // Signature verification fails because signature was over original key
744
+ expect(() =>
745
+ processHandshakeAccept(
746
+ modifiedAccept,
747
+ daemonId,
748
+ daemonIdentity.publicKey,
749
+ ephemeralKeyPair,
750
+ ),
751
+ ).toThrow(SbrpError);
752
+ expect(() =>
753
+ processHandshakeAccept(
754
+ modifiedAccept,
755
+ daemonId,
756
+ daemonIdentity.publicKey,
757
+ ephemeralKeyPair,
758
+ ),
759
+ ).toThrow(/Signature verification failed/);
760
+ });
761
+
762
+ it("rejects MITM with modified signature", () => {
763
+ const daemonId = asDaemonId("daemon-mitm-sig");
764
+ const daemonIdentity = generateIdentityKeyPair();
765
+
766
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
767
+ const { message: accept } = processHandshakeInit(
768
+ init,
769
+ daemonId,
770
+ daemonIdentity,
771
+ );
772
+
773
+ // Attacker modifies the signature
774
+ const modifiedAccept = {
775
+ ...accept,
776
+ signature: new Uint8Array(64).fill(0xbb), // Fake signature
777
+ };
778
+
779
+ expect(() =>
780
+ processHandshakeAccept(
781
+ modifiedAccept,
782
+ daemonId,
783
+ daemonIdentity.publicKey,
784
+ ephemeralKeyPair,
785
+ ),
786
+ ).toThrow(SbrpError);
787
+ });
788
+
789
+ it("detects replay attack on data frames", () => {
790
+ const daemonId = asDaemonId("daemon-replay");
791
+ const daemonIdentity = generateIdentityKeyPair();
792
+
793
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
794
+ const { message: accept, result: daemonResult } = processHandshakeInit(
795
+ init,
796
+ daemonId,
797
+ daemonIdentity,
798
+ );
799
+ const clientResult = processHandshakeAccept(
800
+ accept,
801
+ daemonId,
802
+ daemonIdentity.publicKey,
803
+ ephemeralKeyPair,
804
+ );
805
+
806
+ const clientSession = createClientSession(
807
+ asClientId("replay-test"),
808
+ daemonResult.sessionKeys,
809
+ );
810
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
811
+
812
+ // Send legitimate message
813
+ const message = textEncoder.encode("Original message");
814
+ const encrypted = encryptClientToDaemon(daemonSession, message);
815
+
816
+ // First decryption succeeds
817
+ const decrypted = decryptClientToDaemon(clientSession, encrypted);
818
+ expect(textDecoder.decode(decrypted)).toBe("Original message");
819
+
820
+ // Replay same message - should be rejected
821
+ expect(() => decryptClientToDaemon(clientSession, encrypted)).toThrow(
822
+ SbrpError,
823
+ );
824
+ expect(() => decryptClientToDaemon(clientSession, encrypted)).toThrow(
825
+ /replay detected/,
826
+ );
827
+ });
828
+
829
+ it("rejects message encrypted with wrong session keys", () => {
830
+ const daemonId = asDaemonId("daemon-wrongkey");
831
+ const daemonIdentity = generateIdentityKeyPair();
832
+
833
+ // Session A
834
+ const { message: initA, ephemeralKeyPair: ephA } = createHandshakeInit();
835
+ const { message: acceptA, result: drA } = processHandshakeInit(
836
+ initA,
837
+ daemonId,
838
+ daemonIdentity,
839
+ );
840
+ const crA = processHandshakeAccept(
841
+ acceptA,
842
+ daemonId,
843
+ daemonIdentity.publicKey,
844
+ ephA,
845
+ );
846
+
847
+ // Session B
848
+ const { message: initB, ephemeralKeyPair: ephB } = createHandshakeInit();
849
+ const { message: acceptB, result: drB } = processHandshakeInit(
850
+ initB,
851
+ daemonId,
852
+ daemonIdentity,
853
+ );
854
+ const crB = processHandshakeAccept(
855
+ acceptB,
856
+ daemonId,
857
+ daemonIdentity.publicKey,
858
+ ephB,
859
+ );
860
+
861
+ const clientSessionA = createClientSession(
862
+ asClientId("A"),
863
+ drA.sessionKeys,
864
+ );
865
+ const clientSessionB = createClientSession(
866
+ asClientId("B"),
867
+ drB.sessionKeys,
868
+ );
869
+ const daemonSessionB = createDaemonSession(crB.sessionKeys);
870
+
871
+ // Encrypt with session B keys
872
+ const message = textEncoder.encode("Wrong session test");
873
+ const encryptedB = encryptClientToDaemon(daemonSessionB, message);
874
+
875
+ // Try to decrypt with session A - should fail (wrong keys cause auth failure)
876
+ expect(() => decryptClientToDaemon(clientSessionA, encryptedB)).toThrow(
877
+ SbrpError,
878
+ );
879
+
880
+ // Decrypting with correct session works
881
+ const decrypted = decryptClientToDaemon(clientSessionB, encryptedB);
882
+ expect(textDecoder.decode(decrypted)).toBe("Wrong session test");
883
+ });
884
+
885
+ it("rejects tampered ciphertext", () => {
886
+ const daemonId = asDaemonId("daemon-tamper");
887
+ const daemonIdentity = generateIdentityKeyPair();
888
+
889
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
890
+ const { message: accept, result: daemonResult } = processHandshakeInit(
891
+ init,
892
+ daemonId,
893
+ daemonIdentity,
894
+ );
895
+ const clientResult = processHandshakeAccept(
896
+ accept,
897
+ daemonId,
898
+ daemonIdentity.publicKey,
899
+ ephemeralKeyPair,
900
+ );
901
+
902
+ const clientSession = createClientSession(
903
+ asClientId("tamper-test"),
904
+ daemonResult.sessionKeys,
905
+ );
906
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
907
+
908
+ const message = textEncoder.encode("Tamper test");
909
+ const encrypted = encryptClientToDaemon(daemonSession, message);
910
+
911
+ // Tamper with ciphertext (flip a bit in the middle)
912
+ const tamperedData = new Uint8Array(encrypted.data);
913
+ tamperedData[20] ^= 0x01; // Flip a bit
914
+
915
+ const tamperedMessage = {
916
+ ...encrypted,
917
+ data: tamperedData,
918
+ };
919
+
920
+ // Poly1305 auth tag verification fails
921
+ expect(() =>
922
+ decryptClientToDaemon(clientSession, tamperedMessage),
923
+ ).toThrow(SbrpError);
924
+ });
925
+
926
+ it("rejects wrong direction key usage", () => {
927
+ const daemonId = asDaemonId("daemon-direction");
928
+ const daemonIdentity = generateIdentityKeyPair();
929
+
930
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
931
+ const { message: accept, result: daemonResult } = processHandshakeInit(
932
+ init,
933
+ daemonId,
934
+ daemonIdentity,
935
+ );
936
+ const clientResult = processHandshakeAccept(
937
+ accept,
938
+ daemonId,
939
+ daemonIdentity.publicKey,
940
+ ephemeralKeyPair,
941
+ );
942
+
943
+ const clientSession = createClientSession(
944
+ asClientId("direction-test"),
945
+ daemonResult.sessionKeys,
946
+ );
947
+ const daemonSession = createDaemonSession(clientResult.sessionKeys);
948
+
949
+ // Client sends to daemon
950
+ const clientMessage = textEncoder.encode("Client to daemon");
951
+ const encryptedC2D = encryptClientToDaemon(daemonSession, clientMessage);
952
+
953
+ // Try to decrypt client-to-daemon message as if it were daemon-to-client
954
+ // This should fail because different keys and direction bytes are used
955
+ expect(() => decryptDaemonToClient(daemonSession, encryptedC2D)).toThrow(
956
+ SbrpError,
957
+ );
958
+ });
959
+ });
960
+
961
+ describe("concurrent sessions stress test", () => {
962
+ it("handles many concurrent sessions", () => {
963
+ const daemonId = asDaemonId("daemon-stress");
964
+ const daemonIdentity = generateIdentityKeyPair();
965
+ const numSessions = 50;
966
+
967
+ type SessionPair = {
968
+ clientSession: ReturnType<typeof createClientSession>;
969
+ daemonSession: ReturnType<typeof createDaemonSession>;
970
+ id: number;
971
+ };
972
+
973
+ const sessions: SessionPair[] = [];
974
+
975
+ // Create many sessions
976
+ for (let i = 0; i < numSessions; i++) {
977
+ const { message: init, ephemeralKeyPair } = createHandshakeInit();
978
+ const { message: accept, result: daemonResult } = processHandshakeInit(
979
+ init,
980
+ daemonId,
981
+ daemonIdentity,
982
+ );
983
+ const clientResult = processHandshakeAccept(
984
+ accept,
985
+ daemonId,
986
+ daemonIdentity.publicKey,
987
+ ephemeralKeyPair,
988
+ );
989
+
990
+ sessions.push({
991
+ clientSession: createClientSession(
992
+ asClientId(`stress-${i}`),
993
+ daemonResult.sessionKeys,
994
+ ),
995
+ daemonSession: createDaemonSession(clientResult.sessionKeys),
996
+ id: i,
997
+ });
998
+ }
999
+
1000
+ // Exchange messages on all sessions
1001
+ for (const session of sessions) {
1002
+ const message = textEncoder.encode(
1003
+ `Message from session ${session.id}`,
1004
+ );
1005
+ const encrypted = encryptClientToDaemon(session.daemonSession, message);
1006
+ const decrypted = decryptClientToDaemon(
1007
+ session.clientSession,
1008
+ encrypted,
1009
+ );
1010
+ expect(textDecoder.decode(decrypted)).toBe(
1011
+ `Message from session ${session.id}`,
1012
+ );
1013
+ }
1014
+
1015
+ // Verify session isolation - try to decrypt with wrong session
1016
+ const msg0 = encryptClientToDaemon(
1017
+ sessions[0].daemonSession,
1018
+ textEncoder.encode("Session 0"),
1019
+ );
1020
+ expect(() =>
1021
+ decryptClientToDaemon(sessions[1].clientSession, msg0),
1022
+ ).toThrow(SbrpError);
1023
+ });
1024
+ });
1025
+ });