@sideband/secure-relay 0.2.1 → 0.2.3

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.
Files changed (46) hide show
  1. package/README.md +27 -8
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/constants.d.ts +49 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +51 -0
  6. package/dist/constants.js.map +1 -0
  7. package/dist/crypto.d.ts +70 -0
  8. package/dist/crypto.d.ts.map +1 -0
  9. package/dist/crypto.js +144 -0
  10. package/dist/crypto.js.map +1 -0
  11. package/dist/frame.d.ts +213 -0
  12. package/dist/frame.d.ts.map +1 -0
  13. package/dist/frame.js +547 -0
  14. package/dist/frame.js.map +1 -0
  15. package/dist/handshake.d.ts +39 -0
  16. package/dist/handshake.d.ts.map +1 -0
  17. package/dist/handshake.js +93 -0
  18. package/dist/handshake.js.map +1 -0
  19. package/dist/index.d.ts +46 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +12 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/replay.d.ts +32 -0
  24. package/dist/replay.d.ts.map +1 -0
  25. package/dist/replay.js +88 -0
  26. package/dist/replay.js.map +1 -0
  27. package/dist/session.d.ts +67 -0
  28. package/dist/session.d.ts.map +1 -0
  29. package/dist/session.js +122 -0
  30. package/dist/session.js.map +1 -0
  31. package/dist/types.d.ts +119 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +80 -0
  34. package/dist/types.js.map +1 -0
  35. package/package.json +4 -4
  36. package/src/constants.ts +3 -3
  37. package/src/crypto.test.ts +5 -5
  38. package/src/crypto.ts +9 -9
  39. package/src/frame.test.ts +59 -10
  40. package/src/frame.ts +101 -77
  41. package/src/handshake.test.ts +29 -41
  42. package/src/handshake.ts +25 -27
  43. package/src/index.ts +4 -10
  44. package/src/integration.test.ts +97 -138
  45. package/src/session.test.ts +12 -10
  46. package/src/types.ts +1 -12
