@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.
@@ -0,0 +1,325 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { generateIdentityKeyPair } from "./crypto.js";
5
+ import {
6
+ createHandshakeInit,
7
+ processHandshakeAccept,
8
+ processHandshakeInit,
9
+ } from "./handshake.js";
10
+ import { asDaemonId, SbrpError, SbrpErrorCode } from "./types.js";
11
+
12
+ describe("handshake", () => {
13
+ const daemonId = asDaemonId("test-daemon-123");
14
+
15
+ describe("full handshake flow", () => {
16
+ it("completes handshake between client and daemon", () => {
17
+ const daemonIdentity = generateIdentityKeyPair();
18
+
19
+ // Client creates init
20
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
21
+ createHandshakeInit();
22
+
23
+ // Daemon processes init and creates accept
24
+ const { message: accept, result: daemonResult } = processHandshakeInit(
25
+ init,
26
+ daemonId,
27
+ daemonIdentity,
28
+ );
29
+
30
+ // Client processes accept
31
+ const clientResult = processHandshakeAccept(
32
+ accept,
33
+ daemonId,
34
+ daemonIdentity.publicKey,
35
+ clientEphemeral,
36
+ );
37
+
38
+ // Both should have valid session keys
39
+ expect(clientResult.sessionKeys).toBeDefined();
40
+ expect(daemonResult.sessionKeys).toBeDefined();
41
+ });
42
+ });
43
+
44
+ describe("session key derivation", () => {
45
+ it("derives identical session keys on both sides", () => {
46
+ const daemonIdentity = generateIdentityKeyPair();
47
+
48
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
49
+ createHandshakeInit();
50
+ const { message: accept, result: daemonResult } = processHandshakeInit(
51
+ init,
52
+ daemonId,
53
+ daemonIdentity,
54
+ );
55
+ const clientResult = processHandshakeAccept(
56
+ accept,
57
+ daemonId,
58
+ daemonIdentity.publicKey,
59
+ clientEphemeral,
60
+ );
61
+
62
+ // Keys should be byte-for-byte identical
63
+ expect(clientResult.sessionKeys.clientToDaemon).toEqual(
64
+ daemonResult.sessionKeys.clientToDaemon,
65
+ );
66
+ expect(clientResult.sessionKeys.daemonToClient).toEqual(
67
+ daemonResult.sessionKeys.daemonToClient,
68
+ );
69
+ });
70
+
71
+ it("derives directional keys (clientToDaemon != daemonToClient)", () => {
72
+ const daemonIdentity = generateIdentityKeyPair();
73
+
74
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
75
+ createHandshakeInit();
76
+ const { message: accept, result: daemonResult } = processHandshakeInit(
77
+ init,
78
+ daemonId,
79
+ daemonIdentity,
80
+ );
81
+ const clientResult = processHandshakeAccept(
82
+ accept,
83
+ daemonId,
84
+ daemonIdentity.publicKey,
85
+ clientEphemeral,
86
+ );
87
+
88
+ // Directional keys must differ to prevent reflection attacks
89
+ expect(clientResult.sessionKeys.clientToDaemon).not.toEqual(
90
+ clientResult.sessionKeys.daemonToClient,
91
+ );
92
+ expect(daemonResult.sessionKeys.clientToDaemon).not.toEqual(
93
+ daemonResult.sessionKeys.daemonToClient,
94
+ );
95
+ });
96
+
97
+ it("produces different keys for different handshakes (ephemeral randomness)", () => {
98
+ const daemonIdentity = generateIdentityKeyPair();
99
+
100
+ // First handshake
101
+ const { message: init1, ephemeralKeyPair: clientEphemeral1 } =
102
+ createHandshakeInit();
103
+ const { message: accept1 } = processHandshakeInit(
104
+ init1,
105
+ daemonId,
106
+ daemonIdentity,
107
+ );
108
+ const result1 = processHandshakeAccept(
109
+ accept1,
110
+ daemonId,
111
+ daemonIdentity.publicKey,
112
+ clientEphemeral1,
113
+ );
114
+
115
+ // Second handshake
116
+ const { message: init2, ephemeralKeyPair: clientEphemeral2 } =
117
+ createHandshakeInit();
118
+ const { message: accept2 } = processHandshakeInit(
119
+ init2,
120
+ daemonId,
121
+ daemonIdentity,
122
+ );
123
+ const result2 = processHandshakeAccept(
124
+ accept2,
125
+ daemonId,
126
+ daemonIdentity.publicKey,
127
+ clientEphemeral2,
128
+ );
129
+
130
+ // Keys from different handshakes should differ
131
+ expect(result1.sessionKeys.clientToDaemon).not.toEqual(
132
+ result2.sessionKeys.clientToDaemon,
133
+ );
134
+ expect(result1.sessionKeys.daemonToClient).not.toEqual(
135
+ result2.sessionKeys.daemonToClient,
136
+ );
137
+ });
138
+ });
139
+
140
+ describe("signature verification", () => {
141
+ it("fails with wrong identity key (throws SbrpError with HandshakeFailed)", () => {
142
+ const daemonIdentity = generateIdentityKeyPair();
143
+ const wrongIdentity = generateIdentityKeyPair();
144
+
145
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
146
+ createHandshakeInit();
147
+ const { message: accept } = processHandshakeInit(
148
+ init,
149
+ daemonId,
150
+ daemonIdentity,
151
+ );
152
+
153
+ // Client tries to verify with wrong identity key
154
+ expect(() =>
155
+ processHandshakeAccept(
156
+ accept,
157
+ daemonId,
158
+ wrongIdentity.publicKey, // wrong key!
159
+ clientEphemeral,
160
+ ),
161
+ ).toThrow(SbrpError);
162
+
163
+ try {
164
+ processHandshakeAccept(
165
+ accept,
166
+ daemonId,
167
+ wrongIdentity.publicKey,
168
+ clientEphemeral,
169
+ );
170
+ } catch (err) {
171
+ expect(err).toBeInstanceOf(SbrpError);
172
+ expect((err as SbrpError).code).toBe(SbrpErrorCode.HandshakeFailed);
173
+ }
174
+ });
175
+
176
+ it("fails with tampered signature", () => {
177
+ const daemonIdentity = generateIdentityKeyPair();
178
+
179
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
180
+ createHandshakeInit();
181
+ const { message: accept } = processHandshakeInit(
182
+ init,
183
+ daemonId,
184
+ daemonIdentity,
185
+ );
186
+
187
+ // Tamper with the signature
188
+ const tamperedAccept = {
189
+ ...accept,
190
+ signature: new Uint8Array(accept.signature),
191
+ };
192
+ tamperedAccept.signature[0] ^= 0xff; // flip bits
193
+
194
+ expect(() =>
195
+ processHandshakeAccept(
196
+ tamperedAccept,
197
+ daemonId,
198
+ daemonIdentity.publicKey,
199
+ clientEphemeral,
200
+ ),
201
+ ).toThrow(SbrpError);
202
+ });
203
+
204
+ it("fails with wrong daemon ID in verification", () => {
205
+ const daemonIdentity = generateIdentityKeyPair();
206
+ const wrongDaemonId = asDaemonId("wrong-daemon-456");
207
+
208
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
209
+ createHandshakeInit();
210
+ const { message: accept } = processHandshakeInit(
211
+ init,
212
+ daemonId,
213
+ daemonIdentity,
214
+ );
215
+
216
+ // Client tries to verify with wrong daemon ID
217
+ expect(() =>
218
+ processHandshakeAccept(
219
+ accept,
220
+ wrongDaemonId, // wrong daemon ID!
221
+ daemonIdentity.publicKey,
222
+ clientEphemeral,
223
+ ),
224
+ ).toThrow(SbrpError);
225
+
226
+ try {
227
+ processHandshakeAccept(
228
+ accept,
229
+ wrongDaemonId,
230
+ daemonIdentity.publicKey,
231
+ clientEphemeral,
232
+ );
233
+ } catch (err) {
234
+ expect(err).toBeInstanceOf(SbrpError);
235
+ expect((err as SbrpError).code).toBe(SbrpErrorCode.HandshakeFailed);
236
+ }
237
+ });
238
+ });
239
+
240
+ describe("createHandshakeInit", () => {
241
+ it("returns 32-byte ephemeral public key", () => {
242
+ const { message, ephemeralKeyPair } = createHandshakeInit();
243
+
244
+ expect(message.type).toBe("handshake.init");
245
+ expect(message.initPublicKey).toBeInstanceOf(Uint8Array);
246
+ expect(message.initPublicKey.length).toBe(32);
247
+ expect(ephemeralKeyPair.publicKey).toEqual(message.initPublicKey);
248
+ expect(ephemeralKeyPair.privateKey.length).toBe(32);
249
+ });
250
+
251
+ it("generates different ephemeral keys each time", () => {
252
+ const result1 = createHandshakeInit();
253
+ const result2 = createHandshakeInit();
254
+
255
+ expect(result1.message.initPublicKey).not.toEqual(
256
+ result2.message.initPublicKey,
257
+ );
258
+ expect(result1.ephemeralKeyPair.privateKey).not.toEqual(
259
+ result2.ephemeralKeyPair.privateKey,
260
+ );
261
+ });
262
+ });
263
+
264
+ describe("processHandshakeInit", () => {
265
+ it("returns 64-byte signature and 32-byte ephemeral key", () => {
266
+ const daemonIdentity = generateIdentityKeyPair();
267
+ const { message: init } = createHandshakeInit();
268
+
269
+ const { message: accept, result } = processHandshakeInit(
270
+ init,
271
+ daemonId,
272
+ daemonIdentity,
273
+ );
274
+
275
+ expect(accept.type).toBe("handshake.accept");
276
+ expect(accept.acceptPublicKey).toBeInstanceOf(Uint8Array);
277
+ expect(accept.acceptPublicKey.length).toBe(32);
278
+ expect(accept.signature).toBeInstanceOf(Uint8Array);
279
+ expect(accept.signature.length).toBe(64);
280
+ expect(result.signature).toEqual(accept.signature);
281
+ expect(result.ephemeralKeyPair.publicKey).toEqual(accept.acceptPublicKey);
282
+ });
283
+
284
+ it("generates different ephemeral keys and signatures each time", () => {
285
+ const daemonIdentity = generateIdentityKeyPair();
286
+ const { message: init } = createHandshakeInit();
287
+
288
+ const result1 = processHandshakeInit(init, daemonId, daemonIdentity);
289
+ const result2 = processHandshakeInit(init, daemonId, daemonIdentity);
290
+
291
+ // Different ephemeral keys
292
+ expect(result1.message.acceptPublicKey).not.toEqual(
293
+ result2.message.acceptPublicKey,
294
+ );
295
+
296
+ // Different signatures (due to different ephemeral keys in payload)
297
+ expect(result1.message.signature).not.toEqual(result2.message.signature);
298
+ });
299
+ });
300
+
301
+ describe("session key properties", () => {
302
+ it("derives 32-byte symmetric keys", () => {
303
+ const daemonIdentity = generateIdentityKeyPair();
304
+
305
+ const { message: init, ephemeralKeyPair: clientEphemeral } =
306
+ createHandshakeInit();
307
+ const { message: accept, result: daemonResult } = processHandshakeInit(
308
+ init,
309
+ daemonId,
310
+ daemonIdentity,
311
+ );
312
+ const clientResult = processHandshakeAccept(
313
+ accept,
314
+ daemonId,
315
+ daemonIdentity.publicKey,
316
+ clientEphemeral,
317
+ );
318
+
319
+ expect(clientResult.sessionKeys.clientToDaemon.length).toBe(32);
320
+ expect(clientResult.sessionKeys.daemonToClient.length).toBe(32);
321
+ expect(daemonResult.sessionKeys.clientToDaemon.length).toBe(32);
322
+ expect(daemonResult.sessionKeys.daemonToClient.length).toBe(32);
323
+ });
324
+ });
325
+ });
package/src/handshake.ts CHANGED
@@ -1,5 +1,4 @@
1
- // SPDX-FileCopyrightText: 2025-present Sideband
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
1
+ // SPDX-License-Identifier: Apache-2.0
3
2
 
