@sideband/secure-relay 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -8
- 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 +4 -4
- package/src/constants.ts +3 -3
- package/src/crypto.test.ts +5 -5
- package/src/crypto.ts +9 -9
- 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/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
|
|
package/src/handshake.test.ts
CHANGED
|
@@ -21,14 +21,14 @@ describe("handshake", () => {
|
|
|
21
21
|
createHandshakeInit();
|
|
22
22
|
|
|
23
23
|
// Daemon processes init and creates accept
|
|
24
|
-
const { message: accept,
|
|
24
|
+
const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
|
|
25
25
|
init,
|
|
26
26
|
daemonId,
|
|
27
27
|
daemonIdentity,
|
|
28
28
|
);
|
|
29
29
|
|
|
30
30
|
// Client processes accept
|
|
31
|
-
const
|
|
31
|
+
const clientKeys = processHandshakeAccept(
|
|
32
32
|
accept,
|
|
33
33
|
daemonId,
|
|
34
34
|
daemonIdentity.publicKey,
|
|
@@ -36,8 +36,8 @@ describe("handshake", () => {
|
|
|
36
36
|
);
|
|
37
37
|
|
|
38
38
|
// Both should have valid session keys
|
|
39
|
-
expect(
|
|
40
|
-
expect(
|
|
39
|
+
expect(clientKeys).toBeDefined();
|
|
40
|
+
expect(daemonKeys).toBeDefined();
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
@@ -47,12 +47,12 @@ describe("handshake", () => {
|
|
|
47
47
|
|
|
48
48
|
const { message: init, ephemeralKeyPair: clientEphemeral } =
|
|
49
49
|
createHandshakeInit();
|
|
50
|
-
const { message: accept,
|
|
50
|
+
const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
|
|
51
51
|
init,
|
|
52
52
|
daemonId,
|
|
53
53
|
daemonIdentity,
|
|
54
54
|
);
|
|
55
|
-
const
|
|
55
|
+
const clientKeys = processHandshakeAccept(
|
|
56
56
|
accept,
|
|
57
57
|
daemonId,
|
|
58
58
|
daemonIdentity.publicKey,
|
|
@@ -60,12 +60,8 @@ describe("handshake", () => {
|
|
|
60
60
|
);
|
|
61
61
|
|
|
62
62
|
// Keys should be byte-for-byte identical
|
|
63
|
-
expect(
|
|
64
|
-
|
|
65
|
-
);
|
|
66
|
-
expect(clientResult.sessionKeys.daemonToClient).toEqual(
|
|
67
|
-
daemonResult.sessionKeys.daemonToClient,
|
|
68
|
-
);
|
|
63
|
+
expect(clientKeys.clientToDaemon).toEqual(daemonKeys.clientToDaemon);
|
|
64
|
+
expect(clientKeys.daemonToClient).toEqual(daemonKeys.daemonToClient);
|
|
69
65
|
});
|
|
70
66
|
|
|
71
67
|
it("derives directional keys (clientToDaemon != daemonToClient)", () => {
|
|
@@ -73,12 +69,12 @@ describe("handshake", () => {
|
|
|
73
69
|
|
|
74
70
|
const { message: init, ephemeralKeyPair: clientEphemeral } =
|
|
75
71
|
createHandshakeInit();
|
|
76
|
-
const { message: accept,
|
|
72
|
+
const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
|
|
77
73
|
init,
|
|
78
74
|
daemonId,
|
|
79
75
|
daemonIdentity,
|
|
80
76
|
);
|
|
81
|
-
const
|
|
77
|
+
const clientKeys = processHandshakeAccept(
|
|
82
78
|
accept,
|
|
83
79
|
daemonId,
|
|
84
80
|
daemonIdentity.publicKey,
|
|
@@ -86,12 +82,8 @@ describe("handshake", () => {
|
|
|
86
82
|
);
|
|
87
83
|
|
|
88
84
|
// Directional keys must differ to prevent reflection attacks
|
|
89
|
-
expect(
|
|
90
|
-
|
|
91
|
-
);
|
|
92
|
-
expect(daemonResult.sessionKeys.clientToDaemon).not.toEqual(
|
|
93
|
-
daemonResult.sessionKeys.daemonToClient,
|
|
94
|
-
);
|
|
85
|
+
expect(clientKeys.clientToDaemon).not.toEqual(clientKeys.daemonToClient);
|
|
86
|
+
expect(daemonKeys.clientToDaemon).not.toEqual(daemonKeys.daemonToClient);
|
|
95
87
|
});
|
|
96
88
|
|
|
97
89
|
it("produces different keys for different handshakes (ephemeral randomness)", () => {
|
|
@@ -105,7 +97,7 @@ describe("handshake", () => {
|
|
|
105
97
|
daemonId,
|
|
106
98
|
daemonIdentity,
|
|
107
99
|
);
|
|
108
|
-
const
|
|
100
|
+
const keys1 = processHandshakeAccept(
|
|
109
101
|
accept1,
|
|
110
102
|
daemonId,
|
|
111
103
|
daemonIdentity.publicKey,
|
|
@@ -120,7 +112,7 @@ describe("handshake", () => {
|
|
|
120
112
|
daemonId,
|
|
121
113
|
daemonIdentity,
|
|
122
114
|
);
|
|
123
|
-
const
|
|
115
|
+
const keys2 = processHandshakeAccept(
|
|
124
116
|
accept2,
|
|
125
117
|
daemonId,
|
|
126
118
|
daemonIdentity.publicKey,
|
|
@@ -128,17 +120,13 @@ describe("handshake", () => {
|
|
|
128
120
|
);
|
|
129
121
|
|
|
130
122
|
// Keys from different handshakes should differ
|
|
131
|
-
expect(
|
|
132
|
-
|
|
133
|
-
);
|
|
134
|
-
expect(result1.sessionKeys.daemonToClient).not.toEqual(
|
|
135
|
-
result2.sessionKeys.daemonToClient,
|
|
136
|
-
);
|
|
123
|
+
expect(keys1.clientToDaemon).not.toEqual(keys2.clientToDaemon);
|
|
124
|
+
expect(keys1.daemonToClient).not.toEqual(keys2.daemonToClient);
|
|
137
125
|
});
|
|
138
126
|
});
|
|
139
127
|
|
|
140
128
|
describe("signature verification", () => {
|
|
141
|
-
it("fails with wrong identity key (
|
|
129
|
+
it("fails with wrong identity key (IdentityKeyChanged)", () => {
|
|
142
130
|
const daemonIdentity = generateIdentityKeyPair();
|
|
143
131
|
const wrongIdentity = generateIdentityKeyPair();
|
|
144
132
|
|
|
@@ -169,7 +157,7 @@ describe("handshake", () => {
|
|
|
169
157
|
);
|
|
170
158
|
} catch (err) {
|
|
171
159
|
expect(err).toBeInstanceOf(SbrpError);
|
|
172
|
-
expect((err as SbrpError).code).toBe(SbrpErrorCode.
|
|
160
|
+
expect((err as SbrpError).code).toBe(SbrpErrorCode.IdentityKeyChanged);
|
|
173
161
|
}
|
|
174
162
|
});
|
|
175
163
|
|
|
@@ -189,7 +177,7 @@ describe("handshake", () => {
|
|
|
189
177
|
...accept,
|
|
190
178
|
signature: new Uint8Array(accept.signature),
|
|
191
179
|
};
|
|
192
|
-
tamperedAccept.signature[0]
|
|
180
|
+
tamperedAccept.signature[0] = tamperedAccept.signature[0]! ^ 0xff; // flip bits
|
|
193
181
|
|
|
194
182
|
expect(() =>
|
|
195
183
|
processHandshakeAccept(
|
|
@@ -262,11 +250,11 @@ describe("handshake", () => {
|
|
|
262
250
|
});
|
|
263
251
|
|
|
264
252
|
describe("processHandshakeInit", () => {
|
|
265
|
-
it("returns
|
|
253
|
+
it("returns accept message with correct field sizes", () => {
|
|
266
254
|
const daemonIdentity = generateIdentityKeyPair();
|
|
267
255
|
const { message: init } = createHandshakeInit();
|
|
268
256
|
|
|
269
|
-
const { message: accept,
|
|
257
|
+
const { message: accept, sessionKeys } = processHandshakeInit(
|
|
270
258
|
init,
|
|
271
259
|
daemonId,
|
|
272
260
|
daemonIdentity,
|
|
@@ -277,8 +265,8 @@ describe("handshake", () => {
|
|
|
277
265
|
expect(accept.acceptPublicKey.length).toBe(32);
|
|
278
266
|
expect(accept.signature).toBeInstanceOf(Uint8Array);
|
|
279
267
|
expect(accept.signature.length).toBe(64);
|
|
280
|
-
expect(
|
|
281
|
-
expect(
|
|
268
|
+
expect(sessionKeys.clientToDaemon.length).toBe(32);
|
|
269
|
+
expect(sessionKeys.daemonToClient.length).toBe(32);
|
|
282
270
|
});
|
|
283
271
|
|
|
284
272
|
it("generates different ephemeral keys and signatures each time", () => {
|
|
@@ -304,22 +292,22 @@ describe("handshake", () => {
|
|
|
304
292
|
|
|
305
293
|
const { message: init, ephemeralKeyPair: clientEphemeral } =
|
|
306
294
|
createHandshakeInit();
|
|
307
|
-
const { message: accept,
|
|
295
|
+
const { message: accept, sessionKeys: daemonKeys } = processHandshakeInit(
|
|
308
296
|
init,
|
|
309
297
|
daemonId,
|
|
310
298
|
daemonIdentity,
|
|
311
299
|
);
|
|
312
|
-
const
|
|
300
|
+
const clientKeys = processHandshakeAccept(
|
|
313
301
|
accept,
|
|
314
302
|
daemonId,
|
|
315
303
|
daemonIdentity.publicKey,
|
|
316
304
|
clientEphemeral,
|
|
317
305
|
);
|
|
318
306
|
|
|
319
|
-
expect(
|
|
320
|
-
expect(
|
|
321
|
-
expect(
|
|
322
|
-
expect(
|
|
307
|
+
expect(clientKeys.clientToDaemon.length).toBe(32);
|
|
308
|
+
expect(clientKeys.daemonToClient.length).toBe(32);
|
|
309
|
+
expect(daemonKeys.clientToDaemon.length).toBe(32);
|
|
310
|
+
expect(daemonKeys.daemonToClient.length).toBe(32);
|
|
323
311
|
});
|
|
324
312
|
});
|
|
325
313
|
});
|
package/src/handshake.ts
CHANGED
|
@@ -27,19 +27,6 @@ import type {
|
|
|
27
27
|
} from "./types.js";
|
|
28
28
|
import { SbrpError, SbrpErrorCode } from "./types.js";
|
|
29
29
|
|
|
30
|
-
/** Result of a successful daemon handshake */
|
|
31
|
-
export interface DaemonHandshakeResult {
|
|
32
|
-
sessionKeys: SessionKeys;
|
|
33
|
-
ephemeralKeyPair: EphemeralKeyPair;
|
|
34
|
-
signature: Uint8Array;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Result of a successful client handshake */
|
|
38
|
-
export interface ClientHandshakeResult {
|
|
39
|
-
sessionKeys: SessionKeys;
|
|
40
|
-
ephemeralKeyPair: EphemeralKeyPair;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
30
|
/**
|
|
44
31
|
* Create a handshake init message (client side).
|
|
45
32
|
*
|
|
@@ -73,7 +60,7 @@ export function processHandshakeInit(
|
|
|
73
60
|
init: HandshakeInit,
|
|
74
61
|
daemonId: DaemonId,
|
|
75
62
|
identityKeyPair: IdentityKeyPair,
|
|
76
|
-
): { message: HandshakeAccept;
|
|
63
|
+
): { message: HandshakeAccept; sessionKeys: SessionKeys } {
|
|
77
64
|
const ephemeralKeyPair = generateEphemeralKeyPair();
|
|
78
65
|
|
|
79
66
|
// Sign ephemeral key with context binding
|
|
@@ -97,20 +84,18 @@ export function processHandshakeInit(
|
|
|
97
84
|
);
|
|
98
85
|
const sessionKeys = deriveSessionKeys(sharedSecret, transcriptHash);
|
|
99
86
|
|
|
100
|
-
// Best-effort zeroize
|
|
87
|
+
// Best-effort zeroize secrets
|
|
101
88
|
zeroize(sharedSecret);
|
|
89
|
+
zeroize(ephemeralKeyPair.privateKey);
|
|
102
90
|
|
|
103
91
|
return {
|
|
104
92
|
message: {
|
|
105
93
|
type: "handshake.accept",
|
|
94
|
+
identityPublicKey: identityKeyPair.publicKey,
|
|
106
95
|
acceptPublicKey: ephemeralKeyPair.publicKey,
|
|
107
96
|
signature,
|
|
108
97
|
},
|
|
109
|
-
|
|
110
|
-
sessionKeys,
|
|
111
|
-
ephemeralKeyPair,
|
|
112
|
-
signature,
|
|
113
|
-
},
|
|
98
|
+
sessionKeys,
|
|
114
99
|
};
|
|
115
100
|
}
|
|
116
101
|
|
|
@@ -123,14 +108,29 @@ export function processHandshakeInit(
|
|
|
123
108
|
* NOTE: Callers MUST enforce a 30-second handshake timeout per SBRP §1.4.
|
|
124
109
|
* This function does not track time; timeout enforcement is a transport concern.
|
|
125
110
|
*
|
|
126
|
-
* @
|
|
111
|
+
* @param ephemeralKeyPair The privateKey is zeroized in-place after key derivation.
|
|
112
|
+
* @throws {SbrpError} IdentityKeyChanged if advertised key doesn't match pinned key
|
|
113
|
+
* @throws {SbrpError} HandshakeFailed if signature verification fails
|
|
127
114
|
*/
|
|
128
115
|
export function processHandshakeAccept(
|
|
129
116
|
accept: HandshakeAccept,
|
|
130
117
|
daemonId: DaemonId,
|
|
131
118
|
pinnedIdentityPublicKey: Uint8Array,
|
|
132
119
|
ephemeralKeyPair: EphemeralKeyPair,
|
|
133
|
-
):
|
|
120
|
+
): SessionKeys {
|
|
121
|
+
// Reject if advertised identity key doesn't match pinned key.
|
|
122
|
+
// Signature is verified against pinnedIdentityPublicKey, but an attacker
|
|
123
|
+
// could swap the advertised field to mislead higher layers.
|
|
124
|
+
if (
|
|
125
|
+
accept.identityPublicKey.length !== pinnedIdentityPublicKey.length ||
|
|
126
|
+
!accept.identityPublicKey.every((b, i) => b === pinnedIdentityPublicKey[i])
|
|
127
|
+
) {
|
|
128
|
+
throw new SbrpError(
|
|
129
|
+
SbrpErrorCode.IdentityKeyChanged,
|
|
130
|
+
"Advertised identity key does not match pinned key",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
134
|
// Verify daemon signature using PINNED key (not relay-provided!)
|
|
135
135
|
const signaturePayload = createSignaturePayload(
|
|
136
136
|
daemonId,
|
|
@@ -164,11 +164,9 @@ export function processHandshakeAccept(
|
|
|
164
164
|
);
|
|
165
165
|
const sessionKeys = deriveSessionKeys(sharedSecret, transcriptHash);
|
|
166
166
|
|
|
167
|
-
// Best-effort zeroize
|
|
167
|
+
// Best-effort zeroize secrets
|
|
168
168
|
zeroize(sharedSecret);
|
|
169
|
+
zeroize(ephemeralKeyPair.privateKey);
|
|
169
170
|
|
|
170
|
-
return
|
|
171
|
-
sessionKeys,
|
|
172
|
-
ephemeralKeyPair,
|
|
173
|
-
};
|
|
171
|
+
return sessionKeys;
|
|
174
172
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
* const { message: init, ephemeralKeyPair } = createHandshakeInit();
|
|
23
23
|
*
|
|
24
24
|
* // Daemon side: process init and create accept
|
|
25
|
-
* const { message: accept,
|
|
26
|
-
* const clientSession = createClientSession(clientId,
|
|
25
|
+
* const { message: accept, sessionKeys } = processHandshakeInit(init, daemonId, identity);
|
|
26
|
+
* const clientSession = createClientSession(clientId, sessionKeys);
|
|
27
27
|
*
|
|
28
28
|
* // Client side: process accept (with TOFU-pinned identity)
|
|
29
|
-
* const
|
|
30
|
-
* const daemonSession = createDaemonSession(
|
|
29
|
+
* const clientKeys = processHandshakeAccept(accept, daemonId, pinnedKey, ephemeralKeyPair);
|
|
30
|
+
* const daemonSession = createDaemonSession(clientKeys);
|
|
31
31
|
*
|
|
32
32
|
* // Encrypt/decrypt messages
|
|
33
33
|
* const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
@@ -44,7 +44,6 @@ export type {
|
|
|
44
44
|
HandshakeAccept,
|
|
45
45
|
HandshakeInit,
|
|
46
46
|
IdentityKeyPair,
|
|
47
|
-
PinnedIdentity,
|
|
48
47
|
SessionId,
|
|
49
48
|
SessionKeys,
|
|
50
49
|
} from "./types.js";
|
|
@@ -106,11 +105,6 @@ export {
|
|
|
106
105
|
} from "./crypto.js";
|
|
107
106
|
|
|
108
107
|
// Handshake
|
|
109
|
-
export type {
|
|
110
|
-
ClientHandshakeResult,
|
|
111
|
-
DaemonHandshakeResult,
|
|
112
|
-
} from "./handshake.js";
|
|
113
|
-
|
|
114
108
|
export {
|
|
115
109
|
createHandshakeInit,
|
|
116
110
|
processHandshakeAccept,
|