@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.
- package/README.md +6 -4
- package/dist/.tsbuildinfo +1 -0
- package/dist/constants.d.ts +49 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +51 -0
- package/dist/constants.js.map +1 -0
- package/dist/crypto.d.ts +70 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +144 -0
- package/dist/crypto.js.map +1 -0
- package/dist/frame.d.ts +213 -0
- package/dist/frame.d.ts.map +1 -0
- package/dist/frame.js +547 -0
- package/dist/frame.js.map +1 -0
- package/dist/handshake.d.ts +39 -0
- package/dist/handshake.d.ts.map +1 -0
- package/dist/handshake.js +93 -0
- package/dist/handshake.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/replay.d.ts +32 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +88 -0
- package/dist/replay.js.map +1 -0
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +122 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +80 -0
- package/dist/types.js.map +1 -0
- package/package.json +1 -1
- package/src/constants.ts +3 -3
- package/src/crypto.test.ts +5 -5
- package/src/frame.test.ts +59 -10
- package/src/frame.ts +101 -77
- package/src/handshake.test.ts +29 -41
- package/src/handshake.ts +25 -27
- package/src/index.ts +4 -10
- package/src/integration.test.ts +97 -138
- package/src/session.test.ts +12 -10
- 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.
|
|
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 (
|
|
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 =
|
|
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;
|
package/src/crypto.test.ts
CHANGED
|
@@ -153,7 +153,7 @@ describe("crypto", () => {
|
|
|
153
153
|
|
|
154
154
|
const signature = signPayload(payload, privateKey);
|
|
155
155
|
// Tamper with signature
|
|
156
|
-
signature[0]
|
|
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]
|
|
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]
|
|
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]
|
|
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(
|
|
347
|
-
const frame = new Uint8Array(FRAME_HEADER_SIZE +
|
|
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,
|
|
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(
|
|
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]
|
|
748
|
-
expect(frames[0]
|
|
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]
|
|
762
|
-
expect(frames[1]
|
|
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]
|
|
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]
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
462
|
+
`acceptPublicKey must be ${X25519_PUBLIC_KEY_LENGTH} bytes, got ${accept.acceptPublicKey.length}`,
|
|
451
463
|
);
|
|
452
464
|
}
|
|
453
|
-
if (accept.signature.length !==
|
|
465
|
+
if (accept.signature.length !== ED25519_SIGNATURE_LENGTH) {
|
|
454
466
|
throw new SbrpError(
|
|
455
467
|
SbrpErrorCode.MalformedFrame,
|
|
456
|
-
`signature must be
|
|
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.
|
|
461
|
-
payload.set(accept.
|
|
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 =
|
|
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 ?
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
621
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
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 =
|
|
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
|
|
757
|
-
yield
|
|
758
|
-
this.buffer = this.buffer.subarray(
|
|
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
|
|