4
3
  /**
5
4
  * E2EE handshake protocol for Sideband Relay Protocol (SBRP).
@@ -66,6 +65,9 @@ export function createHandshakeInit(): {
66
65
  * 1. Generate ephemeral X25519 keypair
67
66
  * 2. Sign ephemeral public key with identity key (context-bound)
68
67
  * 3. Derive session keys
68
+ *
69
+ * NOTE: Callers MUST enforce a 30-second handshake timeout per SBRP §1.4.
70
+ * This function does not track time; timeout enforcement is a transport concern.
69
71
  */
70
72
  export function processHandshakeInit(
71
73
  init: HandshakeInit,
@@ -118,6 +120,9 @@ export function processHandshakeInit(
118
120
  * 1. Verify signature using PINNED identity key (TOFU)
119
121
  * 2. Derive session keys using same transcript hash as daemon
120
122
  *
123
+ * NOTE: Callers MUST enforce a 30-second handshake timeout per SBRP §1.4.
124
+ * This function does not track time; timeout enforcement is a transport concern.
125
+ *
121
126
  * @throws {SbrpError} with code HandshakeFailed if signature verification fails
122
127
  */
123
128
  export function processHandshakeAccept(
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
- // SPDX-FileCopyrightText: 2025-present Sideband
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
1
+ // SPDX-License-Identifier: Apache-2.0
3
2
 
4
3
  /**
5
4
  * @sideband/secure-relay
@@ -46,6 +45,7 @@ export type {
46
45
  HandshakeInit,
47
46
  IdentityKeyPair,
48
47
  PinnedIdentity,
48
+ SessionId,
49
49
  SessionKeys,
50
50
  } from "./types.js";
51
51
 
@@ -55,6 +55,8 @@ export {
55
55
  Direction,
56
56
  SbrpError,
57
57
  SbrpErrorCode,
58
+ SignalCode,
59
+ SignalReason,
58
60
  } from "./types.js";
59
61
 
60
62
  // Constants
@@ -66,11 +68,19 @@ export {
66
68
  ED25519_PRIVATE_KEY_LENGTH,
67
69
  ED25519_PUBLIC_KEY_LENGTH,
68
70
  ED25519_SIGNATURE_LENGTH,
71
+ FRAME_HEADER_SIZE,
72
+ HANDSHAKE_ACCEPT_PAYLOAD_SIZE,
73
+ HANDSHAKE_INIT_PAYLOAD_SIZE,
74
+ MAX_PAYLOAD_SIZE,
75
+ MAX_PING_PAYLOAD_SIZE,
76
+ MIN_CONTROL_PAYLOAD_SIZE,
77
+ MIN_ENCRYPTED_PAYLOAD_SIZE,
69
78
  NONCE_LENGTH,
70
79
  SBRP_HANDSHAKE_CONTEXT,
71
80
  SBRP_SESSION_KEYS_INFO,
72
81
  SBRP_TRANSCRIPT_CONTEXT,
73
82
  SESSION_KEYS_LENGTH,
83
+ SIGNAL_PAYLOAD_SIZE,
74
84
  SYMMETRIC_KEY_LENGTH,
75
85
  X25519_PRIVATE_KEY_LENGTH,
76
86
  X25519_PUBLIC_KEY_LENGTH,
@@ -130,3 +140,35 @@ export {
130
140
  encryptClientToDaemon,
131
141
  encryptDaemonToClient,
132
142
  } from "./session.js";
143
+
144
+ // Wire format (binary framing)
145
+ export type {
146
+ ControlPayload,
147
+ Frame,
148
+ FrameHeader,
149
+ SignalPayload,
150
+ } from "./frame.js";
151
+
152
+ export {
153
+ decodeControl,
154
+ decodeData,
155
+ decodeFrame,
156
+ decodeHandshakeAccept,
157
+ decodeHandshakeInit,
158
+ decodeSignal,
159
+ encodeControl,
160
+ encodeData,
161
+ encodeFrame,
162
+ encodeHandshakeAccept,
163
+ encodeHandshakeInit,
164
+ encodePing,
165
+ encodePong,
166
+ encodeSignal,
167
+ FrameDecoder,
168
+ FrameType,
169
+ fromWireControlCode,
170
+ isTerminalCode,
171
+ readFrameHeader,
172
+ toWireControlCode,
173
+ WireControlCode,
174
+ } from "./frame.js";