@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sideband/secure-relay",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Secure Relay Protocol (SBRP): E2EE handshake, session encryption, and TOFU identity pinning for relay-mediated communication.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -49,9 +49,6 @@
49
49
  "@noble/curves": "^1.8.1",
50
50
  "@noble/hashes": "^1.7.1"
51
51
  },
52
- "devDependencies": {
53
- "@types/bun": "^1.3.3"
54
- },
55
52
  "files": [
56
53
  "dist",
57
54
  "src"
package/src/constants.ts CHANGED
@@ -1,5 +1,4 @@
1
- // SPDX-FileCopyrightText: 2025-present Sideband
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
1
+ // SPDX-License-Identifier: Apache-2.0
3
2
 
4
3
  /**
5
4
  * Protocol constants for Sideband Relay Protocol (SBRP).
@@ -41,9 +40,35 @@ export const NONCE_LENGTH = 12;
41
40
  /** Length of Poly1305 auth tag in bytes */
42
41
  export const AUTH_TAG_LENGTH = 16;
43
42
 
44
- /** Default replay window size (bits) */
45
- export const DEFAULT_REPLAY_WINDOW_SIZE = 64n;
43
+ /** Default replay window size (bits). Spec requires ≥64, recommends ≥128. */
44
+ export const DEFAULT_REPLAY_WINDOW_SIZE = 128n;
46
45
 
47
46
  /** Direction bytes in nonce (4 bytes, big-endian) */
48
47
  export const DIRECTION_CLIENT_TO_DAEMON = new Uint8Array([0, 0, 0, 1]);
49
48
  export const DIRECTION_DAEMON_TO_CLIENT = new Uint8Array([0, 0, 0, 2]);
49
+
50
+ // Wire format constants (§13)
51
+
52
+ /** Frame header size: type (1) + length (4) + sessionId (8) */
53
+ export const FRAME_HEADER_SIZE = 13;
54
+
55
+ /** Maximum payload size in bytes (64 KB) */
56
+ export const MAX_PAYLOAD_SIZE = 65536;
57
+
58
+ /** HandshakeInit payload: X25519 ephemeral public key */
59
+ export const HANDSHAKE_INIT_PAYLOAD_SIZE = 32;
60
+
61
+ /** HandshakeAccept payload: X25519 ephemeral (32) + Ed25519 signature (64) */
62
+ export const HANDSHAKE_ACCEPT_PAYLOAD_SIZE = 96;
63
+
64
+ /** Minimum encrypted payload: nonce (12) + authTag (16), no plaintext */
65
+ export const MIN_ENCRYPTED_PAYLOAD_SIZE = NONCE_LENGTH + AUTH_TAG_LENGTH;
66
+
67
+ /** Control payload minimum: code (2 bytes) */
68
+ export const MIN_CONTROL_PAYLOAD_SIZE = 2;
69
+
70
+ /** Signal payload size: signal (1) + reason (1) */
71
+ export const SIGNAL_PAYLOAD_SIZE = 2;
72
+
73
+ /** Maximum Ping/Pong payload size (0-8 bytes for RTT nonce) */
74
+ export const MAX_PING_PAYLOAD_SIZE = 8;
@@ -0,0 +1,644 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ ED25519_PUBLIC_KEY_LENGTH,
6
+ ED25519_SIGNATURE_LENGTH,
7
+ NONCE_LENGTH,
8
+ SYMMETRIC_KEY_LENGTH,
9
+ X25519_PUBLIC_KEY_LENGTH,
10
+ } from "./constants.js";
11
+ import {
12
+ computeFingerprint,
13
+ computeSharedSecret,
14
+ constructNonce,
15
+ createSignaturePayload,
16
+ createTranscriptHash,
17
+ decrypt,
18
+ deriveSessionKeys,
19
+ encrypt,
20
+ extractSequence,
21
+ generateEphemeralKeyPair,
22
+ generateIdentityKeyPair,
23
+ signPayload,
24
+ verifySignature,
25
+ zeroize,
26
+ } from "./crypto.js";
27
+ import { asDaemonId, Direction } from "./types.js";
28
+
29
+ describe("crypto", () => {
30
+ describe("generateIdentityKeyPair", () => {
31
+ it("produces 32-byte public key", () => {
32
+ const { publicKey } = generateIdentityKeyPair();
33
+ expect(publicKey.length).toBe(ED25519_PUBLIC_KEY_LENGTH);
34
+ });
35
+
36
+ it("produces 32-byte private key (seed)", () => {
37
+ const { privateKey } = generateIdentityKeyPair();
38
+ expect(privateKey.length).toBe(32);
39
+ });
40
+
41
+ it("generates unique keypairs", () => {
42
+ const kp1 = generateIdentityKeyPair();
43
+ const kp2 = generateIdentityKeyPair();
44
+ expect(kp1.publicKey).not.toEqual(kp2.publicKey);
45
+ expect(kp1.privateKey).not.toEqual(kp2.privateKey);
46
+ });
47
+ });
48
+
49
+ describe("generateEphemeralKeyPair", () => {
50
+ it("produces 32-byte public key", () => {
51
+ const { publicKey } = generateEphemeralKeyPair();
52
+ expect(publicKey.length).toBe(X25519_PUBLIC_KEY_LENGTH);
53
+ });
54
+
55
+ it("produces 32-byte private key", () => {
56
+ const { privateKey } = generateEphemeralKeyPair();
57
+ expect(privateKey.length).toBe(32);
58
+ });
59
+
60
+ it("generates unique keypairs", () => {
61
+ const kp1 = generateEphemeralKeyPair();
62
+ const kp2 = generateEphemeralKeyPair();
63
+ expect(kp1.publicKey).not.toEqual(kp2.publicKey);
64
+ expect(kp1.privateKey).not.toEqual(kp2.privateKey);
65
+ });
66
+ });
67
+
68
+ describe("computeFingerprint", () => {
69
+ it("produces SHA256:XX:XX:... format", () => {
70
+ const { publicKey } = generateIdentityKeyPair();
71
+ const fingerprint = computeFingerprint(publicKey);
72
+ expect(fingerprint.startsWith("SHA256:")).toBe(true);
73
+ });
74
+
75
+ it("produces uppercase hex with colons", () => {
76
+ const { publicKey } = generateIdentityKeyPair();
77
+ const fingerprint = computeFingerprint(publicKey);
78
+ // SHA256: prefix + 32 bytes as hex with colons (64 hex chars + 31 colons)
79
+ expect(fingerprint.length).toBe(7 + 64 + 31); // "SHA256:" + "XX:XX:..."
80
+ const hexPart = fingerprint.slice(7);
81
+ expect(hexPart).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/);
82
+ });
83
+
84
+ it("produces consistent fingerprint for same key", () => {
85
+ const { publicKey } = generateIdentityKeyPair();
86
+ const fp1 = computeFingerprint(publicKey);
87
+ const fp2 = computeFingerprint(publicKey);
88
+ expect(fp1).toBe(fp2);
89
+ });
90
+
91
+ it("produces different fingerprints for different keys", () => {
92
+ const kp1 = generateIdentityKeyPair();
93
+ const kp2 = generateIdentityKeyPair();
94
+ const fp1 = computeFingerprint(kp1.publicKey);
95
+ const fp2 = computeFingerprint(kp2.publicKey);
96
+ expect(fp1).not.toBe(fp2);
97
+ });
98
+ });
99
+
100
+ describe("createSignaturePayload", () => {
101
+ it("returns 32-byte SHA-256 hash", () => {
102
+ const daemonId = asDaemonId("test-daemon");
103
+ const clientPub = new Uint8Array(32).fill(0xaa);
104
+ const daemonEphPub = new Uint8Array(32).fill(0xbb);
105
+
106
+ const payload = createSignaturePayload(daemonId, clientPub, daemonEphPub);
107
+ expect(payload.length).toBe(32);
108
+ });
109
+
110
+ it("produces consistent hash for same inputs", () => {
111
+ const daemonId = asDaemonId("test-daemon");
112
+ const clientPub = new Uint8Array(32).fill(0xaa);
113
+ const daemonEphPub = new Uint8Array(32).fill(0xbb);
114
+
115
+ const p1 = createSignaturePayload(daemonId, clientPub, daemonEphPub);
116
+ const p2 = createSignaturePayload(daemonId, clientPub, daemonEphPub);
117
+ expect(p1).toEqual(p2);
118
+ });
119
+
120
+ it("produces different hash for different inputs", () => {
121
+ const clientPub = new Uint8Array(32).fill(0xaa);
122
+ const daemonEphPub = new Uint8Array(32).fill(0xbb);
123
+
124
+ const p1 = createSignaturePayload(
125
+ asDaemonId("daemon-1"),
126
+ clientPub,
127
+ daemonEphPub,
128
+ );
129
+ const p2 = createSignaturePayload(
130
+ asDaemonId("daemon-2"),
131
+ clientPub,
132
+ daemonEphPub,
133
+ );
134
+ expect(p1).not.toEqual(p2);
135
+ });
136
+ });
137
+
138
+ describe("signPayload / verifySignature", () => {
139
+ it("produces valid signature that verifies", () => {
140
+ const { publicKey, privateKey } = generateIdentityKeyPair();
141
+ const payload = new Uint8Array(32).fill(0x42);
142
+
143
+ const signature = signPayload(payload, privateKey);
144
+ expect(signature.length).toBe(ED25519_SIGNATURE_LENGTH);
145
+
146
+ const valid = verifySignature(payload, signature, publicKey);
147
+ expect(valid).toBe(true);
148
+ });
149
+
150
+ it("rejects tampered signature", () => {
151
+ const { publicKey, privateKey } = generateIdentityKeyPair();
152
+ const payload = new Uint8Array(32).fill(0x42);
153
+
154
+ const signature = signPayload(payload, privateKey);
155
+ // Tamper with signature
156
+ signature[0] ^= 0xff;
157
+
158
+ const valid = verifySignature(payload, signature, publicKey);
159
+ expect(valid).toBe(false);
160
+ });
161
+
162
+ it("rejects signature from wrong key", () => {
163
+ const kp1 = generateIdentityKeyPair();
164
+ const kp2 = generateIdentityKeyPair();
165
+ const payload = new Uint8Array(32).fill(0x42);
166
+
167
+ const signature = signPayload(payload, kp1.privateKey);
168
+ const valid = verifySignature(payload, signature, kp2.publicKey);
169
+ expect(valid).toBe(false);
170
+ });
171
+
172
+ it("rejects signature for different payload", () => {
173
+ const { publicKey, privateKey } = generateIdentityKeyPair();
174
+ const payload1 = new Uint8Array(32).fill(0x42);
175
+ const payload2 = new Uint8Array(32).fill(0x43);
176
+
177
+ const signature = signPayload(payload1, privateKey);
178
+ const valid = verifySignature(payload2, signature, publicKey);
179
+ expect(valid).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe("computeSharedSecret", () => {
184
+ it("produces 32-byte shared secret", () => {
185
+ const alice = generateEphemeralKeyPair();
186
+ const bob = generateEphemeralKeyPair();
187
+
188
+ const secretA = computeSharedSecret(alice.privateKey, bob.publicKey);
189
+ expect(secretA.length).toBe(32);
190
+ });
191
+
192
+ it("produces same secret from both sides (Diffie-Hellman)", () => {
193
+ const alice = generateEphemeralKeyPair();
194
+ const bob = generateEphemeralKeyPair();
195
+
196
+ const secretA = computeSharedSecret(alice.privateKey, bob.publicKey);
197
+ const secretB = computeSharedSecret(bob.privateKey, alice.publicKey);
198
+ expect(secretA).toEqual(secretB);
199
+ });
200
+
201
+ it("produces different secrets with different peers", () => {
202
+ const alice = generateEphemeralKeyPair();
203
+ const bob = generateEphemeralKeyPair();
204
+ const charlie = generateEphemeralKeyPair();
205
+
206
+ const secretAB = computeSharedSecret(alice.privateKey, bob.publicKey);
207
+ const secretAC = computeSharedSecret(alice.privateKey, charlie.publicKey);
208
+ expect(secretAB).not.toEqual(secretAC);
209
+ });
210
+ });
211
+
212
+ describe("createTranscriptHash", () => {
213
+ it("returns 32-byte SHA-256 hash", () => {
214
+ const daemonId = asDaemonId("test-daemon");
215
+ const clientPub = new Uint8Array(32).fill(0xaa);
216
+ const daemonPub = new Uint8Array(32).fill(0xbb);
217
+ const signature = new Uint8Array(64).fill(0xcc);
218
+
219
+ const hash = createTranscriptHash(
220
+ daemonId,
221
+ clientPub,
222
+ daemonPub,
223
+ signature,
224
+ );
225
+ expect(hash.length).toBe(32);
226
+ });
227
+
228
+ it("produces consistent hash for same inputs", () => {
229
+ const daemonId = asDaemonId("test-daemon");
230
+ const clientPub = new Uint8Array(32).fill(0xaa);
231
+ const daemonPub = new Uint8Array(32).fill(0xbb);
232
+ const signature = new Uint8Array(64).fill(0xcc);
233
+
234
+ const h1 = createTranscriptHash(
235
+ daemonId,
236
+ clientPub,
237
+ daemonPub,
238
+ signature,
239
+ );
240
+ const h2 = createTranscriptHash(
241
+ daemonId,
242
+ clientPub,
243
+ daemonPub,
244
+ signature,
245
+ );
246
+ expect(h1).toEqual(h2);
247
+ });
248
+
249
+ it("produces different hash for different signatures", () => {
250
+ const daemonId = asDaemonId("test-daemon");
251
+ const clientPub = new Uint8Array(32).fill(0xaa);
252
+ const daemonPub = new Uint8Array(32).fill(0xbb);
253
+ const sig1 = new Uint8Array(64).fill(0xcc);
254
+ const sig2 = new Uint8Array(64).fill(0xdd);
255
+
256
+ const h1 = createTranscriptHash(daemonId, clientPub, daemonPub, sig1);
257
+ const h2 = createTranscriptHash(daemonId, clientPub, daemonPub, sig2);
258
+ expect(h1).not.toEqual(h2);
259
+ });
260
+ });
261
+
262
+ describe("deriveSessionKeys", () => {
263
+ it("produces two 32-byte keys", () => {
264
+ const sharedSecret = new Uint8Array(32).fill(0x42);
265
+ const transcriptHash = new Uint8Array(32).fill(0x43);
266
+
267
+ const keys = deriveSessionKeys(sharedSecret, transcriptHash);
268
+ expect(keys.clientToDaemon.length).toBe(SYMMETRIC_KEY_LENGTH);
269
+ expect(keys.daemonToClient.length).toBe(SYMMETRIC_KEY_LENGTH);
270
+ });
271
+
272
+ it("produces different keys for each direction", () => {
273
+ const sharedSecret = new Uint8Array(32).fill(0x42);
274
+ const transcriptHash = new Uint8Array(32).fill(0x43);
275
+
276
+ const keys = deriveSessionKeys(sharedSecret, transcriptHash);
277
+ expect(keys.clientToDaemon).not.toEqual(keys.daemonToClient);
278
+ });
279
+
280
+ it("produces consistent keys for same inputs", () => {
281
+ const sharedSecret = new Uint8Array(32).fill(0x42);
282
+ const transcriptHash = new Uint8Array(32).fill(0x43);
283
+
284
+ const keys1 = deriveSessionKeys(sharedSecret, transcriptHash);
285
+ const keys2 = deriveSessionKeys(sharedSecret, transcriptHash);
286
+ expect(keys1.clientToDaemon).toEqual(keys2.clientToDaemon);
287
+ expect(keys1.daemonToClient).toEqual(keys2.daemonToClient);
288
+ });
289
+
290
+ it("produces different keys for different shared secrets", () => {
291
+ const transcriptHash = new Uint8Array(32).fill(0x43);
292
+ const secret1 = new Uint8Array(32).fill(0x42);
293
+ const secret2 = new Uint8Array(32).fill(0x44);
294
+
295
+ const keys1 = deriveSessionKeys(secret1, transcriptHash);
296
+ const keys2 = deriveSessionKeys(secret2, transcriptHash);
297
+ expect(keys1.clientToDaemon).not.toEqual(keys2.clientToDaemon);
298
+ expect(keys1.daemonToClient).not.toEqual(keys2.daemonToClient);
299
+ });
300
+
301
+ it("produces different keys for different transcript hashes", () => {
302
+ const sharedSecret = new Uint8Array(32).fill(0x42);
303
+ const hash1 = new Uint8Array(32).fill(0x43);
304
+ const hash2 = new Uint8Array(32).fill(0x44);
305
+
306
+ const keys1 = deriveSessionKeys(sharedSecret, hash1);
307
+ const keys2 = deriveSessionKeys(sharedSecret, hash2);
308
+ expect(keys1.clientToDaemon).not.toEqual(keys2.clientToDaemon);
309
+ });
310
+ });
311
+
312
+ describe("constructNonce", () => {
313
+ it("produces 12-byte nonce", () => {
314
+ const nonce = constructNonce(Direction.ClientToDaemon, 0n);
315
+ expect(nonce.length).toBe(NONCE_LENGTH);
316
+ });
317
+
318
+ it("encodes client→daemon direction as 0x00000001", () => {
319
+ const nonce = constructNonce(Direction.ClientToDaemon, 0n);
320
+ expect(nonce[0]).toBe(0);
321
+ expect(nonce[1]).toBe(0);
322
+ expect(nonce[2]).toBe(0);
323
+ expect(nonce[3]).toBe(1);
324
+ });
325
+
326
+ it("encodes daemon→client direction as 0x00000002", () => {
327
+ const nonce = constructNonce(Direction.DaemonToClient, 0n);
328
+ expect(nonce[0]).toBe(0);
329
+ expect(nonce[1]).toBe(0);
330
+ expect(nonce[2]).toBe(0);
331
+ expect(nonce[3]).toBe(2);
332
+ });
333
+
334
+ it("encodes sequence number in big-endian (bytes 4-11)", () => {
335
+ const nonce = constructNonce(
336
+ Direction.ClientToDaemon,
337
+ 0x0102030405060708n,
338
+ );
339
+ expect(nonce[4]).toBe(0x01);
340
+ expect(nonce[5]).toBe(0x02);
341
+ expect(nonce[6]).toBe(0x03);
342
+ expect(nonce[7]).toBe(0x04);
343
+ expect(nonce[8]).toBe(0x05);
344
+ expect(nonce[9]).toBe(0x06);
345
+ expect(nonce[10]).toBe(0x07);
346
+ expect(nonce[11]).toBe(0x08);
347
+ });
348
+
349
+ it("handles sequence number 0", () => {
350
+ const nonce = constructNonce(Direction.ClientToDaemon, 0n);
351
+ const view = new DataView(nonce.buffer, nonce.byteOffset);
352
+ expect(view.getBigUint64(4, false)).toBe(0n);
353
+ });
354
+
355
+ it("handles max sequence number", () => {
356
+ const maxSeq = 0xffff_ffff_ffff_ffffn;
357
+ const nonce = constructNonce(Direction.ClientToDaemon, maxSeq);
358
+ const view = new DataView(nonce.buffer, nonce.byteOffset);
359
+ expect(view.getBigUint64(4, false)).toBe(maxSeq);
360
+ });
361
+ });
362
+
363
+ describe("encrypt / decrypt", () => {
364
+ it("roundtrips plaintext correctly", () => {
365
+ const key = new Uint8Array(32).fill(0x42);
366
+ const plaintext = new TextEncoder().encode("Hello, SBRP!");
367
+
368
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
369
+ const decrypted = decrypt(key, encrypted);
370
+ expect(decrypted).toEqual(plaintext);
371
+ });
372
+
373
+ it("produces different ciphertext for different sequences", () => {
374
+ const key = new Uint8Array(32).fill(0x42);
375
+ const plaintext = new Uint8Array(16).fill(0xaa);
376
+
377
+ const enc1 = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
378
+ const enc2 = encrypt(key, Direction.ClientToDaemon, 2n, plaintext);
379
+ // Nonces differ, so ciphertext differs
380
+ expect(enc1).not.toEqual(enc2);
381
+ });
382
+
383
+ it("produces different ciphertext for different directions", () => {
384
+ const key = new Uint8Array(32).fill(0x42);
385
+ const plaintext = new Uint8Array(16).fill(0xaa);
386
+
387
+ const enc1 = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
388
+ const enc2 = encrypt(key, Direction.DaemonToClient, 1n, plaintext);
389
+ expect(enc1).not.toEqual(enc2);
390
+ });
391
+
392
+ it("includes nonce as prefix in output", () => {
393
+ const key = new Uint8Array(32).fill(0x42);
394
+ const plaintext = new Uint8Array(16).fill(0xaa);
395
+
396
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 42n, plaintext);
397
+ // First 12 bytes should be the nonce
398
+ expect(encrypted.length).toBeGreaterThanOrEqual(NONCE_LENGTH + 16); // nonce + authTag
399
+ const nonce = encrypted.slice(0, NONCE_LENGTH);
400
+ const expected = constructNonce(Direction.ClientToDaemon, 42n);
401
+ expect(nonce).toEqual(expected);
402
+ });
403
+
404
+ it("decryption fails with wrong key", () => {
405
+ const key1 = new Uint8Array(32).fill(0x42);
406
+ const key2 = new Uint8Array(32).fill(0x43);
407
+ const plaintext = new TextEncoder().encode("secret");
408
+
409
+ const encrypted = encrypt(key1, Direction.ClientToDaemon, 1n, plaintext);
410
+ expect(() => decrypt(key2, encrypted)).toThrow();
411
+ });
412
+
413
+ it("decryption fails with tampered ciphertext", () => {
414
+ const key = new Uint8Array(32).fill(0x42);
415
+ const plaintext = new TextEncoder().encode("secret");
416
+
417
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
418
+ // Tamper with ciphertext (after nonce)
419
+ encrypted[NONCE_LENGTH + 1] ^= 0xff;
420
+
421
+ expect(() => decrypt(key, encrypted)).toThrow();
422
+ });
423
+
424
+ it("decryption fails with tampered auth tag", () => {
425
+ const key = new Uint8Array(32).fill(0x42);
426
+ const plaintext = new TextEncoder().encode("secret");
427
+
428
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
429
+ // Tamper with last byte (auth tag)
430
+ encrypted[encrypted.length - 1] ^= 0xff;
431
+
432
+ expect(() => decrypt(key, encrypted)).toThrow();
433
+ });
434
+
435
+ it("decryption fails with tampered nonce", () => {
436
+ const key = new Uint8Array(32).fill(0x42);
437
+ const plaintext = new TextEncoder().encode("secret");
438
+
439
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
440
+ // Tamper with nonce
441
+ encrypted[0] ^= 0xff;
442
+
443
+ expect(() => decrypt(key, encrypted)).toThrow();
444
+ });
445
+
446
+ it("rejects message too short", () => {
447
+ const key = new Uint8Array(32).fill(0x42);
448
+ const tooShort = new Uint8Array(NONCE_LENGTH + 15); // 27 bytes, need 28
449
+
450
+ expect(() => decrypt(key, tooShort)).toThrow(/too short/);
451
+ });
452
+
453
+ it("handles empty plaintext", () => {
454
+ const key = new Uint8Array(32).fill(0x42);
455
+ const plaintext = new Uint8Array(0);
456
+
457
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
458
+ const decrypted = decrypt(key, encrypted);
459
+ expect(decrypted.length).toBe(0);
460
+ });
461
+
462
+ it("handles large plaintext", () => {
463
+ const key = new Uint8Array(32).fill(0x42);
464
+ const plaintext = new Uint8Array(65536).fill(0xab);
465
+
466
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
467
+ const decrypted = decrypt(key, encrypted);
468
+ expect(decrypted).toEqual(plaintext);
469
+ });
470
+ });
471
+
472
+ describe("extractSequence", () => {
473
+ it("extracts sequence from encrypted message", () => {
474
+ const key = new Uint8Array(32).fill(0x42);
475
+ const plaintext = new Uint8Array(16);
476
+
477
+ const encrypted = encrypt(
478
+ key,
479
+ Direction.ClientToDaemon,
480
+ 12345n,
481
+ plaintext,
482
+ );
483
+ const seq = extractSequence(encrypted);
484
+ expect(seq).toBe(12345n);
485
+ });
486
+
487
+ it("extracts zero sequence", () => {
488
+ const key = new Uint8Array(32).fill(0x42);
489
+ const plaintext = new Uint8Array(16);
490
+
491
+ const encrypted = encrypt(key, Direction.ClientToDaemon, 0n, plaintext);
492
+ const seq = extractSequence(encrypted);
493
+ expect(seq).toBe(0n);
494
+ });
495
+
496
+ it("extracts max sequence", () => {
497
+ const key = new Uint8Array(32).fill(0x42);
498
+ const plaintext = new Uint8Array(16);
499
+ const maxSeq = 0xffff_ffff_ffff_ffffn;
500
+
501
+ const encrypted = encrypt(
502
+ key,
503
+ Direction.ClientToDaemon,
504
+ maxSeq,
505
+ plaintext,
506
+ );
507
+ const seq = extractSequence(encrypted);
508
+ expect(seq).toBe(maxSeq);
509
+ });
510
+
511
+ it("rejects data too short for nonce", () => {
512
+ const tooShort = new Uint8Array(11); // Need at least 12 bytes
513
+ expect(() => extractSequence(tooShort)).toThrow(/too short/);
514
+ });
515
+
516
+ it("works with minimum-length encrypted data", () => {
517
+ const nonce = constructNonce(Direction.ClientToDaemon, 42n);
518
+ const seq = extractSequence(nonce);
519
+ expect(seq).toBe(42n);
520
+ });
521
+ });
522
+
523
+ describe("zeroize", () => {
524
+ it("fills array with zeros", () => {
525
+ const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
526
+ zeroize(data);
527
+ expect(data).toEqual(new Uint8Array(8));
528
+ });
529
+
530
+ it("handles empty array", () => {
531
+ const data = new Uint8Array(0);
532
+ zeroize(data);
533
+ expect(data.length).toBe(0);
534
+ });
535
+
536
+ it("handles large array", () => {
537
+ const data = new Uint8Array(1024).fill(0xff);
538
+ zeroize(data);
539
+ expect(data.every((b) => b === 0)).toBe(true);
540
+ });
541
+
542
+ it("zeroizes key material", () => {
543
+ const { privateKey } = generateIdentityKeyPair();
544
+ expect(privateKey.some((b) => b !== 0)).toBe(true);
545
+
546
+ zeroize(privateKey);
547
+ expect(privateKey.every((b) => b === 0)).toBe(true);
548
+ });
549
+ });
550
+
551
+ describe("end-to-end handshake flow", () => {
552
+ it("client and daemon derive same session keys", () => {
553
+ const daemonId = asDaemonId("test-daemon");
554
+ const daemonIdentity = generateIdentityKeyPair();
555
+
556
+ // Client generates ephemeral keypair
557
+ const clientEphemeral = generateEphemeralKeyPair();
558
+
559
+ // Daemon generates ephemeral keypair
560
+ const daemonEphemeral = generateEphemeralKeyPair();
561
+
562
+ // Daemon creates and signs payload
563
+ const payload = createSignaturePayload(
564
+ daemonId,
565
+ clientEphemeral.publicKey,
566
+ daemonEphemeral.publicKey,
567
+ );
568
+ const signature = signPayload(payload, daemonIdentity.privateKey);
569
+
570
+ // Client verifies signature
571
+ const valid = verifySignature(
572
+ payload,
573
+ signature,
574
+ daemonIdentity.publicKey,
575
+ );
576
+ expect(valid).toBe(true);
577
+
578
+ // Both sides compute shared secret
579
+ const clientSecret = computeSharedSecret(
580
+ clientEphemeral.privateKey,
581
+ daemonEphemeral.publicKey,
582
+ );
583
+ const daemonSecret = computeSharedSecret(
584
+ daemonEphemeral.privateKey,
585
+ clientEphemeral.publicKey,
586
+ );
587
+ expect(clientSecret).toEqual(daemonSecret);
588
+
589
+ // Both sides compute transcript hash
590
+ const clientTranscript = createTranscriptHash(
591
+ daemonId,
592
+ clientEphemeral.publicKey,
593
+ daemonEphemeral.publicKey,
594
+ signature,
595
+ );
596
+ const daemonTranscript = createTranscriptHash(
597
+ daemonId,
598
+ clientEphemeral.publicKey,
599
+ daemonEphemeral.publicKey,
600
+ signature,
601
+ );
602
+ expect(clientTranscript).toEqual(daemonTranscript);
603
+
604
+ // Both sides derive session keys
605
+ const clientKeys = deriveSessionKeys(clientSecret, clientTranscript);
606
+ const daemonKeys = deriveSessionKeys(daemonSecret, daemonTranscript);
607
+ expect(clientKeys.clientToDaemon).toEqual(daemonKeys.clientToDaemon);
608
+ expect(clientKeys.daemonToClient).toEqual(daemonKeys.daemonToClient);
609
+ });
610
+
611
+ it("client can encrypt and daemon can decrypt (and vice versa)", () => {
612
+ const sharedSecret = new Uint8Array(32).fill(0x42);
613
+ const transcriptHash = new Uint8Array(32).fill(0x43);
614
+ const keys = deriveSessionKeys(sharedSecret, transcriptHash);
615
+
616
+ const clientMessage = new TextEncoder().encode("Hello from client");
617
+ const daemonMessage = new TextEncoder().encode("Hello from daemon");
618
+
619
+ // Client encrypts with clientToDaemon key
620
+ const clientEncrypted = encrypt(
621
+ keys.clientToDaemon,
622
+ Direction.ClientToDaemon,
623
+ 1n,
624
+ clientMessage,
625
+ );
626
+
627
+ // Daemon decrypts with same key
628
+ const clientDecrypted = decrypt(keys.clientToDaemon, clientEncrypted);
629
+ expect(clientDecrypted).toEqual(clientMessage);
630
+
631
+ // Daemon encrypts with daemonToClient key
632
+ const daemonEncrypted = encrypt(
633
+ keys.daemonToClient,
634
+ Direction.DaemonToClient,
635
+ 1n,
636
+ daemonMessage,
637
+ );
638
+
639
+ // Client decrypts with same key
640
+ const daemonDecrypted = decrypt(keys.daemonToClient, daemonEncrypted);
641
+ expect(daemonDecrypted).toEqual(daemonMessage);
642
+ });
643
+ });
644
+ });