package/src/frame.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import {
17
+ ED25519_PUBLIC_KEY_LENGTH,
18
+ ED25519_SIGNATURE_LENGTH,
17
19
  FRAME_HEADER_SIZE,
18
20
  HANDSHAKE_ACCEPT_PAYLOAD_SIZE,
19
21
  HANDSHAKE_INIT_PAYLOAD_SIZE,
@@ -22,6 +24,7 @@ import {
22
24
  MIN_CONTROL_PAYLOAD_SIZE,
23
25
  MIN_ENCRYPTED_PAYLOAD_SIZE,
24
26
  SIGNAL_PAYLOAD_SIZE,
27
+ X25519_PUBLIC_KEY_LENGTH,
25
28
  } from "./constants.js";
26
29
  import { extractSequence } from "./crypto.js";
27
30
  import type {
@@ -29,10 +32,11 @@ import type {
29
32
  HandshakeAccept,
30
33
  HandshakeInit,
31
34
  SessionId,
32
- SignalCode,
33
- SignalReason,
34
35
  } from "./types.js";
35
- import { SbrpError, SbrpErrorCode } from "./types.js";
36
+ import { SbrpError, SbrpErrorCode, SignalCode, SignalReason } from "./types.js";
37
+
38
+ const textEncoder = new TextEncoder();
39
+ const textDecoder = new TextDecoder("utf-8", { fatal: false });
36
40
 
37
41
  /**
38
42
  * Frame type discriminant (wire byte).
@@ -111,12 +115,17 @@ export type WireControlCode =
111
115
 
112
116
  /** Check if a control code is terminal (closes connection) */
113
117
  export function isTerminalCode(code: WireControlCode): boolean {
114
- // Non-terminal codes: daemon_offline (0x0202), rate_limited (0x09xx), session state (0x10xx)
115
- return (
116
- code !== WireControlCode.DaemonOffline &&
117
- code !== WireControlCode.RateLimited &&
118
- code < 0x1000
119
- );
118
+ switch (code) {
119
+ case WireControlCode.DaemonOffline:
120
+ case WireControlCode.RateLimited:
121
+ case WireControlCode.SessionPaused:
122
+ case WireControlCode.SessionResumed:
123
+ case WireControlCode.SessionEnded:
124
+ case WireControlCode.SessionPending:
125
+ return false;
126
+ default:
127
+ return true;
128
+ }
120
129
  }
121
130
 
122
131
  /** Decoded frame header */
@@ -332,6 +341,23 @@ export function readFrameHeader(data: Uint8Array): FrameHeader {
332
341
 
333
342
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
334
343
  const type = data[0] as FrameType;
344
+
345
+ switch (type) {
346
+ case FrameType.HandshakeInit:
347
+ case FrameType.HandshakeAccept:
348
+ case FrameType.Data:
349
+ case FrameType.Signal:
350
+ case FrameType.Ping:
351
+ case FrameType.Pong:
352
+ case FrameType.Control:
353
+ break;
354
+ default:
355
+ throw new SbrpError(
356
+ SbrpErrorCode.InvalidFrameType,
357
+ `Unknown frame type: 0x${(type as number).toString(16).padStart(2, "0")}`,
358
+ );
359
+ }
360
+
335
361
  const length = view.getUint32(1, false);
336
362
  const sessionId = view.getBigUint64(5, false);
337
363
 
@@ -391,28 +417,6 @@ export function decodeFrame(data: Uint8Array): Frame {
391
417
  return { ...header, payload };
392
418
  }
393
419
 
394
- /**
395
- * Decode frame allowing trailing bytes (for streaming use).
396
- * Returns the frame and number of bytes consumed.
397
- */
398
- function decodeFrameFromBuffer(data: Uint8Array): {
399
- frame: Frame;
400
- bytesConsumed: number;
401
- } {
402
- const header = readFrameHeader(data);
403
- const frameSize = FRAME_HEADER_SIZE + header.length;
404
-
405
- if (data.length < frameSize) {
406
- throw new SbrpError(
407
- SbrpErrorCode.MalformedFrame,
408
- `Frame truncated: got ${data.length}, expected ${frameSize}`,
409
- );
410
- }
411
-
412
- const payload = data.subarray(FRAME_HEADER_SIZE, frameSize);
413
- return { frame: { ...header, payload }, bytesConsumed: frameSize };
414
- }
415
-
416
420
  // ============================================================================
417
421
  // High-level frame encoding (typed message → binary)
418
422
  // ============================================================================
@@ -438,27 +442,39 @@ export function encodeHandshakeInit(
438
442
  /**
439
443
  * Encode HandshakeAccept to wire frame.
440
444
  *
441
- * @throws {SbrpError} if acceptPublicKey/signature have wrong sizes or sessionId is invalid
445
+ * Wire layout (128 bytes): identityPublicKey(32) + acceptPublicKey(32) + signature(64)
446
+ *
447
+ * @throws {SbrpError} if field sizes are wrong or sessionId is invalid
442
448
  */
443
449
  export function encodeHandshakeAccept(
444
450
  sessionId: SessionId,
445
451
  accept: HandshakeAccept,
446
452
  ): Uint8Array {
447
- if (accept.acceptPublicKey.length !== 32) {
453
+ if (accept.identityPublicKey.length !== ED25519_PUBLIC_KEY_LENGTH) {
454
+ throw new SbrpError(
455
+ SbrpErrorCode.MalformedFrame,
456
+ `identityPublicKey must be ${ED25519_PUBLIC_KEY_LENGTH} bytes, got ${accept.identityPublicKey.length}`,
457
+ );
458
+ }
459
+ if (accept.acceptPublicKey.length !== X25519_PUBLIC_KEY_LENGTH) {
448
460
  throw new SbrpError(
449
461
  SbrpErrorCode.MalformedFrame,
450
- `acceptPublicKey must be 32 bytes, got ${accept.acceptPublicKey.length}`,
462
+ `acceptPublicKey must be ${X25519_PUBLIC_KEY_LENGTH} bytes, got ${accept.acceptPublicKey.length}`,
451
463
  );
452
464
  }
453
- if (accept.signature.length !== 64) {
465
+ if (accept.signature.length !== ED25519_SIGNATURE_LENGTH) {
454
466
  throw new SbrpError(
455
467
  SbrpErrorCode.MalformedFrame,
456
- `signature must be 64 bytes, got ${accept.signature.length}`,
468
+ `signature must be ${ED25519_SIGNATURE_LENGTH} bytes, got ${accept.signature.length}`,
457
469
  );
458
470
  }
459
471
  const payload = new Uint8Array(HANDSHAKE_ACCEPT_PAYLOAD_SIZE);
460
- payload.set(accept.acceptPublicKey, 0);
461
- payload.set(accept.signature, 32);
472
+ payload.set(accept.identityPublicKey, 0);
473
+ payload.set(accept.acceptPublicKey, ED25519_PUBLIC_KEY_LENGTH);
474
+ payload.set(
475
+ accept.signature,
476
+ ED25519_PUBLIC_KEY_LENGTH + X25519_PUBLIC_KEY_LENGTH,
477
+ );
462
478
  return encodeFrame(FrameType.HandshakeAccept, sessionId, payload);
463
479
  }
464
480
 
@@ -490,7 +506,7 @@ export function encodeData(
490
506
  export function encodeSignal(
491
507
  sessionId: SessionId,
492
508
  signal: SignalCode,
493
- reason: SignalReason = 0x00,
509
+ reason: SignalReason = SignalReason.None,
494
510
  ): Uint8Array {
495
511
  const payload = new Uint8Array(SIGNAL_PAYLOAD_SIZE);
496
512
  payload[0] = signal;
@@ -544,7 +560,7 @@ export function encodeControl(
544
560
  code: WireControlCode,
545
561
  message?: string,
546
562
  ): Uint8Array {
547
- const msgBytes = message ? new TextEncoder().encode(message) : null;
563
+ const msgBytes = message ? textEncoder.encode(message) : null;
548
564
  const payload = new Uint8Array(2 + (msgBytes?.length ?? 0));
549
565
  new DataView(payload.buffer).setUint16(0, code, false);
550
566
  if (msgBytes) payload.set(msgBytes, 2);
@@ -555,26 +571,10 @@ export function encodeControl(
555
571
  // High-level frame decoding (Frame → typed message)
556
572
  // ============================================================================
557
573
 
558
- /** Validate sessionId for session-bound frames on decode path */
559
- function validateSessionIdOnDecode(frame: Frame): void {
560
- if (isSessionBound(frame.type) && frame.sessionId === 0n) {
561
- throw new SbrpError(
562
- SbrpErrorCode.MalformedFrame,
563
- `Session-bound frame type 0x${frame.type.toString(16).padStart(2, "0")} requires non-zero sessionId`,
564
- );
565
- }
566
- if (isConnectionScoped(frame.type) && frame.sessionId !== 0n) {
567
- throw new SbrpError(
568
- SbrpErrorCode.MalformedFrame,
569
- `Connection-scoped frame type 0x${frame.type.toString(16).padStart(2, "0")} requires sessionId = 0`,
570
- );
571
- }
572
- }
573
-
574
574
  /**
575
575
  * Decode HandshakeInit from frame.
576
576
  *
577
- * @throws {SbrpError} if frame type, payload size, or sessionId is invalid
577
+ * @throws {SbrpError} if frame type or payload size is invalid
578
578
  */
579
579
  export function decodeHandshakeInit(frame: Frame): HandshakeInit {
580
580
  if (frame.type !== FrameType.HandshakeInit) {
@@ -583,7 +583,6 @@ export function decodeHandshakeInit(frame: Frame): HandshakeInit {
583
583
  `Expected HandshakeInit (0x01), got 0x${frame.type.toString(16).padStart(2, "0")}`,
584
584
  );
585
585
  }
586
- validateSessionIdOnDecode(frame);
587
586
  if (frame.payload.length !== HANDSHAKE_INIT_PAYLOAD_SIZE) {
588
587
  throw new SbrpError(
589
588
  SbrpErrorCode.MalformedFrame,
@@ -599,7 +598,9 @@ export function decodeHandshakeInit(frame: Frame): HandshakeInit {
599
598
  /**
600
599
  * Decode HandshakeAccept from frame.
601
600
  *
602
- * @throws {SbrpError} if frame type, payload size, or sessionId is invalid
601
+ * Wire layout (128 bytes): identityPublicKey(32) + acceptPublicKey(32) + signature(64)
602
+ *
603
+ * @throws {SbrpError} if frame type or payload size is invalid
603
604
  */
604
605
  export function decodeHandshakeAccept(frame: Frame): HandshakeAccept {
605
606
  if (frame.type !== FrameType.HandshakeAccept) {
@@ -608,24 +609,25 @@ export function decodeHandshakeAccept(frame: Frame): HandshakeAccept {
608
609
  `Expected HandshakeAccept (0x02), got 0x${frame.type.toString(16).padStart(2, "0")}`,
609
610
  );
610
611
  }
611
- validateSessionIdOnDecode(frame);
612
612
  if (frame.payload.length !== HANDSHAKE_ACCEPT_PAYLOAD_SIZE) {
613
613
  throw new SbrpError(
614
614
  SbrpErrorCode.MalformedFrame,
615
615
  `HandshakeAccept payload must be ${HANDSHAKE_ACCEPT_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
616
616
  );
617
617
  }
618
+ const keyEnd = ED25519_PUBLIC_KEY_LENGTH + X25519_PUBLIC_KEY_LENGTH;
618
619
  return {
619
620
  type: "handshake.accept",
620
- acceptPublicKey: frame.payload.slice(0, 32),
621
- signature: frame.payload.slice(32, 96),
621
+ identityPublicKey: frame.payload.slice(0, ED25519_PUBLIC_KEY_LENGTH),
622
+ acceptPublicKey: frame.payload.slice(ED25519_PUBLIC_KEY_LENGTH, keyEnd),
623
+ signature: frame.payload.slice(keyEnd, keyEnd + ED25519_SIGNATURE_LENGTH),
622
624
  };
623
625
  }
624
626
 
625
627
  /**
626
628
  * Decode Data frame (encrypted message).
627
629
  *
628
- * @throws {SbrpError} if frame type, payload, or sessionId is invalid
630
+ * @throws {SbrpError} if frame type or payload is invalid
629
631
  */
630
632
  export function decodeData(frame: Frame): EncryptedMessage {
631
633
  if (frame.type !== FrameType.Data) {
@@ -634,7 +636,6 @@ export function decodeData(frame: Frame): EncryptedMessage {
634
636
  `Expected Data (0x03), got 0x${frame.type.toString(16).padStart(2, "0")}`,
635
637
  );
636
638
  }
637
- validateSessionIdOnDecode(frame);
638
639
  if (frame.payload.length < MIN_ENCRYPTED_PAYLOAD_SIZE) {
639
640
  throw new SbrpError(
640
641
  SbrpErrorCode.MalformedFrame,
@@ -652,7 +653,7 @@ export function decodeData(frame: Frame): EncryptedMessage {
652
653
  /**
653
654
  * Decode Signal frame (daemon → relay).
654
655
  *
655
- * @throws {SbrpError} if frame type, payload size, or sessionId is invalid
656
+ * @throws {SbrpError} if frame type, payload size, or signal values are invalid
656
657
  */
657
658
  export function decodeSignal(frame: Frame): SignalPayload {
658
659
  if (frame.type !== FrameType.Signal) {
@@ -661,17 +662,35 @@ export function decodeSignal(frame: Frame): SignalPayload {
661
662
  `Expected Signal (0x04), got 0x${frame.type.toString(16).padStart(2, "0")}`,
662
663
  );
663
664
  }
664
- validateSessionIdOnDecode(frame);
665
665
  if (frame.payload.length !== SIGNAL_PAYLOAD_SIZE) {
666
666
  throw new SbrpError(
667
667
  SbrpErrorCode.MalformedFrame,
668
668
  `Signal payload must be ${SIGNAL_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
669
669
  );
670
670
  }
671
- return {
672
- signal: frame.payload[0] as SignalCode,
673
- reason: frame.payload[1] as SignalReason,
674
- };
671
+ // Length validated above (exactly SIGNAL_PAYLOAD_SIZE = 2 bytes)
672
+ const signal = frame.payload[0]!;
673
+ const reason = frame.payload[1]!;
674
+ if (signal !== SignalCode.Ready && signal !== SignalCode.Close) {
675
+ throw new SbrpError(
676
+ SbrpErrorCode.MalformedFrame,
677
+ `Unknown signal code: 0x${signal.toString(16).padStart(2, "0")}`,
678
+ );
679
+ }
680
+ switch (reason) {
681
+ case SignalReason.None:
682
+ case SignalReason.StateLost:
683
+ case SignalReason.Shutdown:
684
+ case SignalReason.Policy:
685
+ case SignalReason.Error:
686
+ break;
687
+ default:
688
+ throw new SbrpError(
689
+ SbrpErrorCode.MalformedFrame,
690
+ `Unknown signal reason: 0x${reason.toString(16).padStart(2, "0")}`,
691
+ );
692
+ }
693
+ return { signal: signal as SignalCode, reason: reason as SignalReason };
675
694
  }
676
695
 
677
696
  /**
@@ -699,11 +718,16 @@ export function decodeControl(frame: Frame): ControlPayload {
699
718
  frame.payload.byteOffset,
700
719
  frame.payload.byteLength,
701
720
  );
702
- const code = view.getUint16(0, false) as WireControlCode;
721
+ const rawCode = view.getUint16(0, false);
722
+ if (wireToSbrp[rawCode] === undefined) {
723
+ throw new SbrpError(
724
+ SbrpErrorCode.MalformedFrame,
725
+ `Unknown control code: 0x${rawCode.toString(16).padStart(4, "0")}`,
726
+ );
727
+ }
728
+ const code = rawCode as WireControlCode;
703
729
  // TextDecoder with fatal:false replaces invalid UTF-8 with U+FFFD
704
- const message = new TextDecoder("utf-8", { fatal: false }).decode(
705
- frame.payload.subarray(2),
706
- );
730
+ const message = textDecoder.decode(frame.payload.subarray(2));
707
731
  return { code, message };
708
732
  }
709
733
 
@@ -753,9 +777,9 @@ export class FrameDecoder {
753
777
  break; // Incomplete frame, wait for more data
754
778
  }
755
779
 
756
- const { frame, bytesConsumed } = decodeFrameFromBuffer(this.buffer);
757
- yield frame;
758
- this.buffer = this.buffer.subarray(bytesConsumed);
780
+ const payload = this.buffer.subarray(FRAME_HEADER_SIZE, frameSize);
781
+ yield { ...header, payload };
782
+ this.buffer = this.buffer.subarray(frameSize);
759
783
  }
760
784
  }
761
785
 
@@ -21,14 +21,14 @@ describe("handshake", () => {
21
21
  createHandshakeInit();
22
22
 
23
23
  // Daemon processes init and creates accept
24
- const { message: accept, result: daemonResult } = processHandshakeInit(
24
+ const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
25
25
  init,
26
26
  daemonId,
27
27
  daemonIdentity,
28
28
  );
29
29
 
30
30
  // Client processes accept
31
- const clientResult = processHandshakeAccept(
31
+ const clientKeys = processHandshakeAccept(
32
32
  accept,
33
33
  daemonId,
34
34
  daemonIdentity.publicKey,
@@ -36,8 +36,8 @@ describe("handshake", () => {
36
36
  );
37
37
 
38
38
  // Both should have valid session keys
39
- expect(clientResult.sessionKeys).toBeDefined();
40
- expect(daemonResult.sessionKeys).toBeDefined();
39
+ expect(clientKeys).toBeDefined();
40
+ expect(daemonKeys).toBeDefined();
41
41
  });
42
42
  });
43
43
 
@@ -47,12 +47,12 @@ describe("handshake", () => {
47
47
 
48
48
  const { message: init, ephemeralKeyPair: clientEphemeral } =
49
49
  createHandshakeInit();
50
- const { message: accept, result: daemonResult } = processHandshakeInit(
50
+ const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
51
51
  init,
52
52
  daemonId,
53
53
  daemonIdentity,
54
54
  );
55
- const clientResult = processHandshakeAccept(
55
+ const clientKeys = processHandshakeAccept(
56
56
  accept,
57
57
  daemonId,
58
58
  daemonIdentity.publicKey,
@@ -60,12 +60,8 @@ describe("handshake", () => {
60
60
  );
61
61
 
62
62
  // Keys should be byte-for-byte identical
63
- expect(clientResult.sessionKeys.clientToDaemon).toEqual(
64
- daemonResult.sessionKeys.clientToDaemon,
65
- );
66
- expect(clientResult.sessionKeys.daemonToClient).toEqual(
67
- daemonResult.sessionKeys.daemonToClient,
68
- );
63
+ expect(clientKeys.clientToDaemon).toEqual(daemonKeys.clientToDaemon);
64
+ expect(clientKeys.daemonToClient).toEqual(daemonKeys.daemonToClient);
69
65
  });
70
66
 
71
67
  it("derives directional keys (clientToDaemon != daemonToClient)", () => {
@@ -73,12 +69,12 @@ describe("handshake", () => {
73
69
 
74
70
  const { message: init, ephemeralKeyPair: clientEphemeral } =
75
71
  createHandshakeInit();
76
- const { message: accept, result: daemonResult } = processHandshakeInit(
72
+ const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
77
73
  init,
78
74
  daemonId,
79
75
  daemonIdentity,
80
76
  );
81
- const clientResult = processHandshakeAccept(
77
+ const clientKeys = processHandshakeAccept(
82
78
  accept,
83
79
  daemonId,
84
80
  daemonIdentity.publicKey,
@@ -86,12 +82,8 @@ describe("handshake", () => {
86
82
  );
87
83
 
88
84
  // Directional keys must differ to prevent reflection attacks
89
- expect(clientResult.sessionKeys.clientToDaemon).not.toEqual(
90
- clientResult.sessionKeys.daemonToClient,
91
- );
92
- expect(daemonResult.sessionKeys.clientToDaemon).not.toEqual(
93
- daemonResult.sessionKeys.daemonToClient,
94
- );
85
+ expect(clientKeys.clientToDaemon).not.toEqual(clientKeys.daemonToClient);
86
+ expect(daemonKeys.clientToDaemon).not.toEqual(daemonKeys.daemonToClient);
95
87
  });
96
88
 
97
89
  it("produces different keys for different handshakes (ephemeral randomness)", () => {
@@ -105,7 +97,7 @@ describe("handshake", () => {
105
97
  daemonId,
106
98
  daemonIdentity,
107
99
  );
108
- const result1 = processHandshakeAccept(
100
+ const keys1 = processHandshakeAccept(
109
101
  accept1,
110
102
  daemonId,
111
103
  daemonIdentity.publicKey,
@@ -120,7 +112,7 @@ describe("handshake", () => {
120
112
  daemonId,
121
113
  daemonIdentity,
122
114
  );
123
- const result2 = processHandshakeAccept(
115
+ const keys2 = processHandshakeAccept(
124
116
  accept2,
125
117
  daemonId,
126
118
  daemonIdentity.publicKey,
@@ -128,17 +120,13 @@ describe("handshake", () => {
128
120
  );
129
121
 
130
122
  // Keys from different handshakes should differ
131
- expect(result1.sessionKeys.clientToDaemon).not.toEqual(
132
- result2.sessionKeys.clientToDaemon,
133
- );
134
- expect(result1.sessionKeys.daemonToClient).not.toEqual(
135
- result2.sessionKeys.daemonToClient,
136
- );
123
+ expect(keys1.clientToDaemon).not.toEqual(keys2.clientToDaemon);
124
+ expect(keys1.daemonToClient).not.toEqual(keys2.daemonToClient);
137
125
  });
138
126
  });
139
127
 
140
128
  describe("signature verification", () => {
141
- it("fails with wrong identity key (throws SbrpError with HandshakeFailed)", () => {
129
+ it("fails with wrong identity key (IdentityKeyChanged)", () => {
142
130
  const daemonIdentity = generateIdentityKeyPair();
143
131
  const wrongIdentity = generateIdentityKeyPair();
144
132
 
@@ -169,7 +157,7 @@ describe("handshake", () => {
169
157
  );
170
158
  } catch (err) {
171
159
  expect(err).toBeInstanceOf(SbrpError);
172
- expect((err as SbrpError).code).toBe(SbrpErrorCode.HandshakeFailed);
160
+ expect((err as SbrpError).code).toBe(SbrpErrorCode.IdentityKeyChanged);
173
161
  }
174
162
  });
175
163
 
@@ -189,7 +177,7 @@ describe("handshake", () => {
189
177
  ...accept,
190
178
  signature: new Uint8Array(accept.signature),
191
179
  };
192
- tamperedAccept.signature[0] ^= 0xff; // flip bits
180
+ tamperedAccept.signature[0] = tamperedAccept.signature[0]! ^ 0xff; // flip bits
193
181
 
194
182
  expect(() =>
195
183
  processHandshakeAccept(
@@ -262,11 +250,11 @@ describe("handshake", () => {
262
250
  });
263
251
 
264
252
  describe("processHandshakeInit", () => {
265
- it("returns 64-byte signature and 32-byte ephemeral key", () => {
253
+ it("returns accept message with correct field sizes", () => {
266
254
  const daemonIdentity = generateIdentityKeyPair();
267
255
  const { message: init } = createHandshakeInit();
268
256
 
269
- const { message: accept, result } = processHandshakeInit(
257
+ const { message: accept, sessionKeys } = processHandshakeInit(
270
258
  init,
271
259
  daemonId,
272
260
  daemonIdentity,
@@ -277,8 +265,8 @@ describe("handshake", () => {
277
265
  expect(accept.acceptPublicKey.length).toBe(32);
278
266
  expect(accept.signature).toBeInstanceOf(Uint8Array);
279
267
  expect(accept.signature.length).toBe(64);
280
- expect(result.signature).toEqual(accept.signature);
281
- expect(result.ephemeralKeyPair.publicKey).toEqual(accept.acceptPublicKey);
268
+ expect(sessionKeys.clientToDaemon.length).toBe(32);
269
+ expect(sessionKeys.daemonToClient.length).toBe(32);
282
270
  });
283
271
 
284
272
  it("generates different ephemeral keys and signatures each time", () => {
@@ -304,22 +292,22 @@ describe("handshake", () => {
304
292
 
305
293
  const { message: init, ephemeralKeyPair: clientEphemeral } =
306
294
  createHandshakeInit();
307
- const { message: accept, result: daemonResult } = processHandshakeInit(
295
+ const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
308
296
  init,
309
297
  daemonId,
310
298
  daemonIdentity,
311
299
  );
312
- const clientResult = processHandshakeAccept(
300
+ const clientKeys = processHandshakeAccept(
313
301
  accept,
314
302
  daemonId,
315
303
  daemonIdentity.publicKey,
316
304
  clientEphemeral,
317
305
  );
318
306
 
319
- expect(clientResult.sessionKeys.clientToDaemon.length).toBe(32);
320
- expect(clientResult.sessionKeys.daemonToClient.length).toBe(32);
321
- expect(daemonResult.sessionKeys.clientToDaemon.length).toBe(32);
322
- expect(daemonResult.sessionKeys.daemonToClient.length).toBe(32);
307
+ expect(clientKeys.clientToDaemon.length).toBe(32);
308
+ expect(clientKeys.daemonToClient.length).toBe(32);
309
+ expect(daemonKeys.clientToDaemon.length).toBe(32);
310
+ expect(daemonKeys.daemonToClient.length).toBe(32);
323
311
  });
324
312
  });
325
313
  });
package/src/handshake.ts CHANGED
@@ -27,19 +27,6 @@ import type {
27
27
  } from "./types.js";
28
28
  import { SbrpError, SbrpErrorCode } from "./types.js";
29
29
 
30
- /** Result of a successful daemon handshake */
31
- export interface DaemonHandshakeResult {
32
- sessionKeys: SessionKeys;
33
- ephemeralKeyPair: EphemeralKeyPair;
34
- signature: Uint8Array;
35
- }
36
-
37
- /** Result of a successful client handshake */
38
- export interface ClientHandshakeResult {
39
- sessionKeys: SessionKeys;
40
- ephemeralKeyPair: EphemeralKeyPair;
41
- }
42
-
43
30
  /**
44
31
  * Create a handshake init message (client side).
45
32
  *
@@ -73,7 +60,7 @@ export function processHandshakeInit(
73
60
  init: HandshakeInit,
74
61
  daemonId: DaemonId,
75
62
  identityKeyPair: IdentityKeyPair,
76
- ): { message: HandshakeAccept; result: DaemonHandshakeResult } {
63
+ ): { message: HandshakeAccept; sessionKeys: SessionKeys } {
77
64
  const ephemeralKeyPair = generateEphemeralKeyPair();
78
65
 
79
66
  // Sign ephemeral key with context binding
@@ -97,20 +84,18 @@ export function processHandshakeInit(
97
84
  );
98
85
  const sessionKeys = deriveSessionKeys(sharedSecret, transcriptHash);
99
86
 
100
- // Best-effort zeroize shared secret
87
+ // Best-effort zeroize secrets
101
88
  zeroize(sharedSecret);
89
+ zeroize(ephemeralKeyPair.privateKey);
102
90
 
103
91
  return {
104
92
  message: {
105
93
  type: "handshake.accept",
94
+ identityPublicKey: identityKeyPair.publicKey,
106
95
  acceptPublicKey: ephemeralKeyPair.publicKey,
107
96
  signature,
108
97
  },
109
- result: {
110
- sessionKeys,
111
- ephemeralKeyPair,
112
- signature,
113
- },
98
+ sessionKeys,
114
99
  };
115
100
  }
116
101
 
@@ -123,14 +108,29 @@ export function processHandshakeInit(
123
108
  * NOTE: Callers MUST enforce a 30-second handshake timeout per SBRP §1.4.
124
109
  * This function does not track time; timeout enforcement is a transport concern.
125
110
  *
126
- * @throws {SbrpError} with code HandshakeFailed if signature verification fails
111
+ * @param ephemeralKeyPair The privateKey is zeroized in-place after key derivation.
112
+ * @throws {SbrpError} IdentityKeyChanged if advertised key doesn't match pinned key
113
+ * @throws {SbrpError} HandshakeFailed if signature verification fails
127
114
  */
128
115
  export function processHandshakeAccept(
129
116
  accept: HandshakeAccept,
130
117
  daemonId: DaemonId,
131
118
  pinnedIdentityPublicKey: Uint8Array,
132
119
  ephemeralKeyPair: EphemeralKeyPair,
133
- ): ClientHandshakeResult {
120
+ ): SessionKeys {
121
+ // Reject if advertised identity key doesn't match pinned key.
122
+ // Signature is verified against pinnedIdentityPublicKey, but an attacker
123
+ // could swap the advertised field to mislead higher layers.
124
+ if (
125
+ accept.identityPublicKey.length !== pinnedIdentityPublicKey.length ||
126
+ !accept.identityPublicKey.every((b, i) => b === pinnedIdentityPublicKey[i])
127
+ ) {
128
+ throw new SbrpError(
129
+ SbrpErrorCode.IdentityKeyChanged,
130
+ "Advertised identity key does not match pinned key",
131
+ );
132
+ }
133
+
134
134
  // Verify daemon signature using PINNED key (not relay-provided!)
135
135
  const signaturePayload = createSignaturePayload(
136
136
  daemonId,
@@ -164,11 +164,9 @@ export function processHandshakeAccept(
164
164
  );
165
165
  const sessionKeys = deriveSessionKeys(sharedSecret, transcriptHash);
166
166
 
167
- // Best-effort zeroize shared secret
167
+ // Best-effort zeroize secrets
168
168
  zeroize(sharedSecret);
169
+ zeroize(ephemeralKeyPair.privateKey);
169
170
 
170
- return {
171
- sessionKeys,
172
- ephemeralKeyPair,
173
- };
171
+ return sessionKeys;
174
172
  }
package/src/index.ts CHANGED
@@ -22,12 +22,12 @@
22
22
  * const { message: init, ephemeralKeyPair } = createHandshakeInit();
23
23
  *
24
24
  * // Daemon side: process init and create accept
25
- * const { message: accept, result } = processHandshakeInit(init, daemonId, identity);
26
- * const clientSession = createClientSession(clientId, result.sessionKeys);
25
+ * const { message: accept, sessionKeys } = processHandshakeInit(init, daemonId, identity);
26
+ * const clientSession = createClientSession(clientId, sessionKeys);
27
27
  *
28
28
  * // Client side: process accept (with TOFU-pinned identity)
29
- * const { sessionKeys } = processHandshakeAccept(accept, daemonId, pinnedKey, ephemeralKeyPair);
30
- * const daemonSession = createDaemonSession(sessionKeys);
29
+ * const clientKeys = processHandshakeAccept(accept, daemonId, pinnedKey, ephemeralKeyPair);
30
+ * const daemonSession = createDaemonSession(clientKeys);
31
31
  *
32
32
  * // Encrypt/decrypt messages
33
33
  * const encrypted = encryptClientToDaemon(daemonSession, plaintext);
@@ -44,7 +44,6 @@ export type {
44
44
  HandshakeAccept,
45
45
  HandshakeInit,
46
46
  IdentityKeyPair,
47
- PinnedIdentity,
48
47
  SessionId,
49
48
  SessionKeys,
50
49
  } from "./types.js";
@@ -106,11 +105,6 @@ export {
106
105
  } from "./crypto.js";
107
106
 
108
107
  // Handshake
109
- export type {
110
- ClientHandshakeResult,
111
- DaemonHandshakeResult,
112
- } from "./handshake.js";
113
-
114
108
  export {
115
109
  createHandshakeInit,
116
110
  processHandshakeAccept,