@sideband/secure-relay 0.2.2 → 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 (45) hide show
  1. package/README.md +6 -4
  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 +1 -1
  36. package/src/constants.ts +3 -3
  37. package/src/crypto.test.ts +5 -5
  38. package/src/frame.test.ts +59 -10
  39. package/src/frame.ts +101 -77
  40. package/src/handshake.test.ts +29 -41
  41. package/src/handshake.ts +25 -27
  42. package/src/index.ts +4 -10
  43. package/src/integration.test.ts +97 -138
  44. package/src/session.test.ts +12 -10
  45. package/src/types.ts +1 -12
package/dist/types.js ADDED
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /** Direction of message flow (used in nonce construction) */
3
+ export const Direction = {
4
+ ClientToDaemon: 1,
5
+ DaemonToClient: 2,
6
+ };
7
+ /**
8
+ * SBRP error codes (string constants for SDK use).
9
+ *
10
+ * Wire codes use numeric values in Control frames (see frame.ts).
11
+ * These string constants provide type-safe SDK-level error handling.
12
+ */
13
+ export const SbrpErrorCode = {
14
+ // Authentication (0x01xx) - Terminal
15
+ Unauthorized: "unauthorized",
16
+ Forbidden: "forbidden",
17
+ // Routing (0x02xx) - Varies
18
+ DaemonNotFound: "daemon_not_found",
19
+ DaemonOffline: "daemon_offline",
20
+ // Session (0x03xx) - Terminal
21
+ SessionNotFound: "session_not_found",
22
+ SessionExpired: "session_expired",
23
+ // Wire Format (0x04xx) - Terminal
24
+ MalformedFrame: "malformed_frame",
25
+ PayloadTooLarge: "payload_too_large",
26
+ InvalidFrameType: "invalid_frame_type",
27
+ InvalidSessionId: "invalid_session_id",
28
+ DisallowedSender: "disallowed_sender",
29
+ // Internal (0x06xx) - Terminal
30
+ InternalError: "internal_error",
31
+ // Rate Limiting (0x09xx) - Non-terminal
32
+ RateLimited: "rate_limited",
33
+ // Session State (0x10xx) - Non-terminal
34
+ SessionPaused: "session_paused",
35
+ SessionResumed: "session_resumed",
36
+ SessionEnded: "session_ended",
37
+ SessionPending: "session_pending",
38
+ // Endpoint-only (0xExxx) - Never on wire
39
+ IdentityKeyChanged: "identity_key_changed",
40
+ HandshakeFailed: "handshake_failed",
41
+ HandshakeTimeout: "handshake_timeout",
42
+ DecryptFailed: "decrypt_failed",
43
+ SequenceError: "sequence_error",
44
+ };
45
+ /** Signal codes for Signal frame (0x04) */
46
+ export const SignalCode = {
47
+ Ready: 0x00,
48
+ Close: 0x01,
49
+ };
50
+ /** Reason codes for Signal frames (§13.4) */
51
+ export const SignalReason = {
52
+ /** No specific reason (default for ready signal) */
53
+ None: 0x00,
54
+ /** Process restart, memory cleared */
55
+ StateLost: 0x01,
56
+ /** Graceful daemon shutdown */
57
+ Shutdown: 0x02,
58
+ /** Internal policy denial */
59
+ Policy: 0x03,
60
+ /** Internal daemon error */
61
+ Error: 0x04,
62
+ };
63
+ /** SBRP-specific error */
64
+ export class SbrpError extends Error {
65
+ code;
66
+ constructor(code, message) {
67
+ super(message);
68
+ this.code = code;
69
+ this.name = "SbrpError";
70
+ }
71
+ }
72
+ /** Brand a string as DaemonId (no validation) */
73
+ export function asDaemonId(value) {
74
+ return value;
75
+ }
76
+ /** Brand a string as ClientId (no validation) */
77
+ export function asClientId(value) {
78
+ return value;
79
+ }
80
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,sCAAsC;AA4DtC,6DAA6D;AAC7D,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,cAAc,EAAE,CAAC;IACjB,cAAc,EAAE,CAAC;CACT,CAAC;AAIX;;;;;GAKG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,qCAAqC;IACrC,YAAY,EAAE,cAAc;IAC5B,SAAS,EAAE,WAAW;IAEtB,4BAA4B;IAC5B,cAAc,EAAE,kBAAkB;IAClC,aAAa,EAAE,gBAAgB;IAE/B,8BAA8B;IAC9B,eAAe,EAAE,mBAAmB;IACpC,cAAc,EAAE,iBAAiB;IAEjC,kCAAkC;IAClC,cAAc,EAAE,iBAAiB;IACjC,eAAe,EAAE,mBAAmB;IACpC,gBAAgB,EAAE,oBAAoB;IACtC,gBAAgB,EAAE,oBAAoB;IACtC,gBAAgB,EAAE,mBAAmB;IAErC,+BAA+B;IAC/B,aAAa,EAAE,gBAAgB;IAE/B,wCAAwC;IACxC,WAAW,EAAE,cAAc;IAE3B,wCAAwC;IACxC,aAAa,EAAE,gBAAgB;IAC/B,cAAc,EAAE,iBAAiB;IACjC,YAAY,EAAE,eAAe;IAC7B,cAAc,EAAE,iBAAiB;IAEjC,yCAAyC;IACzC,kBAAkB,EAAE,sBAAsB;IAC1C,eAAe,EAAE,kBAAkB;IACnC,gBAAgB,EAAE,mBAAmB;IACrC,aAAa,EAAE,gBAAgB;IAC/B,aAAa,EAAE,gBAAgB;CACvB,CAAC;AAIX,2CAA2C;AAC3C,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,KAAK,EAAE,IAAI;IACX,KAAK,EAAE,IAAI;CACH,CAAC;AAIX,6CAA6C;AAC7C,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,oDAAoD;IACpD,IAAI,EAAE,IAAI;IACV,sCAAsC;IACtC,SAAS,EAAE,IAAI;IACf,+BAA+B;IAC/B,QAAQ,EAAE,IAAI;IACd,6BAA6B;IAC7B,MAAM,EAAE,IAAI;IACZ,4BAA4B;IAC5B,KAAK,EAAE,IAAI;CACH,CAAC;AAIX,0BAA0B;AAC1B,MAAM,OAAO,SAAU,SAAQ,KAAK;IAEhB;IADlB,YACkB,IAAmB,EACnC,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHC,SAAI,GAAJ,IAAI,CAAe;QAInC,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IAC1B,CAAC;CACF;AAED,iDAAiD;AACjD,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAiB,CAAC;AAC3B,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAiB,CAAC;AAC3B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sideband/secure-relay",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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": {
package/src/constants.ts CHANGED
@@ -13,7 +13,7 @@ export const SBRP_TRANSCRIPT_CONTEXT = "sbrp-v1-transcript";
13
13
  /** HKDF info string for session key derivation */
14
14
  export const SBRP_SESSION_KEYS_INFO = "sbrp-session-keys";
15
15
 
16
- /** Length of session keys in bytes (browserToDaemon + daemonToBrowser) */
16
+ /** Length of session keys in bytes (clientToDaemon + daemonToClient) */
17
17
  export const SESSION_KEYS_LENGTH = 64;
18
18
 
19
19
  /** Length of a single symmetric key in bytes */
@@ -58,8 +58,8 @@ export const MAX_PAYLOAD_SIZE = 65536;
58
58
  /** HandshakeInit payload: X25519 ephemeral public key */
59
59
  export const HANDSHAKE_INIT_PAYLOAD_SIZE = 32;
60
60
 
61
- /** HandshakeAccept payload: X25519 ephemeral (32) + Ed25519 signature (64) */
62
- export const HANDSHAKE_ACCEPT_PAYLOAD_SIZE = 96;
61
+ /** HandshakeAccept payload: Ed25519 identity key (32) + X25519 ephemeral (32) + Ed25519 signature (64) */
62
+ export const HANDSHAKE_ACCEPT_PAYLOAD_SIZE = 128;
63
63
 
64
64
  /** Minimum encrypted payload: nonce (12) + authTag (16), no plaintext */
65
65
  export const MIN_ENCRYPTED_PAYLOAD_SIZE = NONCE_LENGTH + AUTH_TAG_LENGTH;
@@ -153,7 +153,7 @@ describe("crypto", () => {
153
153
 
154
154
  const signature = signPayload(payload, privateKey);
155
155
  // Tamper with signature
156
- signature[0] ^= 0xff;
156
+ signature[0] = signature[0]! ^ 0xff;
157
157
 
158
158
  const valid = verifySignature(payload, signature, publicKey);
159
159
  expect(valid).toBe(false);
@@ -398,7 +398,7 @@ describe("crypto", () => {
398
398
  expect(encrypted.length).toBeGreaterThanOrEqual(NONCE_LENGTH + 16); // nonce + authTag
399
399
  const nonce = encrypted.slice(0, NONCE_LENGTH);
400
400
  const expected = constructNonce(Direction.ClientToDaemon, 42n);
401
- expect(nonce).toEqual(expected);
401
+ expect(Array.from(nonce)).toEqual(Array.from(expected));
402
402
  });
403
403
 
404
404
  it("decryption fails with wrong key", () => {
@@ -416,7 +416,7 @@ describe("crypto", () => {
416
416
 
417
417
  const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
418
418
  // Tamper with ciphertext (after nonce)
419
- encrypted[NONCE_LENGTH + 1] ^= 0xff;
419
+ encrypted[NONCE_LENGTH + 1] = encrypted[NONCE_LENGTH + 1]! ^ 0xff;
420
420
 
421
421
  expect(() => decrypt(key, encrypted)).toThrow();
422
422
  });
@@ -427,7 +427,7 @@ describe("crypto", () => {
427
427
 
428
428
  const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
429
429
  // Tamper with last byte (auth tag)
430
- encrypted[encrypted.length - 1] ^= 0xff;
430
+ encrypted[encrypted.length - 1] = encrypted[encrypted.length - 1]! ^ 0xff;
431
431
 
432
432
  expect(() => decrypt(key, encrypted)).toThrow();
433
433
  });
@@ -438,7 +438,7 @@ describe("crypto", () => {
438
438
 
439
439
  const encrypted = encrypt(key, Direction.ClientToDaemon, 1n, plaintext);
440
440
  // Tamper with nonce
441
- encrypted[0] ^= 0xff;
441
+ encrypted[0] = encrypted[0]! ^ 0xff;
442
442
 
443
443
  expect(() => decrypt(key, encrypted)).toThrow();
444
444
  });
package/src/frame.test.ts CHANGED
@@ -100,6 +100,13 @@ describe("frame codec", () => {
100
100
  expect(() => readFrameHeader(short)).toThrow(SbrpError);
101
101
  });
102
102
 
103
+ it("rejects unknown frame type", () => {
104
+ const frame = new Uint8Array(FRAME_HEADER_SIZE);
105
+ frame[0] = 0x99; // Unknown type
106
+ expect(() => readFrameHeader(frame)).toThrow(SbrpError);
107
+ expect(() => readFrameHeader(frame)).toThrow(/Unknown frame type/);
108
+ });
109
+
103
110
  it("rejects invalid payload length in header", () => {
104
111
  const frame = new Uint8Array(FRAME_HEADER_SIZE);
105
112
  frame[0] = FrameType.Data;
@@ -222,9 +229,23 @@ describe("frame codec", () => {
222
229
  );
223
230
  });
224
231
 
232
+ it("rejects wrong identityPublicKey size", () => {
233
+ const wrongSize: HandshakeAccept = {
234
+ type: "handshake.accept",
235
+ identityPublicKey: new Uint8Array(16), // should be 32
236
+ acceptPublicKey: new Uint8Array(32),
237
+ signature: new Uint8Array(64),
238
+ };
239
+ expect(() => encodeHandshakeAccept(1n, wrongSize)).toThrow(SbrpError);
240
+ expect(() => encodeHandshakeAccept(1n, wrongSize)).toThrow(
241
+ /identityPublicKey must be 32 bytes/,
242
+ );
243
+ });
244
+
225
245
  it("rejects wrong acceptPublicKey size", () => {
226
246
  const wrongSize: HandshakeAccept = {
227
247
  type: "handshake.accept",
248
+ identityPublicKey: new Uint8Array(32),
228
249
  acceptPublicKey: new Uint8Array(16), // should be 32
229
250
  signature: new Uint8Array(64),
230
251
  };
@@ -234,6 +255,7 @@ describe("frame codec", () => {
234
255
  it("rejects wrong signature size", () => {
235
256
  const wrongSize: HandshakeAccept = {
236
257
  type: "handshake.accept",
258
+ identityPublicKey: new Uint8Array(32),
237
259
  acceptPublicKey: new Uint8Array(32),
238
260
  signature: new Uint8Array(32), // should be 64
239
261
  };
@@ -313,10 +335,12 @@ describe("frame codec", () => {
313
335
 
314
336
  describe("HandshakeAccept", () => {
315
337
  it("encodes and decodes correctly", () => {
338
+ const identityPublicKey = new Uint8Array(32).fill(0xab);
316
339
  const acceptPublicKey = new Uint8Array(32).fill(0xcd);
317
340
  const signature = new Uint8Array(64).fill(0xef);
318
341
  const accept: HandshakeAccept = {
319
342
  type: "handshake.accept",
343
+ identityPublicKey,
320
344
  acceptPublicKey,
321
345
  signature,
322
346
  };
@@ -331,6 +355,7 @@ describe("frame codec", () => {
331
355
 
332
356
  const decoded = decodeHandshakeAccept(frame);
333
357
  expect(decoded.type).toBe("handshake.accept");
358
+ expect(decoded.identityPublicKey).toEqual(identityPublicKey);
334
359
  expect(decoded.acceptPublicKey).toEqual(acceptPublicKey);
335
360
  expect(decoded.signature).toEqual(signature);
336
361
  });
@@ -343,10 +368,10 @@ describe("frame codec", () => {
343
368
  });
344
369
 
345
370
  it("rejects zero sessionId on decode", () => {
346
- const payload = new Uint8Array(96);
347
- const frame = new Uint8Array(FRAME_HEADER_SIZE + 96);
371
+ const payload = new Uint8Array(128);
372
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + 128);
348
373
  frame[0] = FrameType.HandshakeAccept;
349
- new DataView(frame.buffer).setUint32(1, 96, false);
374
+ new DataView(frame.buffer).setUint32(1, 128, false);
350
375
  frame.set(payload, FRAME_HEADER_SIZE);
351
376
 
352
377
  // Validation happens at decodeFrame level (via readFrameHeader)
@@ -434,7 +459,7 @@ describe("frame codec", () => {
434
459
 
435
460
  const signal = decodeSignal(frame);
436
461
  expect(signal.signal).toBe(SignalCode.Ready);
437
- expect(signal.reason).toBe(0);
462
+ expect(signal.reason).toBe(SignalReason.None);
438
463
  });
439
464
 
440
465
  it("encodes and decodes close signal with reason", () => {
@@ -472,6 +497,22 @@ describe("frame codec", () => {
472
497
  expect(signal.reason).toBe(SignalReason.None);
473
498
  });
474
499
 
500
+ it("rejects unknown signal code", () => {
501
+ const payload = new Uint8Array([0xff, 0x00]); // Unknown signal, valid reason
502
+ const encoded = encodeFrame(FrameType.Signal, 1n, payload);
503
+ const frame = decodeFrame(encoded);
504
+ expect(() => decodeSignal(frame)).toThrow(SbrpError);
505
+ expect(() => decodeSignal(frame)).toThrow(/Unknown signal code/);
506
+ });
507
+
508
+ it("rejects unknown signal reason", () => {
509
+ const payload = new Uint8Array([0x01, 0xff]); // Valid signal (Close), unknown reason
510
+ const encoded = encodeFrame(FrameType.Signal, 1n, payload);
511
+ const frame = decodeFrame(encoded);
512
+ expect(() => decodeSignal(frame)).toThrow(SbrpError);
513
+ expect(() => decodeSignal(frame)).toThrow(/Unknown signal reason/);
514
+ });
515
+
475
516
  it("rejects wrong payload size", () => {
476
517
  const wrongPayload = new Uint8Array(3);
477
518
  const encoded = encodeFrame(FrameType.Signal, 1n, wrongPayload);
@@ -531,6 +572,14 @@ describe("frame codec", () => {
531
572
  const frame = decodeFrame(encoded);
532
573
  expect(() => decodeControl(frame)).toThrow(SbrpError);
533
574
  });
575
+
576
+ it("rejects unknown control code", () => {
577
+ const payload = new Uint8Array([0x99, 0x99]); // Unknown code 0x9999
578
+ const encoded = encodeFrame(FrameType.Control, 0n, payload);
579
+ const frame = decodeFrame(encoded);
580
+ expect(() => decodeControl(frame)).toThrow(SbrpError);
581
+ expect(() => decodeControl(frame)).toThrow(/Unknown control code/);
582
+ });
534
583
  });
535
584
 
536
585
  describe("isTerminalCode", () => {
@@ -744,8 +793,8 @@ describe("frame codec", () => {
744
793
  const frame = encodePing();
745
794
  const frames = [...decoder.push(frame)];
746
795
  expect(frames.length).toBe(1);
747
- expect(frames[0].type).toBe(FrameType.Ping);
748
- expect(frames[0].sessionId).toBe(0n);
796
+ expect(frames[0]!.type).toBe(FrameType.Ping);
797
+ expect(frames[0]!.sessionId).toBe(0n);
749
798
  });
750
799
 
751
800
  it("decodes multiple frames in one push", () => {
@@ -758,8 +807,8 @@ describe("frame codec", () => {
758
807
 
759
808
  const frames = [...decoder.push(combined)];
760
809
  expect(frames.length).toBe(2);
761
- expect(frames[0].type).toBe(FrameType.Ping);
762
- expect(frames[1].type).toBe(FrameType.Pong);
810
+ expect(frames[0]!.type).toBe(FrameType.Ping);
811
+ expect(frames[1]!.type).toBe(FrameType.Pong);
763
812
  });
764
813
 
765
814
  it("buffers incomplete frames", () => {
@@ -774,7 +823,7 @@ describe("frame codec", () => {
774
823
  // Push rest
775
824
  frames = [...decoder.push(frame.subarray(FRAME_HEADER_SIZE))];
776
825
  expect(frames.length).toBe(1);
777
- expect(frames[0].type).toBe(FrameType.Control);
826
+ expect(frames[0]!.type).toBe(FrameType.Control);
778
827
  expect(decoder.bufferedBytes).toBe(0);
779
828
  });
780
829
 
@@ -791,7 +840,7 @@ describe("frame codec", () => {
791
840
  }
792
841
 
793
842
  expect(allFrames.length).toBe(1);
794
- expect(allFrames[0].type).toBe(FrameType.Ping);
843
+ expect(allFrames[0]!.type).toBe(FrameType.Ping);
795
844
  });
796
845
 
797
846
  it("resets state correctly", () => {
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