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