@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
|
@@ -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-
|
|
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-
|
|
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";
|