@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/src/frame.ts ADDED
@@ -0,0 +1,771 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Binary wire format for Sideband Relay Protocol (SBRP).
5
+ *
6
+ * Frame structure (§13):
7
+ * ```
8
+ * ┌───────────┬──────────────┬────────────────┬─────────────────────┐
9
+ * │ Type (1B) │ Length (4B) │ SessionID (8B) │ Payload (0..64KB) │
10
+ * └───────────┴──────────────┴────────────────┴─────────────────────┘
11
+ * ```
12
+ *
13
+ * All multi-byte integers are big-endian.
14
+ */
15
+
16
+ import {
17
+ FRAME_HEADER_SIZE,
18
+ HANDSHAKE_ACCEPT_PAYLOAD_SIZE,
19
+ HANDSHAKE_INIT_PAYLOAD_SIZE,
20
+ MAX_PAYLOAD_SIZE,
21
+ MAX_PING_PAYLOAD_SIZE,
22
+ MIN_CONTROL_PAYLOAD_SIZE,
23
+ MIN_ENCRYPTED_PAYLOAD_SIZE,
24
+ SIGNAL_PAYLOAD_SIZE,
25
+ } from "./constants.js";
26
+ import { extractSequence } from "./crypto.js";
27
+ import type {
28
+ EncryptedMessage,
29
+ HandshakeAccept,
30
+ HandshakeInit,
31
+ SessionId,
32
+ SignalCode,
33
+ SignalReason,
34
+ } from "./types.js";
35
+ import { SbrpError, SbrpErrorCode } from "./types.js";
36
+
37
+ /**
38
+ * Frame type discriminant (wire byte).
39
+ *
40
+ * Frame types are organized by authority:
41
+ * - Endpoint frames (0x01-0x03): Forwarded by relay, E2EE
42
+ * - Signal frame (0x04): Daemon → Relay only
43
+ * - Keepalive frames (0x10-0x11): Connection-scoped, never forwarded
44
+ * - Control frame (0x20): Relay → Endpoint only
45
+ */
46
+ export const FrameType = {
47
+ // Endpoint frames (forwarded, E2EE)
48
+ HandshakeInit: 0x01,
49
+ HandshakeAccept: 0x02,
50
+ Data: 0x03, // Renamed from Encrypted for clarity
51
+
52
+ // Signal frame (daemon → relay)
53
+ Signal: 0x04,
54
+
55
+ // Keepalive frames (connection-scoped, never forwarded)
56
+ Ping: 0x10,
57
+ Pong: 0x11,
58
+
59
+ // Control frame (relay → endpoint)
60
+ Control: 0x20,
61
+ } as const;
62
+
63
+ export type FrameType = (typeof FrameType)[keyof typeof FrameType];
64
+
65
+ /**
66
+ * Wire control codes (uint16, §14).
67
+ *
68
+ * Codes use ranges for categorization:
69
+ * - 0x01xx: Authentication (terminal)
70
+ * - 0x02xx: Routing (varies)
71
+ * - 0x03xx: Session (terminal)
72
+ * - 0x04xx: Wire format (terminal)
73
+ * - 0x09xx: Rate limiting (non-terminal)
74
+ * - 0x10xx: Session state (non-terminal)
75
+ */
76
+ export const WireControlCode = {
77
+ // Authentication (0x01xx) - Terminal
78
+ Unauthorized: 0x0101,
79
+ Forbidden: 0x0102,
80
+
81
+ // Routing (0x02xx) - Varies
82
+ DaemonNotFound: 0x0201,
83
+ DaemonOffline: 0x0202, // Non-terminal
84
+
85
+ // Session (0x03xx) - Terminal
86
+ SessionNotFound: 0x0301,
87
+ SessionExpired: 0x0302,
88
+
89
+ // Wire Format (0x04xx) - Terminal
90
+ MalformedFrame: 0x0401,
91
+ PayloadTooLarge: 0x0402,
92
+ InvalidFrameType: 0x0403,
93
+ InvalidSessionId: 0x0404,
94
+ DisallowedSender: 0x0405,
95
+
96
+ // Internal (0x06xx) - Terminal
97
+ InternalError: 0x0601,
98
+
99
+ // Rate Limiting (0x09xx) - Non-terminal
100
+ RateLimited: 0x0901,
101
+
102
+ // Session State (0x10xx) - Non-terminal
103
+ SessionPaused: 0x1001,
104
+ SessionResumed: 0x1002,
105
+ SessionEnded: 0x1003,
106
+ SessionPending: 0x1004,
107
+ } as const;
108
+
109
+ export type WireControlCode =
110
+ (typeof WireControlCode)[keyof typeof WireControlCode];
111
+
112
+ /** Check if a control code is terminal (closes connection) */
113
+ 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
+ );
120
+ }
121
+
122
+ /** Decoded frame header */
123
+ export interface FrameHeader {
124
+ type: FrameType;
125
+ length: number;
126
+ sessionId: SessionId;
127
+ }
128
+
129
+ /** Decoded frame (header + payload) */
130
+ export interface Frame extends FrameHeader {
131
+ payload: Uint8Array;
132
+ }
133
+
134
+ /** Decoded Control frame payload */
135
+ export interface ControlPayload {
136
+ code: WireControlCode;
137
+ message: string;
138
+ }
139
+
140
+ /** Decoded Signal frame payload */
141
+ export interface SignalPayload {
142
+ signal: SignalCode;
143
+ reason: SignalReason;
144
+ }
145
+
146
+ // ============================================================================
147
+ // Control code mapping
148
+ // ============================================================================
149
+
150
+ const sbrpToWire: Record<string, WireControlCode> = {
151
+ // Authentication
152
+ [SbrpErrorCode.Unauthorized]: WireControlCode.Unauthorized,
153
+ [SbrpErrorCode.Forbidden]: WireControlCode.Forbidden,
154
+
155
+ // Routing
156
+ [SbrpErrorCode.DaemonNotFound]: WireControlCode.DaemonNotFound,
157
+ [SbrpErrorCode.DaemonOffline]: WireControlCode.DaemonOffline,
158
+
159
+ // Session
160
+ [SbrpErrorCode.SessionNotFound]: WireControlCode.SessionNotFound,
161
+ [SbrpErrorCode.SessionExpired]: WireControlCode.SessionExpired,
162
+
163
+ // Wire Format
164
+ [SbrpErrorCode.MalformedFrame]: WireControlCode.MalformedFrame,
165
+ [SbrpErrorCode.PayloadTooLarge]: WireControlCode.PayloadTooLarge,
166
+ [SbrpErrorCode.InvalidFrameType]: WireControlCode.InvalidFrameType,
167
+ [SbrpErrorCode.InvalidSessionId]: WireControlCode.InvalidSessionId,
168
+ [SbrpErrorCode.DisallowedSender]: WireControlCode.DisallowedSender,
169
+
170
+ // Internal
171
+ [SbrpErrorCode.InternalError]: WireControlCode.InternalError,
172
+
173
+ // Rate Limiting
174
+ [SbrpErrorCode.RateLimited]: WireControlCode.RateLimited,
175
+
176
+ // Session State
177
+ [SbrpErrorCode.SessionPaused]: WireControlCode.SessionPaused,
178
+ [SbrpErrorCode.SessionResumed]: WireControlCode.SessionResumed,
179
+ [SbrpErrorCode.SessionEnded]: WireControlCode.SessionEnded,
180
+ [SbrpErrorCode.SessionPending]: WireControlCode.SessionPending,
181
+ };
182
+
183
+ const wireToSbrp: Record<number, SbrpErrorCode> = {
184
+ // Authentication
185
+ [WireControlCode.Unauthorized]: SbrpErrorCode.Unauthorized,
186
+ [WireControlCode.Forbidden]: SbrpErrorCode.Forbidden,
187
+
188
+ // Routing
189
+ [WireControlCode.DaemonNotFound]: SbrpErrorCode.DaemonNotFound,
190
+ [WireControlCode.DaemonOffline]: SbrpErrorCode.DaemonOffline,
191
+
192
+ // Session
193
+ [WireControlCode.SessionNotFound]: SbrpErrorCode.SessionNotFound,
194
+ [WireControlCode.SessionExpired]: SbrpErrorCode.SessionExpired,
195
+
196
+ // Wire Format
197
+ [WireControlCode.MalformedFrame]: SbrpErrorCode.MalformedFrame,
198
+ [WireControlCode.PayloadTooLarge]: SbrpErrorCode.PayloadTooLarge,
199
+ [WireControlCode.InvalidFrameType]: SbrpErrorCode.InvalidFrameType,
200
+ [WireControlCode.InvalidSessionId]: SbrpErrorCode.InvalidSessionId,
201
+ [WireControlCode.DisallowedSender]: SbrpErrorCode.DisallowedSender,
202
+
203
+ // Internal
204
+ [WireControlCode.InternalError]: SbrpErrorCode.InternalError,
205
+
206
+ // Rate Limiting
207
+ [WireControlCode.RateLimited]: SbrpErrorCode.RateLimited,
208
+
209
+ // Session State
210
+ [WireControlCode.SessionPaused]: SbrpErrorCode.SessionPaused,
211
+ [WireControlCode.SessionResumed]: SbrpErrorCode.SessionResumed,
212
+ [WireControlCode.SessionEnded]: SbrpErrorCode.SessionEnded,
213
+ [WireControlCode.SessionPending]: SbrpErrorCode.SessionPending,
214
+ };
215
+
216
+ /** Convert SbrpErrorCode to wire format (for relay-transmittable codes only) */
217
+ export function toWireControlCode(code: SbrpErrorCode): WireControlCode {
218
+ const wire = sbrpToWire[code];
219
+ if (wire === undefined) {
220
+ throw new Error(`Unknown or non-wire SbrpErrorCode: ${code}`);
221
+ }
222
+ return wire;
223
+ }
224
+
225
+ /** Convert wire control code to SbrpErrorCode */
226
+ export function fromWireControlCode(code: WireControlCode): SbrpErrorCode {
227
+ const sbrp = wireToSbrp[code];
228
+ if (sbrp === undefined) {
229
+ throw new Error(`Unknown WireControlCode: 0x${code.toString(16)}`);
230
+ }
231
+ return sbrp;
232
+ }
233
+
234
+ // ============================================================================
235
+ // Validation helpers
236
+ // ============================================================================
237
+
238
+ const MAX_UINT64 = 0xffff_ffff_ffff_ffffn;
239
+
240
+ /**
241
+ * Check if frame type requires non-zero sessionId (§13.2).
242
+ *
243
+ * Session-bound: HandshakeInit, HandshakeAccept, Data, Signal
244
+ * Connection-scoped (sessionId must be 0): Ping, Pong
245
+ * Variable (depends on content): Control
246
+ */
247
+ function isSessionBound(type: FrameType): boolean {
248
+ return (
249
+ type === FrameType.HandshakeInit ||
250
+ type === FrameType.HandshakeAccept ||
251
+ type === FrameType.Data ||
252
+ type === FrameType.Signal
253
+ );
254
+ }
255
+
256
+ /** Check if frame type requires sessionId = 0 (connection-scoped) */
257
+ function isConnectionScoped(type: FrameType): boolean {
258
+ return type === FrameType.Ping || type === FrameType.Pong;
259
+ }
260
+
261
+ function validateSessionId(sessionId: SessionId, type: FrameType): void {
262
+ if (sessionId < 0n || sessionId > MAX_UINT64) {
263
+ throw new SbrpError(
264
+ SbrpErrorCode.MalformedFrame,
265
+ `SessionId out of uint64 range: ${sessionId}`,
266
+ );
267
+ }
268
+ if (isSessionBound(type) && sessionId === 0n) {
269
+ throw new SbrpError(
270
+ SbrpErrorCode.MalformedFrame,
271
+ `Session-bound frame type 0x${type.toString(16).padStart(2, "0")} requires non-zero sessionId`,
272
+ );
273
+ }
274
+ if (isConnectionScoped(type) && sessionId !== 0n) {
275
+ throw new SbrpError(
276
+ SbrpErrorCode.MalformedFrame,
277
+ `Connection-scoped frame type 0x${type.toString(16).padStart(2, "0")} requires sessionId = 0`,
278
+ );
279
+ }
280
+ }
281
+
282
+ // ============================================================================
283
+ // Low-level frame encoding/decoding
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Encode a frame to binary wire format.
288
+ *
289
+ * @throws {SbrpError} if payload exceeds MAX_PAYLOAD_SIZE or sessionId is invalid
290
+ */
291
+ export function encodeFrame(
292
+ type: FrameType,
293
+ sessionId: SessionId,
294
+ payload: Uint8Array,
295
+ ): Uint8Array {
296
+ validateSessionId(sessionId, type);
297
+
298
+ if (payload.length > MAX_PAYLOAD_SIZE) {
299
+ throw new SbrpError(
300
+ SbrpErrorCode.PayloadTooLarge,
301
+ `Payload size ${payload.length} exceeds maximum ${MAX_PAYLOAD_SIZE}`,
302
+ );
303
+ }
304
+
305
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length);
306
+ const view = new DataView(frame.buffer);
307
+
308
+ frame[0] = type;
309
+ view.setUint32(1, payload.length, false);
310
+ view.setBigUint64(5, sessionId, false);
311
+ frame.set(payload, FRAME_HEADER_SIZE);
312
+
313
+ return frame;
314
+ }
315
+
316
+ /**
317
+ * Read frame header without decoding payload.
318
+ * Useful for routing decisions before full decode.
319
+ *
320
+ * Validates wire format constraints including non-zero sessionId
321
+ * for session-bound frames (§13.2).
322
+ *
323
+ * @throws {SbrpError} if buffer is too short, length exceeds max, or sessionId invalid
324
+ */
325
+ export function readFrameHeader(data: Uint8Array): FrameHeader {
326
+ if (data.length < FRAME_HEADER_SIZE) {
327
+ throw new SbrpError(
328
+ SbrpErrorCode.MalformedFrame,
329
+ `Frame too short: ${data.length} < ${FRAME_HEADER_SIZE}`,
330
+ );
331
+ }
332
+
333
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
334
+ const type = data[0] as FrameType;
335
+ const length = view.getUint32(1, false);
336
+ const sessionId = view.getBigUint64(5, false);
337
+
338
+ if (length > MAX_PAYLOAD_SIZE) {
339
+ throw new SbrpError(
340
+ SbrpErrorCode.PayloadTooLarge,
341
+ `Payload length ${length} exceeds maximum ${MAX_PAYLOAD_SIZE}`,
342
+ );
343
+ }
344
+
345
+ // Wire format constraint: session-bound frames require non-zero sessionId
346
+ if (isSessionBound(type) && sessionId === 0n) {
347
+ throw new SbrpError(
348
+ SbrpErrorCode.MalformedFrame,
349
+ `Session-bound frame type 0x${type.toString(16).padStart(2, "0")} requires non-zero sessionId`,
350
+ );
351
+ }
352
+
353
+ // Wire format constraint: connection-scoped frames require sessionId = 0
354
+ if (isConnectionScoped(type) && sessionId !== 0n) {
355
+ throw new SbrpError(
356
+ SbrpErrorCode.MalformedFrame,
357
+ `Connection-scoped frame type 0x${type.toString(16).padStart(2, "0")} requires sessionId = 0`,
358
+ );
359
+ }
360
+
361
+ return { type, length, sessionId };
362
+ }
363
+
364
+ /**
365
+ * Decode a complete frame from binary data.
366
+ *
367
+ * Rejects trailing bytes to catch framing mistakes. Use `FrameDecoder`
368
+ * for streaming scenarios with multiple frames per buffer.
369
+ *
370
+ * @throws {SbrpError} if frame is malformed or has trailing bytes
371
+ */
372
+ export function decodeFrame(data: Uint8Array): Frame {
373
+ const header = readFrameHeader(data);
374
+ const expectedSize = FRAME_HEADER_SIZE + header.length;
375
+
376
+ if (data.length < expectedSize) {
377
+ throw new SbrpError(
378
+ SbrpErrorCode.MalformedFrame,
379
+ `Frame truncated: got ${data.length}, expected ${expectedSize}`,
380
+ );
381
+ }
382
+
383
+ if (data.length > expectedSize) {
384
+ throw new SbrpError(
385
+ SbrpErrorCode.MalformedFrame,
386
+ `Frame has ${data.length - expectedSize} trailing bytes`,
387
+ );
388
+ }
389
+
390
+ const payload = data.subarray(FRAME_HEADER_SIZE, expectedSize);
391
+ return { ...header, payload };
392
+ }
393
+
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
+ // ============================================================================
417
+ // High-level frame encoding (typed message → binary)
418
+ // ============================================================================
419
+
420
+ /**
421
+ * Encode HandshakeInit to wire frame.
422
+ *
423
+ * @throws {SbrpError} if initPublicKey is not exactly 32 bytes or sessionId is invalid
424
+ */
425
+ export function encodeHandshakeInit(
426
+ sessionId: SessionId,
427
+ init: HandshakeInit,
428
+ ): Uint8Array {
429
+ if (init.initPublicKey.length !== HANDSHAKE_INIT_PAYLOAD_SIZE) {
430
+ throw new SbrpError(
431
+ SbrpErrorCode.MalformedFrame,
432
+ `initPublicKey must be ${HANDSHAKE_INIT_PAYLOAD_SIZE} bytes, got ${init.initPublicKey.length}`,
433
+ );
434
+ }
435
+ return encodeFrame(FrameType.HandshakeInit, sessionId, init.initPublicKey);
436
+ }
437
+
438
+ /**
439
+ * Encode HandshakeAccept to wire frame.
440
+ *
441
+ * @throws {SbrpError} if acceptPublicKey/signature have wrong sizes or sessionId is invalid
442
+ */
443
+ export function encodeHandshakeAccept(
444
+ sessionId: SessionId,
445
+ accept: HandshakeAccept,
446
+ ): Uint8Array {
447
+ if (accept.acceptPublicKey.length !== 32) {
448
+ throw new SbrpError(
449
+ SbrpErrorCode.MalformedFrame,
450
+ `acceptPublicKey must be 32 bytes, got ${accept.acceptPublicKey.length}`,
451
+ );
452
+ }
453
+ if (accept.signature.length !== 64) {
454
+ throw new SbrpError(
455
+ SbrpErrorCode.MalformedFrame,
456
+ `signature must be 64 bytes, got ${accept.signature.length}`,
457
+ );
458
+ }
459
+ const payload = new Uint8Array(HANDSHAKE_ACCEPT_PAYLOAD_SIZE);
460
+ payload.set(accept.acceptPublicKey, 0);
461
+ payload.set(accept.signature, 32);
462
+ return encodeFrame(FrameType.HandshakeAccept, sessionId, payload);
463
+ }
464
+
465
+ /**
466
+ * Encode Data frame (encrypted message).
467
+ *
468
+ * @throws {SbrpError} if data is too short (< nonce + authTag) or sessionId is invalid
469
+ */
470
+ export function encodeData(
471
+ sessionId: SessionId,
472
+ message: EncryptedMessage,
473
+ ): Uint8Array {
474
+ if (message.data.length < MIN_ENCRYPTED_PAYLOAD_SIZE) {
475
+ throw new SbrpError(
476
+ SbrpErrorCode.MalformedFrame,
477
+ `Data payload must be at least ${MIN_ENCRYPTED_PAYLOAD_SIZE} bytes, got ${message.data.length}`,
478
+ );
479
+ }
480
+ return encodeFrame(FrameType.Data, sessionId, message.data);
481
+ }
482
+
483
+ /**
484
+ * Encode Signal frame (daemon → relay).
485
+ *
486
+ * @param sessionId Session being signaled
487
+ * @param signal Signal code (ready or close)
488
+ * @param reason Reason code (for close signal)
489
+ */
490
+ export function encodeSignal(
491
+ sessionId: SessionId,
492
+ signal: SignalCode,
493
+ reason: SignalReason = 0x00,
494
+ ): Uint8Array {
495
+ const payload = new Uint8Array(SIGNAL_PAYLOAD_SIZE);
496
+ payload[0] = signal;
497
+ payload[1] = reason;
498
+ return encodeFrame(FrameType.Signal, sessionId, payload);
499
+ }
500
+
501
+ /**
502
+ * Encode Ping frame (connection-scoped keepalive).
503
+ *
504
+ * @param payload Optional 0-8 byte payload for RTT measurement
505
+ */
506
+ export function encodePing(
507
+ payload: Uint8Array = new Uint8Array(0),
508
+ ): Uint8Array {
509
+ if (payload.length > MAX_PING_PAYLOAD_SIZE) {
510
+ throw new SbrpError(
511
+ SbrpErrorCode.PayloadTooLarge,
512
+ `Ping payload must be 0-${MAX_PING_PAYLOAD_SIZE} bytes, got ${payload.length}`,
513
+ );
514
+ }
515
+ return encodeFrame(FrameType.Ping, 0n, payload);
516
+ }
517
+
518
+ /**
519
+ * Encode Pong frame (connection-scoped keepalive response).
520
+ *
521
+ * @param payload Payload from corresponding Ping (must be copied)
522
+ */
523
+ export function encodePong(
524
+ payload: Uint8Array = new Uint8Array(0),
525
+ ): Uint8Array {
526
+ if (payload.length > MAX_PING_PAYLOAD_SIZE) {
527
+ throw new SbrpError(
528
+ SbrpErrorCode.PayloadTooLarge,
529
+ `Pong payload must be 0-${MAX_PING_PAYLOAD_SIZE} bytes, got ${payload.length}`,
530
+ );
531
+ }
532
+ return encodeFrame(FrameType.Pong, 0n, payload);
533
+ }
534
+
535
+ /**
536
+ * Encode Control frame (relay → endpoint).
537
+ *
538
+ * @param sessionId Session ID (non-zero for session events, 0 for connection errors)
539
+ * @param code Control code from WireControlCode
540
+ * @param message Optional diagnostic message (for errors only)
541
+ */
542
+ export function encodeControl(
543
+ sessionId: SessionId,
544
+ code: WireControlCode,
545
+ message?: string,
546
+ ): Uint8Array {
547
+ const msgBytes = message ? new TextEncoder().encode(message) : null;
548
+ const payload = new Uint8Array(2 + (msgBytes?.length ?? 0));
549
+ new DataView(payload.buffer).setUint16(0, code, false);
550
+ if (msgBytes) payload.set(msgBytes, 2);
551
+ return encodeFrame(FrameType.Control, sessionId, payload);
552
+ }
553
+
554
+ // ============================================================================
555
+ // High-level frame decoding (Frame → typed message)
556
+ // ============================================================================
557
+
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
+ /**
575
+ * Decode HandshakeInit from frame.
576
+ *
577
+ * @throws {SbrpError} if frame type, payload size, or sessionId is invalid
578
+ */
579
+ export function decodeHandshakeInit(frame: Frame): HandshakeInit {
580
+ if (frame.type !== FrameType.HandshakeInit) {
581
+ throw new SbrpError(
582
+ SbrpErrorCode.InvalidFrameType,
583
+ `Expected HandshakeInit (0x01), got 0x${frame.type.toString(16).padStart(2, "0")}`,
584
+ );
585
+ }
586
+ validateSessionIdOnDecode(frame);
587
+ if (frame.payload.length !== HANDSHAKE_INIT_PAYLOAD_SIZE) {
588
+ throw new SbrpError(
589
+ SbrpErrorCode.MalformedFrame,
590
+ `HandshakeInit payload must be ${HANDSHAKE_INIT_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
591
+ );
592
+ }
593
+ return {
594
+ type: "handshake.init",
595
+ initPublicKey: frame.payload.slice(),
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Decode HandshakeAccept from frame.
601
+ *
602
+ * @throws {SbrpError} if frame type, payload size, or sessionId is invalid
603
+ */
604
+ export function decodeHandshakeAccept(frame: Frame): HandshakeAccept {
605
+ if (frame.type !== FrameType.HandshakeAccept) {
606
+ throw new SbrpError(
607
+ SbrpErrorCode.InvalidFrameType,
608
+ `Expected HandshakeAccept (0x02), got 0x${frame.type.toString(16).padStart(2, "0")}`,
609
+ );
610
+ }
611
+ validateSessionIdOnDecode(frame);
612
+ if (frame.payload.length !== HANDSHAKE_ACCEPT_PAYLOAD_SIZE) {
613
+ throw new SbrpError(
614
+ SbrpErrorCode.MalformedFrame,
615
+ `HandshakeAccept payload must be ${HANDSHAKE_ACCEPT_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
616
+ );
617
+ }
618
+ return {
619
+ type: "handshake.accept",
620
+ acceptPublicKey: frame.payload.slice(0, 32),
621
+ signature: frame.payload.slice(32, 96),
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Decode Data frame (encrypted message).
627
+ *
628
+ * @throws {SbrpError} if frame type, payload, or sessionId is invalid
629
+ */
630
+ export function decodeData(frame: Frame): EncryptedMessage {
631
+ if (frame.type !== FrameType.Data) {
632
+ throw new SbrpError(
633
+ SbrpErrorCode.InvalidFrameType,
634
+ `Expected Data (0x03), got 0x${frame.type.toString(16).padStart(2, "0")}`,
635
+ );
636
+ }
637
+ validateSessionIdOnDecode(frame);
638
+ if (frame.payload.length < MIN_ENCRYPTED_PAYLOAD_SIZE) {
639
+ throw new SbrpError(
640
+ SbrpErrorCode.MalformedFrame,
641
+ `Data payload must be at least ${MIN_ENCRYPTED_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
642
+ );
643
+ }
644
+ const seq = extractSequence(frame.payload);
645
+ return {
646
+ type: "encrypted",
647
+ seq,
648
+ data: frame.payload.slice(),
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Decode Signal frame (daemon → relay).
654
+ *
655
+ * @throws {SbrpError} if frame type, payload size, or sessionId is invalid
656
+ */
657
+ export function decodeSignal(frame: Frame): SignalPayload {
658
+ if (frame.type !== FrameType.Signal) {
659
+ throw new SbrpError(
660
+ SbrpErrorCode.InvalidFrameType,
661
+ `Expected Signal (0x04), got 0x${frame.type.toString(16).padStart(2, "0")}`,
662
+ );
663
+ }
664
+ validateSessionIdOnDecode(frame);
665
+ if (frame.payload.length !== SIGNAL_PAYLOAD_SIZE) {
666
+ throw new SbrpError(
667
+ SbrpErrorCode.MalformedFrame,
668
+ `Signal payload must be ${SIGNAL_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
669
+ );
670
+ }
671
+ return {
672
+ signal: frame.payload[0] as SignalCode,
673
+ reason: frame.payload[1] as SignalReason,
674
+ };
675
+ }
676
+
677
+ /**
678
+ * Decode Control frame (relay → endpoint).
679
+ *
680
+ * Invalid UTF-8 sequences in message are replaced with U+FFFD.
681
+ *
682
+ * @throws {SbrpError} if frame type or payload is invalid
683
+ */
684
+ export function decodeControl(frame: Frame): ControlPayload {
685
+ if (frame.type !== FrameType.Control) {
686
+ throw new SbrpError(
687
+ SbrpErrorCode.InvalidFrameType,
688
+ `Expected Control (0x20), got 0x${frame.type.toString(16).padStart(2, "0")}`,
689
+ );
690
+ }
691
+ if (frame.payload.length < MIN_CONTROL_PAYLOAD_SIZE) {
692
+ throw new SbrpError(
693
+ SbrpErrorCode.MalformedFrame,
694
+ `Control payload must be at least ${MIN_CONTROL_PAYLOAD_SIZE} bytes, got ${frame.payload.length}`,
695
+ );
696
+ }
697
+ const view = new DataView(
698
+ frame.payload.buffer,
699
+ frame.payload.byteOffset,
700
+ frame.payload.byteLength,
701
+ );
702
+ const code = view.getUint16(0, false) as WireControlCode;
703
+ // 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
+ );
707
+ return { code, message };
708
+ }
709
+
710
+ // ============================================================================
711
+ // Streaming frame decoder
712
+ // ============================================================================
713
+
714
+ /**
715
+ * Streaming frame decoder for incremental parsing.
716
+ *
717
+ * Accumulates bytes and yields complete frames. Useful when frames
718
+ * may be fragmented across WebSocket messages or TCP reads.
719
+ *
720
+ * @example
721
+ * ```typescript
722
+ * const decoder = new FrameDecoder();
723
+ * ws.on("message", (data) => {
724
+ * for (const frame of decoder.push(data)) {
725
+ * handleFrame(frame);
726
+ * }
727
+ * });
728
+ * ```
729
+ */
730
+ export class FrameDecoder {
731
+ private buffer: Uint8Array = new Uint8Array(0);
732
+
733
+ /**
734
+ * Push data and yield any complete frames.
735
+ */
736
+ *push(data: Uint8Array): Generator<Frame> {
737
+ // Append to buffer
738
+ if (this.buffer.length === 0) {
739
+ this.buffer = data;
740
+ } else {
741
+ const combined = new Uint8Array(this.buffer.length + data.length);
742
+ combined.set(this.buffer, 0);
743
+ combined.set(data, this.buffer.length);
744
+ this.buffer = combined;
745
+ }
746
+
747
+ // Yield complete frames
748
+ while (this.buffer.length >= FRAME_HEADER_SIZE) {
749
+ const header = readFrameHeader(this.buffer);
750
+ const frameSize = FRAME_HEADER_SIZE + header.length;
751
+
752
+ if (this.buffer.length < frameSize) {
753
+ break; // Incomplete frame, wait for more data
754
+ }
755
+
756
+ const { frame, bytesConsumed } = decodeFrameFromBuffer(this.buffer);
757
+ yield frame;
758
+ this.buffer = this.buffer.subarray(bytesConsumed);
759
+ }
760
+ }
761
+
762
+ /** Reset decoder state, discarding any buffered data */
763
+ reset(): void {
764
+ this.buffer = new Uint8Array(0);
765
+ }
766
+
767
+ /** Number of bytes currently buffered */
768
+ get bufferedBytes(): number {
769
+ return this.buffer.length;
770
+ }
771
+ }