@sideband/secure-relay 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -11
- package/dist/LICENSE +190 -694
- package/package.json +1 -4
- package/src/constants.ts +29 -4
- package/src/crypto.test.ts +644 -0
- package/src/crypto.ts +1 -2
- package/src/frame.test.ts +820 -0
- package/src/frame.ts +771 -0
- package/src/handshake.test.ts +325 -0
- package/src/handshake.ts +7 -2
- package/src/index.ts +44 -2
- package/src/integration.test.ts +1025 -0
- package/src/replay.test.ts +306 -0
- package/src/replay.ts +1 -2
- package/src/session.test.ts +767 -0
- package/src/session.ts +1 -2
- package/src/types.ts +85 -18
- package/LICENSE +0 -190
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
|
+
}
|