@hyperdrive.bot/paseo-relay 0.2.5
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/dist/base64.d.ts +3 -0
- package/dist/base64.js +17 -0
- package/dist/cloudflare-adapter.d.ts +75 -0
- package/dist/cloudflare-adapter.js +500 -0
- package/dist/crypto.d.ts +30 -0
- package/dist/crypto.js +132 -0
- package/dist/e2ee.d.ts +5 -0
- package/dist/e2ee.js +3 -0
- package/dist/encrypted-channel.d.ts +76 -0
- package/dist/encrypted-channel.js +350 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +11 -0
- package/package.json +54 -0
package/dist/base64.d.ts
ADDED
package/dist/base64.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fromByteArray, toByteArray } from "base64-js";
|
|
2
|
+
export function arrayBufferToBase64(buffer) {
|
|
3
|
+
return fromByteArray(new Uint8Array(buffer));
|
|
4
|
+
}
|
|
5
|
+
export function base64ToArrayBuffer(base64) {
|
|
6
|
+
const normalized = (() => {
|
|
7
|
+
const trimmed = base64.trim();
|
|
8
|
+
const standard = trimmed.replace(/-/g, "+").replace(/_/g, "/");
|
|
9
|
+
const padLen = (4 - (standard.length % 4)) % 4;
|
|
10
|
+
return standard + "=".repeat(padLen);
|
|
11
|
+
})();
|
|
12
|
+
const bytes = toByteArray(normalized);
|
|
13
|
+
const out = new Uint8Array(bytes.byteLength);
|
|
14
|
+
out.set(bytes);
|
|
15
|
+
return out.buffer;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=base64.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Durable Objects adapter for the relay.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a Durable Object class that can be deployed to
|
|
5
|
+
* Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
|
|
6
|
+
*
|
|
7
|
+
* Each session gets its own Durable Object instance, identified by session ID.
|
|
8
|
+
*
|
|
9
|
+
* Wrangler config:
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {
|
|
12
|
+
* "durable_objects": {
|
|
13
|
+
* "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
|
|
14
|
+
* },
|
|
15
|
+
* "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
interface DurableObjectState {
|
|
20
|
+
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
|
|
21
|
+
getWebSockets(tag?: string): WebSocket[];
|
|
22
|
+
}
|
|
23
|
+
interface Env {
|
|
24
|
+
RELAY: DurableObjectNamespace;
|
|
25
|
+
}
|
|
26
|
+
interface DurableObjectNamespace {
|
|
27
|
+
idFromName(name: string): DurableObjectId;
|
|
28
|
+
get(id: DurableObjectId): DurableObjectStub;
|
|
29
|
+
}
|
|
30
|
+
interface DurableObjectId {
|
|
31
|
+
toString(): string;
|
|
32
|
+
}
|
|
33
|
+
interface DurableObjectStub {
|
|
34
|
+
fetch(request: Request): Promise<Response>;
|
|
35
|
+
}
|
|
36
|
+
export declare class RelayDurableObject {
|
|
37
|
+
private state;
|
|
38
|
+
private pendingFrames;
|
|
39
|
+
constructor(state: DurableObjectState);
|
|
40
|
+
private createWebSocketPair;
|
|
41
|
+
private requireWebSocketUpgrade;
|
|
42
|
+
private asSwitchingProtocolsResponse;
|
|
43
|
+
private hasServerDataSocket;
|
|
44
|
+
private hasClientSocket;
|
|
45
|
+
private closeExistingServerSockets;
|
|
46
|
+
private handleControlKeepalive;
|
|
47
|
+
private nudgeOrResetControlForConnection;
|
|
48
|
+
private bufferFrame;
|
|
49
|
+
private flushFrames;
|
|
50
|
+
private listConnectedConnectionIds;
|
|
51
|
+
private notifyControls;
|
|
52
|
+
private fetchV1;
|
|
53
|
+
private fetchV2;
|
|
54
|
+
fetch(request: Request): Promise<Response>;
|
|
55
|
+
/**
|
|
56
|
+
* Called when a WebSocket message is received (wakes from hibernation).
|
|
57
|
+
*/
|
|
58
|
+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void;
|
|
59
|
+
/**
|
|
60
|
+
* Called when a WebSocket closes (wakes from hibernation).
|
|
61
|
+
*/
|
|
62
|
+
webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): void;
|
|
63
|
+
/**
|
|
64
|
+
* Called on WebSocket error.
|
|
65
|
+
*/
|
|
66
|
+
webSocketError(ws: WebSocket, error: unknown): void;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Worker entry point that routes requests to the appropriate Durable Object.
|
|
70
|
+
*/
|
|
71
|
+
declare const _default: {
|
|
72
|
+
fetch(request: Request, env: Env): Promise<Response>;
|
|
73
|
+
};
|
|
74
|
+
export default _default;
|
|
75
|
+
//# sourceMappingURL=cloudflare-adapter.d.ts.map
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Durable Objects adapter for the relay.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a Durable Object class that can be deployed to
|
|
5
|
+
* Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
|
|
6
|
+
*
|
|
7
|
+
* Each session gets its own Durable Object instance, identified by session ID.
|
|
8
|
+
*
|
|
9
|
+
* Wrangler config:
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {
|
|
12
|
+
* "durable_objects": {
|
|
13
|
+
* "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
|
|
14
|
+
* },
|
|
15
|
+
* "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
const LEGACY_RELAY_VERSION = "1";
|
|
20
|
+
const CURRENT_RELAY_VERSION = "2";
|
|
21
|
+
function resolveRelayVersion(rawValue) {
|
|
22
|
+
if (rawValue == null)
|
|
23
|
+
return LEGACY_RELAY_VERSION;
|
|
24
|
+
const value = rawValue.trim();
|
|
25
|
+
if (!value)
|
|
26
|
+
return LEGACY_RELAY_VERSION;
|
|
27
|
+
if (value === LEGACY_RELAY_VERSION || value === CURRENT_RELAY_VERSION) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function hasAttachmentMethods(ws) {
|
|
33
|
+
// Type-safe check for attachment methods - required for Cloudflare WebSocket hibernation API
|
|
34
|
+
// Use Reflect to check for methods without type assertions
|
|
35
|
+
return ("serializeAttachment" in ws &&
|
|
36
|
+
"deserializeAttachment" in ws &&
|
|
37
|
+
typeof Reflect.get(ws, "serializeAttachment") === "function" &&
|
|
38
|
+
typeof Reflect.get(ws, "deserializeAttachment") === "function");
|
|
39
|
+
}
|
|
40
|
+
function deserializeAttachment(ws) {
|
|
41
|
+
if (!hasAttachmentMethods(ws))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
return ws.deserializeAttachment();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function serializeAttachment(ws, value) {
|
|
51
|
+
if (!hasAttachmentMethods(ws)) {
|
|
52
|
+
throw new Error("WebSocket does not support attachments");
|
|
53
|
+
}
|
|
54
|
+
ws.serializeAttachment(value);
|
|
55
|
+
}
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
return typeof value === "object" && value !== null;
|
|
58
|
+
}
|
|
59
|
+
function getString(record, key) {
|
|
60
|
+
const value = record[key];
|
|
61
|
+
return typeof value === "string" ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
function getGlobalWebSocketPair() {
|
|
64
|
+
// Access WebSocketPair from global scope (Cloudflare Workers runtime)
|
|
65
|
+
// Use Reflect to access global property without type assertions
|
|
66
|
+
const WebSocketPair = Reflect.get(globalThis, "WebSocketPair");
|
|
67
|
+
if (typeof WebSocketPair === "function") {
|
|
68
|
+
return WebSocketPair;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
export class RelayDurableObject {
|
|
73
|
+
constructor(state) {
|
|
74
|
+
this.pendingFrames = new Map();
|
|
75
|
+
this.state = state;
|
|
76
|
+
}
|
|
77
|
+
createWebSocketPair() {
|
|
78
|
+
const WebSocketPairCtor = getGlobalWebSocketPair();
|
|
79
|
+
if (!WebSocketPairCtor) {
|
|
80
|
+
throw new Error("WebSocketPair not available in global scope");
|
|
81
|
+
}
|
|
82
|
+
const pair = new WebSocketPairCtor();
|
|
83
|
+
return [pair[0], pair[1]];
|
|
84
|
+
}
|
|
85
|
+
requireWebSocketUpgrade(request) {
|
|
86
|
+
const upgradeHeader = request.headers.get("Upgrade");
|
|
87
|
+
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
|
88
|
+
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
asSwitchingProtocolsResponse(client) {
|
|
93
|
+
return new Response(null, {
|
|
94
|
+
status: 101,
|
|
95
|
+
webSocket: client,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
hasServerDataSocket(connectionId) {
|
|
99
|
+
try {
|
|
100
|
+
return this.state.getWebSockets(`server:${connectionId}`).length > 0;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
hasClientSocket(connectionId) {
|
|
107
|
+
try {
|
|
108
|
+
return this.state.getWebSockets(`client:${connectionId}`).length > 0;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
closeExistingServerSockets(args) {
|
|
115
|
+
if (args.isServerControl) {
|
|
116
|
+
for (const ws of this.state.getWebSockets("server-control")) {
|
|
117
|
+
ws.close(1008, "Replaced by new connection");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (args.isServerData) {
|
|
121
|
+
for (const ws of this.state.getWebSockets(`server:${args.resolvedConnectionId}`)) {
|
|
122
|
+
ws.close(1008, "Replaced by new connection");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// COMPAT(relay-json-ping): Old daemons (< v0.1.76) send JSON {type:"ping"} on the control
|
|
127
|
+
// socket and rely on a JSON {type:"pong"} reply to keep controlLastSeenAt fresh. New daemons
|
|
128
|
+
// use WebSocket protocol pings (auto-answered at the edge, DO stays hibernated). Remove this
|
|
129
|
+
// handler once the supported-daemon floor is >= v0.1.76 (target: 2026-11-13).
|
|
130
|
+
handleControlKeepalive(ws, message) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(message);
|
|
133
|
+
const parsedRecord = isRecord(parsed) ? parsed : null;
|
|
134
|
+
if (parsedRecord?.type !== "ping")
|
|
135
|
+
return;
|
|
136
|
+
// Logged so the daemon-side e2e idle test can assert no JSON ping reached the DO
|
|
137
|
+
// (which would indicate a regression to app-level pings that wake the DO).
|
|
138
|
+
console.log("[Relay DO] legacy_json_ping_received");
|
|
139
|
+
try {
|
|
140
|
+
ws.send(JSON.stringify({ type: "pong", ts: Date.now() }));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// ignore
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// ignore non-JSON control payloads
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
nudgeOrResetControlForConnection(connectionId) {
|
|
151
|
+
// If the daemon's control WS becomes half-open, the DO can't reliably detect it via ws.send errors
|
|
152
|
+
// (Cloudflare may accept writes even if the other side is no longer reading).
|
|
153
|
+
//
|
|
154
|
+
// Instead, observe whether the daemon reacts by opening the per-connection server-data socket.
|
|
155
|
+
// If it doesn't, nudge with a sync message; if still no reaction, force-close the control
|
|
156
|
+
// socket(s) so the daemon reconnects.
|
|
157
|
+
const initialDelayMs = 10000;
|
|
158
|
+
const secondDelayMs = 5000;
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
if (!this.hasClientSocket(connectionId))
|
|
161
|
+
return;
|
|
162
|
+
if (this.hasServerDataSocket(connectionId))
|
|
163
|
+
return;
|
|
164
|
+
// First nudge: send a full sync list.
|
|
165
|
+
this.notifyControls({ type: "sync", connectionIds: this.listConnectedConnectionIds() });
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
if (!this.hasClientSocket(connectionId))
|
|
168
|
+
return;
|
|
169
|
+
if (this.hasServerDataSocket(connectionId))
|
|
170
|
+
return;
|
|
171
|
+
// Still nothing: assume control is stuck and force a reconnect.
|
|
172
|
+
for (const ws of this.state.getWebSockets("server-control")) {
|
|
173
|
+
try {
|
|
174
|
+
ws.close(1011, "Control unresponsive");
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}, secondDelayMs);
|
|
181
|
+
}, initialDelayMs);
|
|
182
|
+
}
|
|
183
|
+
bufferFrame(connectionId, message) {
|
|
184
|
+
const existing = this.pendingFrames.get(connectionId) ?? [];
|
|
185
|
+
existing.push(message);
|
|
186
|
+
// Prevent unbounded memory growth if a daemon never connects.
|
|
187
|
+
if (existing.length > 200) {
|
|
188
|
+
existing.splice(0, existing.length - 200);
|
|
189
|
+
}
|
|
190
|
+
this.pendingFrames.set(connectionId, existing);
|
|
191
|
+
}
|
|
192
|
+
flushFrames(connectionId, serverWs) {
|
|
193
|
+
const frames = this.pendingFrames.get(connectionId);
|
|
194
|
+
if (!frames || frames.length === 0)
|
|
195
|
+
return;
|
|
196
|
+
this.pendingFrames.delete(connectionId);
|
|
197
|
+
for (const frame of frames) {
|
|
198
|
+
try {
|
|
199
|
+
serverWs.send(frame);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// If we can't flush, re-buffer and let the daemon re-establish.
|
|
203
|
+
this.bufferFrame(connectionId, frame);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
listConnectedConnectionIds() {
|
|
209
|
+
const out = new Set();
|
|
210
|
+
for (const ws of this.state.getWebSockets("client")) {
|
|
211
|
+
try {
|
|
212
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
213
|
+
const attachment = isRecord(attachmentRaw) ? attachmentRaw : null;
|
|
214
|
+
if (attachment?.role === "client" &&
|
|
215
|
+
typeof attachment.connectionId === "string" &&
|
|
216
|
+
attachment.connectionId) {
|
|
217
|
+
out.add(attachment.connectionId);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// ignore
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return Array.from(out);
|
|
225
|
+
}
|
|
226
|
+
notifyControls(message) {
|
|
227
|
+
const text = JSON.stringify(message);
|
|
228
|
+
for (const ws of this.state.getWebSockets("server-control")) {
|
|
229
|
+
try {
|
|
230
|
+
ws.send(text);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// If the control socket is dead, close it so the daemon can reconnect.
|
|
234
|
+
try {
|
|
235
|
+
ws.close(1011, "Control send failed");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// ignore
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
fetchV1(request, role, serverId) {
|
|
244
|
+
const upgradeError = this.requireWebSocketUpgrade(request);
|
|
245
|
+
if (upgradeError)
|
|
246
|
+
return upgradeError;
|
|
247
|
+
for (const ws of this.state.getWebSockets(role)) {
|
|
248
|
+
ws.close(1008, "Replaced by new connection");
|
|
249
|
+
}
|
|
250
|
+
const [client, server] = this.createWebSocketPair();
|
|
251
|
+
this.state.acceptWebSocket(server, [role]);
|
|
252
|
+
const attachment = {
|
|
253
|
+
serverId,
|
|
254
|
+
role,
|
|
255
|
+
version: LEGACY_RELAY_VERSION,
|
|
256
|
+
connectionId: null,
|
|
257
|
+
createdAt: Date.now(),
|
|
258
|
+
};
|
|
259
|
+
serializeAttachment(server, attachment);
|
|
260
|
+
console.log(`[Relay DO] v1:${role} connected to session ${serverId}`);
|
|
261
|
+
return this.asSwitchingProtocolsResponse(client);
|
|
262
|
+
}
|
|
263
|
+
fetchV2(request, role, serverId, connectionId) {
|
|
264
|
+
const upgradeError = this.requireWebSocketUpgrade(request);
|
|
265
|
+
if (upgradeError)
|
|
266
|
+
return upgradeError;
|
|
267
|
+
// If a client didn't provide a connectionId, the relay assigns one for routing.
|
|
268
|
+
const resolvedConnectionId = role === "client" && !connectionId
|
|
269
|
+
? `conn_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`
|
|
270
|
+
: connectionId;
|
|
271
|
+
const isServerControl = role === "server" && !resolvedConnectionId;
|
|
272
|
+
const isServerData = role === "server" && !!resolvedConnectionId;
|
|
273
|
+
// Close any existing server-side connection with the same identity.
|
|
274
|
+
// - server-control: single per serverId
|
|
275
|
+
// - server-data: single per connectionId
|
|
276
|
+
// - client: many sockets per connectionId are allowed
|
|
277
|
+
this.closeExistingServerSockets({ isServerControl, isServerData, resolvedConnectionId });
|
|
278
|
+
const [client, server] = this.createWebSocketPair();
|
|
279
|
+
const tags = [];
|
|
280
|
+
if (role === "client") {
|
|
281
|
+
tags.push("client", `client:${resolvedConnectionId}`);
|
|
282
|
+
}
|
|
283
|
+
else if (isServerControl) {
|
|
284
|
+
tags.push("server-control");
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
tags.push("server", `server:${resolvedConnectionId}`);
|
|
288
|
+
}
|
|
289
|
+
this.state.acceptWebSocket(server, tags);
|
|
290
|
+
const attachment = {
|
|
291
|
+
serverId,
|
|
292
|
+
role,
|
|
293
|
+
version: CURRENT_RELAY_VERSION,
|
|
294
|
+
connectionId: resolvedConnectionId || null,
|
|
295
|
+
createdAt: Date.now(),
|
|
296
|
+
};
|
|
297
|
+
serializeAttachment(server, attachment);
|
|
298
|
+
let roleSuffix = "";
|
|
299
|
+
if (isServerControl) {
|
|
300
|
+
roleSuffix = "(control)";
|
|
301
|
+
}
|
|
302
|
+
else if (isServerData) {
|
|
303
|
+
roleSuffix = `(data:${resolvedConnectionId})`;
|
|
304
|
+
}
|
|
305
|
+
else if (role === "client") {
|
|
306
|
+
roleSuffix = `(${resolvedConnectionId})`;
|
|
307
|
+
}
|
|
308
|
+
console.log(`[Relay DO] v2:${role}${roleSuffix} connected to session ${serverId}`);
|
|
309
|
+
if (role === "client") {
|
|
310
|
+
this.notifyControls({ type: "connected", connectionId: resolvedConnectionId });
|
|
311
|
+
this.nudgeOrResetControlForConnection(resolvedConnectionId);
|
|
312
|
+
}
|
|
313
|
+
if (isServerControl) {
|
|
314
|
+
// Send current connection list so the daemon can attach existing connections.
|
|
315
|
+
try {
|
|
316
|
+
server.send(JSON.stringify({ type: "sync", connectionIds: this.listConnectedConnectionIds() }));
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// ignore
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (isServerData && resolvedConnectionId) {
|
|
323
|
+
this.flushFrames(resolvedConnectionId, server);
|
|
324
|
+
}
|
|
325
|
+
return this.asSwitchingProtocolsResponse(client);
|
|
326
|
+
}
|
|
327
|
+
async fetch(request) {
|
|
328
|
+
const url = new URL(request.url);
|
|
329
|
+
const roleRaw = url.searchParams.get("role");
|
|
330
|
+
const role = roleRaw === "server" || roleRaw === "client" ? roleRaw : null;
|
|
331
|
+
const serverId = url.searchParams.get("serverId");
|
|
332
|
+
const connectionIdRaw = url.searchParams.get("connectionId");
|
|
333
|
+
const connectionId = typeof connectionIdRaw === "string" ? connectionIdRaw.trim() : "";
|
|
334
|
+
const version = resolveRelayVersion(url.searchParams.get("v"));
|
|
335
|
+
if (!role || (role !== "server" && role !== "client")) {
|
|
336
|
+
return new Response("Missing or invalid role parameter", { status: 400 });
|
|
337
|
+
}
|
|
338
|
+
if (!serverId) {
|
|
339
|
+
return new Response("Missing serverId parameter", { status: 400 });
|
|
340
|
+
}
|
|
341
|
+
if (!version) {
|
|
342
|
+
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
|
|
343
|
+
}
|
|
344
|
+
if (version === LEGACY_RELAY_VERSION) {
|
|
345
|
+
return this.fetchV1(request, role, serverId);
|
|
346
|
+
}
|
|
347
|
+
return this.fetchV2(request, role, serverId, connectionId);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Called when a WebSocket message is received (wakes from hibernation).
|
|
351
|
+
*/
|
|
352
|
+
webSocketMessage(ws, message) {
|
|
353
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
354
|
+
if (!isRecord(attachmentRaw)) {
|
|
355
|
+
console.error("[Relay DO] Message from WebSocket without attachment");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const attachment = attachmentRaw;
|
|
359
|
+
const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION;
|
|
360
|
+
if (version === LEGACY_RELAY_VERSION) {
|
|
361
|
+
const targetRole = attachment.role === "server" ? "client" : "server";
|
|
362
|
+
const targets = this.state.getWebSockets(targetRole);
|
|
363
|
+
for (const target of targets) {
|
|
364
|
+
try {
|
|
365
|
+
target.send(message);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const role = getString(attachment, "role");
|
|
374
|
+
const connectionId = getString(attachment, "connectionId");
|
|
375
|
+
if (!connectionId) {
|
|
376
|
+
// Control channel: support simple app-level keepalive.
|
|
377
|
+
if (typeof message === "string") {
|
|
378
|
+
this.handleControlKeepalive(ws, message);
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (role === "client") {
|
|
383
|
+
const servers = this.state.getWebSockets(`server:${connectionId}`);
|
|
384
|
+
if (servers.length === 0) {
|
|
385
|
+
this.bufferFrame(connectionId, message);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
for (const target of servers) {
|
|
389
|
+
try {
|
|
390
|
+
target.send(message);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
console.error(`[Relay DO] Failed to forward client->server(${connectionId}):`, error);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// server data socket -> client
|
|
399
|
+
const targets = this.state.getWebSockets(`client:${connectionId}`);
|
|
400
|
+
for (const target of targets) {
|
|
401
|
+
try {
|
|
402
|
+
target.send(message);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
console.error(`[Relay DO] Failed to forward server->client(${connectionId}):`, error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Called when a WebSocket closes (wakes from hibernation).
|
|
411
|
+
*/
|
|
412
|
+
webSocketClose(ws, code, reason, _wasClean) {
|
|
413
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
414
|
+
if (!isRecord(attachmentRaw))
|
|
415
|
+
return;
|
|
416
|
+
const attachment = attachmentRaw;
|
|
417
|
+
const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION;
|
|
418
|
+
const role = getString(attachment, "role");
|
|
419
|
+
const connectionId = getString(attachment, "connectionId");
|
|
420
|
+
const serverId = getString(attachment, "serverId");
|
|
421
|
+
console.log(`[Relay DO] v${version}:${role ?? "unknown"}${connectionId ? `(${connectionId})` : ""} disconnected from session ${serverId ?? "unknown"} (${code}: ${reason})`);
|
|
422
|
+
if (version === LEGACY_RELAY_VERSION) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (role === "client" && connectionId) {
|
|
426
|
+
const remainingClientSockets = this.state
|
|
427
|
+
.getWebSockets(`client:${connectionId}`)
|
|
428
|
+
.some((socket) => socket !== ws);
|
|
429
|
+
if (remainingClientSockets) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
this.pendingFrames.delete(connectionId);
|
|
433
|
+
// Last socket for this session closed: now clean up matching server-data socket.
|
|
434
|
+
for (const serverWs of this.state.getWebSockets(`server:${connectionId}`)) {
|
|
435
|
+
try {
|
|
436
|
+
serverWs.close(1001, "Client disconnected");
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// ignore
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this.notifyControls({ type: "disconnected", connectionId });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (role === "server" && connectionId) {
|
|
446
|
+
// Force the client to reconnect and re-handshake when the daemon side drops.
|
|
447
|
+
for (const clientWs of this.state.getWebSockets(`client:${connectionId}`)) {
|
|
448
|
+
try {
|
|
449
|
+
clientWs.close(1012, "Server disconnected");
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// ignore
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Called on WebSocket error.
|
|
459
|
+
*/
|
|
460
|
+
webSocketError(ws, error) {
|
|
461
|
+
const attachmentRaw = deserializeAttachment(ws);
|
|
462
|
+
const attachment = isRecord(attachmentRaw) ? attachmentRaw : null;
|
|
463
|
+
const role = attachment ? getString(attachment, "role") : undefined;
|
|
464
|
+
console.error(`[Relay DO] WebSocket error for ${role ?? "unknown"}:`, error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Worker entry point that routes requests to the appropriate Durable Object.
|
|
469
|
+
*/
|
|
470
|
+
export default {
|
|
471
|
+
async fetch(request, env) {
|
|
472
|
+
const url = new URL(request.url);
|
|
473
|
+
// Health check
|
|
474
|
+
if (url.pathname === "/health") {
|
|
475
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
476
|
+
headers: { "Content-Type": "application/json" },
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
// Relay endpoint
|
|
480
|
+
if (url.pathname === "/ws") {
|
|
481
|
+
const serverId = url.searchParams.get("serverId");
|
|
482
|
+
if (!serverId) {
|
|
483
|
+
return new Response("Missing serverId parameter", { status: 400 });
|
|
484
|
+
}
|
|
485
|
+
const version = resolveRelayVersion(url.searchParams.get("v"));
|
|
486
|
+
if (!version) {
|
|
487
|
+
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
|
|
488
|
+
}
|
|
489
|
+
// Route to a version-isolated Durable Object instance.
|
|
490
|
+
const id = env.RELAY.idFromName(`relay-v${version}:${serverId}`);
|
|
491
|
+
const stub = env.RELAY.get(id);
|
|
492
|
+
const normalizedUrl = new URL(request.url);
|
|
493
|
+
normalizedUrl.searchParams.set("v", version);
|
|
494
|
+
const normalizedRequest = new Request(normalizedUrl.toString(), request);
|
|
495
|
+
return stub.fetch(normalizedRequest);
|
|
496
|
+
}
|
|
497
|
+
return new Response("Not found", { status: 404 });
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
//# sourceMappingURL=cloudflare-adapter.js.map
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE crypto primitives using NaCl (tweetnacl).
|
|
3
|
+
*
|
|
4
|
+
* - Key exchange: Curve25519 (nacl.box.before)
|
|
5
|
+
* - Encryption: XSalsa20-Poly1305 (nacl.box.after / open.after)
|
|
6
|
+
*
|
|
7
|
+
* Bundle format (binary):
|
|
8
|
+
* [nonce (24 bytes)] [ciphertext...]
|
|
9
|
+
*
|
|
10
|
+
* Transport format:
|
|
11
|
+
* The encrypted-channel sends the bundle as base64 text over WebSocket.
|
|
12
|
+
*/
|
|
13
|
+
export interface KeyPair {
|
|
14
|
+
publicKey: Uint8Array;
|
|
15
|
+
secretKey: Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
export type SharedKey = Uint8Array;
|
|
18
|
+
export declare function generateKeyPair(): KeyPair;
|
|
19
|
+
export declare function exportPublicKey(publicKey: Uint8Array): string;
|
|
20
|
+
export declare function importPublicKey(base64: string): Uint8Array;
|
|
21
|
+
export declare function exportSecretKey(secretKey: Uint8Array): string;
|
|
22
|
+
export declare function importSecretKey(base64: string): Uint8Array;
|
|
23
|
+
export declare function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Array): SharedKey;
|
|
24
|
+
/**
|
|
25
|
+
* Encrypts data and returns the binary bundle:
|
|
26
|
+
* [nonce (24)] [ciphertext...]
|
|
27
|
+
*/
|
|
28
|
+
export declare function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): ArrayBuffer;
|
|
29
|
+
export declare function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | ArrayBuffer;
|
|
30
|
+
//# sourceMappingURL=crypto.d.ts.map
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* E2EE crypto primitives using NaCl (tweetnacl).
|
|
4
|
+
*
|
|
5
|
+
* - Key exchange: Curve25519 (nacl.box.before)
|
|
6
|
+
* - Encryption: XSalsa20-Poly1305 (nacl.box.after / open.after)
|
|
7
|
+
*
|
|
8
|
+
* Bundle format (binary):
|
|
9
|
+
* [nonce (24 bytes)] [ciphertext...]
|
|
10
|
+
*
|
|
11
|
+
* Transport format:
|
|
12
|
+
* The encrypted-channel sends the bundle as base64 text over WebSocket.
|
|
13
|
+
*/
|
|
14
|
+
import nacl from "tweetnacl";
|
|
15
|
+
import { fromByteArray, toByteArray } from "base64-js";
|
|
16
|
+
const NONCE_LENGTH = nacl.box.nonceLength; // 24
|
|
17
|
+
let prngReady = false;
|
|
18
|
+
function getGlobalCrypto() {
|
|
19
|
+
const g = globalThis;
|
|
20
|
+
return g.crypto;
|
|
21
|
+
}
|
|
22
|
+
function ensurePrng() {
|
|
23
|
+
if (prngReady)
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
nacl.randomBytes(1);
|
|
27
|
+
prngReady = true;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// fallthrough
|
|
32
|
+
}
|
|
33
|
+
const cryptoObj = getGlobalCrypto();
|
|
34
|
+
if (cryptoObj?.getRandomValues) {
|
|
35
|
+
nacl.setPRNG((x, n) => {
|
|
36
|
+
const buf = new Uint8Array(n);
|
|
37
|
+
cryptoObj.getRandomValues(buf);
|
|
38
|
+
x.set(buf, 0);
|
|
39
|
+
});
|
|
40
|
+
prngReady = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw new Error("No secure PRNG available for tweetnacl (missing crypto.getRandomValues)");
|
|
44
|
+
}
|
|
45
|
+
function encodeBase64(bytes) {
|
|
46
|
+
return fromByteArray(bytes);
|
|
47
|
+
}
|
|
48
|
+
function decodeBase64(base64) {
|
|
49
|
+
return toByteArray(base64);
|
|
50
|
+
}
|
|
51
|
+
function toUint8(data) {
|
|
52
|
+
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
53
|
+
}
|
|
54
|
+
function toArrayBuffer(bytes) {
|
|
55
|
+
const out = new Uint8Array(bytes.byteLength);
|
|
56
|
+
out.set(bytes);
|
|
57
|
+
return out.buffer;
|
|
58
|
+
}
|
|
59
|
+
export function generateKeyPair() {
|
|
60
|
+
ensurePrng();
|
|
61
|
+
const { publicKey, secretKey } = nacl.box.keyPair();
|
|
62
|
+
return { publicKey, secretKey };
|
|
63
|
+
}
|
|
64
|
+
export function exportPublicKey(publicKey) {
|
|
65
|
+
if (!(publicKey instanceof Uint8Array) || publicKey.byteLength !== nacl.box.publicKeyLength) {
|
|
66
|
+
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
|
|
67
|
+
}
|
|
68
|
+
return encodeBase64(publicKey);
|
|
69
|
+
}
|
|
70
|
+
export function importPublicKey(base64) {
|
|
71
|
+
const bytes = decodeBase64(base64);
|
|
72
|
+
if (bytes.byteLength !== nacl.box.publicKeyLength) {
|
|
73
|
+
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
|
|
74
|
+
}
|
|
75
|
+
return bytes;
|
|
76
|
+
}
|
|
77
|
+
export function exportSecretKey(secretKey) {
|
|
78
|
+
if (!(secretKey instanceof Uint8Array) || secretKey.byteLength !== nacl.box.secretKeyLength) {
|
|
79
|
+
throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
|
|
80
|
+
}
|
|
81
|
+
return encodeBase64(secretKey);
|
|
82
|
+
}
|
|
83
|
+
export function importSecretKey(base64) {
|
|
84
|
+
const bytes = decodeBase64(base64);
|
|
85
|
+
if (bytes.byteLength !== nacl.box.secretKeyLength) {
|
|
86
|
+
throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
|
|
87
|
+
}
|
|
88
|
+
return bytes;
|
|
89
|
+
}
|
|
90
|
+
export function deriveSharedKey(ourSecretKey, peerPublicKey) {
|
|
91
|
+
if (ourSecretKey.byteLength !== nacl.box.secretKeyLength) {
|
|
92
|
+
throw new Error(`Invalid secret key length (expected ${nacl.box.secretKeyLength})`);
|
|
93
|
+
}
|
|
94
|
+
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
|
|
95
|
+
throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
|
|
96
|
+
}
|
|
97
|
+
return nacl.box.before(peerPublicKey, ourSecretKey);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Encrypts data and returns the binary bundle:
|
|
101
|
+
* [nonce (24)] [ciphertext...]
|
|
102
|
+
*/
|
|
103
|
+
export function encrypt(sharedKey, data) {
|
|
104
|
+
ensurePrng();
|
|
105
|
+
const nonce = nacl.randomBytes(NONCE_LENGTH);
|
|
106
|
+
const plaintext = toUint8(data);
|
|
107
|
+
const ciphertext = nacl.box.after(plaintext, nonce, sharedKey);
|
|
108
|
+
const out = new Uint8Array(nonce.byteLength + ciphertext.byteLength);
|
|
109
|
+
out.set(nonce, 0);
|
|
110
|
+
out.set(ciphertext, nonce.byteLength);
|
|
111
|
+
return toArrayBuffer(out);
|
|
112
|
+
}
|
|
113
|
+
export function decrypt(sharedKey, data) {
|
|
114
|
+
const bytes = new Uint8Array(data);
|
|
115
|
+
if (bytes.byteLength < NONCE_LENGTH) {
|
|
116
|
+
throw new Error("Ciphertext bundle too short");
|
|
117
|
+
}
|
|
118
|
+
const nonce = bytes.slice(0, NONCE_LENGTH);
|
|
119
|
+
const ciphertext = bytes.slice(NONCE_LENGTH);
|
|
120
|
+
const opened = nacl.box.open.after(ciphertext, nonce, sharedKey);
|
|
121
|
+
if (!opened) {
|
|
122
|
+
throw new Error("Decryption failed");
|
|
123
|
+
}
|
|
124
|
+
const plaintext = toArrayBuffer(opened);
|
|
125
|
+
try {
|
|
126
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return plaintext;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=crypto.js.map
|
package/dist/e2ee.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
|
|
2
|
+
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
|
|
3
|
+
export { generateKeyPair, exportPublicKey, importPublicKey, exportSecretKey, importSecretKey, } from "./crypto.js";
|
|
4
|
+
export type { KeyPair, SharedKey } from "./crypto.js";
|
|
5
|
+
//# sourceMappingURL=e2ee.d.ts.map
|
package/dist/e2ee.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted channel that wraps a WebSocket-like transport.
|
|
3
|
+
*
|
|
4
|
+
* Handles ECDH handshake and encrypts/decrypts all messages.
|
|
5
|
+
* Works identically for daemon and client sides.
|
|
6
|
+
*/
|
|
7
|
+
import { type KeyPair, type SharedKey } from "./crypto.js";
|
|
8
|
+
export interface Transport {
|
|
9
|
+
send(data: string | ArrayBuffer): void;
|
|
10
|
+
close(code?: number, reason?: string): void;
|
|
11
|
+
onmessage: ((data: string | ArrayBuffer) => void) | null;
|
|
12
|
+
onclose: ((code: number, reason: string) => void) | null;
|
|
13
|
+
onerror: ((error: Error) => void) | null;
|
|
14
|
+
}
|
|
15
|
+
export interface EncryptedChannelEvents {
|
|
16
|
+
onopen?: () => void;
|
|
17
|
+
onmessage?: (data: string | ArrayBuffer) => void;
|
|
18
|
+
onclose?: (code: number, reason: string) => void;
|
|
19
|
+
onerror?: (error: Error) => void;
|
|
20
|
+
}
|
|
21
|
+
type ChannelState = "connecting" | "handshaking" | "open" | "closed";
|
|
22
|
+
interface EncryptedChannelOptions {
|
|
23
|
+
/**
|
|
24
|
+
* If set, the channel can validate repeated plaintext `{type:"e2ee_hello"}`
|
|
25
|
+
* messages even after it is open.
|
|
26
|
+
*
|
|
27
|
+
* This is useful for robustness when the client retries the handshake
|
|
28
|
+
* (e.g., it didn't observe the daemon's `{type:"e2ee_ready"}` yet). In that case,
|
|
29
|
+
* the daemon should re-send `{type:"e2ee_ready"}` without changing keys.
|
|
30
|
+
*/
|
|
31
|
+
daemonKeyPair?: KeyPair;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Creates an encrypted channel as the initiator (client).
|
|
35
|
+
*
|
|
36
|
+
* The client:
|
|
37
|
+
* 1. Receives daemon's public key via QR code
|
|
38
|
+
* 2. Generates own keypair
|
|
39
|
+
* 3. Sends e2ee_hello with own public key
|
|
40
|
+
* 4. Derives shared key and starts encrypted communication
|
|
41
|
+
*/
|
|
42
|
+
export declare function createClientChannel(transport: Transport, daemonPublicKeyB64: string, events?: EncryptedChannelEvents): Promise<EncryptedChannel>;
|
|
43
|
+
/**
|
|
44
|
+
* Creates an encrypted channel as the responder (daemon).
|
|
45
|
+
*
|
|
46
|
+
* The daemon:
|
|
47
|
+
* 1. Has pre-generated keypair (public key was in QR)
|
|
48
|
+
* 2. Waits for client's e2ee_hello with their public key
|
|
49
|
+
* 3. Derives shared key and starts encrypted communication
|
|
50
|
+
*/
|
|
51
|
+
export declare function createDaemonChannel(transport: Transport, daemonKeyPair: KeyPair, events?: EncryptedChannelEvents): Promise<EncryptedChannel>;
|
|
52
|
+
/**
|
|
53
|
+
* Encrypted channel that wraps a transport with E2EE.
|
|
54
|
+
*/
|
|
55
|
+
export declare class EncryptedChannel {
|
|
56
|
+
private transport;
|
|
57
|
+
private sharedKey;
|
|
58
|
+
private state;
|
|
59
|
+
private events;
|
|
60
|
+
private options;
|
|
61
|
+
private pendingSends;
|
|
62
|
+
private onOpenCallbacks;
|
|
63
|
+
private onCloseCallbacks;
|
|
64
|
+
constructor(transport: Transport, sharedKey: SharedKey, events?: EncryptedChannelEvents, options?: EncryptedChannelOptions);
|
|
65
|
+
setState(state: ChannelState): void;
|
|
66
|
+
private handleMessage;
|
|
67
|
+
send(data: string | ArrayBuffer): Promise<void>;
|
|
68
|
+
private flushPendingSends;
|
|
69
|
+
private handleDaemonRehello;
|
|
70
|
+
close(code?: number, reason?: string): void;
|
|
71
|
+
isOpen(): boolean;
|
|
72
|
+
onTransitionToOpen(cb: () => void): void;
|
|
73
|
+
onClose(cb: () => void): void;
|
|
74
|
+
}
|
|
75
|
+
export {};
|
|
76
|
+
//# sourceMappingURL=encrypted-channel.d.ts.map
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Encrypted channel that wraps a WebSocket-like transport.
|
|
4
|
+
*
|
|
5
|
+
* Handles ECDH handshake and encrypts/decrypts all messages.
|
|
6
|
+
* Works identically for daemon and client sides.
|
|
7
|
+
*/
|
|
8
|
+
import { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
|
|
9
|
+
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function isE2EEHelloMessage(value) {
|
|
14
|
+
return (isRecord(value) &&
|
|
15
|
+
value.type === "e2ee_hello" &&
|
|
16
|
+
typeof value.key === "string" &&
|
|
17
|
+
value.key.trim().length > 0);
|
|
18
|
+
}
|
|
19
|
+
function isE2EEReadyMessage(value) {
|
|
20
|
+
return isRecord(value) && value.type === "e2ee_ready";
|
|
21
|
+
}
|
|
22
|
+
function buildInvalidHelloError(rawText, parsed) {
|
|
23
|
+
const parsedRecord = isRecord(parsed) ? parsed : null;
|
|
24
|
+
const rawType = parsedRecord?.type;
|
|
25
|
+
function describeType(value) {
|
|
26
|
+
if (typeof value === "string")
|
|
27
|
+
return value;
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return "undefined";
|
|
30
|
+
return typeof value;
|
|
31
|
+
}
|
|
32
|
+
const receivedType = describeType(rawType);
|
|
33
|
+
const hasKey = typeof parsedRecord?.key === "string" && parsedRecord.key.trim().length > 0;
|
|
34
|
+
const compact = rawText.replace(/\s+/g, " ").trim();
|
|
35
|
+
const preview = compact.length > 160 ? `${compact.slice(0, 157)}...` : compact;
|
|
36
|
+
return new Error(`Invalid hello message (receivedType=${receivedType}, hasKey=${hasKey}, preview=${JSON.stringify(preview)})`);
|
|
37
|
+
}
|
|
38
|
+
const HANDSHAKE_RETRY_MS = 1000;
|
|
39
|
+
const MAX_PENDING_SENDS = 200;
|
|
40
|
+
const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008;
|
|
41
|
+
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
|
|
42
|
+
function hasUnref(timeout) {
|
|
43
|
+
return (typeof timeout === "object" &&
|
|
44
|
+
timeout !== null &&
|
|
45
|
+
"unref" in timeout &&
|
|
46
|
+
typeof timeout.unref === "function");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Creates an encrypted channel as the initiator (client).
|
|
50
|
+
*
|
|
51
|
+
* The client:
|
|
52
|
+
* 1. Receives daemon's public key via QR code
|
|
53
|
+
* 2. Generates own keypair
|
|
54
|
+
* 3. Sends e2ee_hello with own public key
|
|
55
|
+
* 4. Derives shared key and starts encrypted communication
|
|
56
|
+
*/
|
|
57
|
+
export async function createClientChannel(transport, daemonPublicKeyB64, events = {}) {
|
|
58
|
+
const keyPair = generateKeyPair();
|
|
59
|
+
const daemonPublicKey = importPublicKey(daemonPublicKeyB64);
|
|
60
|
+
const sharedKey = deriveSharedKey(keyPair.secretKey, daemonPublicKey);
|
|
61
|
+
const channel = new EncryptedChannel(transport, sharedKey, events);
|
|
62
|
+
// Send e2ee_hello with our public key
|
|
63
|
+
const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
|
|
64
|
+
const hello = { type: "e2ee_hello", key: ourPublicKeyB64 };
|
|
65
|
+
const helloText = JSON.stringify(hello);
|
|
66
|
+
let retry = null;
|
|
67
|
+
const emitSendError = (error) => {
|
|
68
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
69
|
+
events.onerror?.(err);
|
|
70
|
+
};
|
|
71
|
+
const sendHello = () => {
|
|
72
|
+
try {
|
|
73
|
+
transport.send(helloText);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// This can happen during daemon restarts while the socket transitions
|
|
78
|
+
// through CLOSING/CLOSED states. Report it but do not throw from timers.
|
|
79
|
+
emitSendError(error);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const clearRetry = () => {
|
|
84
|
+
if (retry) {
|
|
85
|
+
clearInterval(retry);
|
|
86
|
+
retry = null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
channel.onTransitionToOpen(() => clearRetry());
|
|
90
|
+
channel.onClose(() => clearRetry());
|
|
91
|
+
sendHello();
|
|
92
|
+
retry = setInterval(() => {
|
|
93
|
+
if (channel.isOpen()) {
|
|
94
|
+
clearRetry();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
sendHello();
|
|
98
|
+
}, HANDSHAKE_RETRY_MS);
|
|
99
|
+
// Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
|
|
100
|
+
if (hasUnref(retry)) {
|
|
101
|
+
retry.unref();
|
|
102
|
+
}
|
|
103
|
+
return channel;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Creates an encrypted channel as the responder (daemon).
|
|
107
|
+
*
|
|
108
|
+
* The daemon:
|
|
109
|
+
* 1. Has pre-generated keypair (public key was in QR)
|
|
110
|
+
* 2. Waits for client's e2ee_hello with their public key
|
|
111
|
+
* 3. Derives shared key and starts encrypted communication
|
|
112
|
+
*/
|
|
113
|
+
export async function createDaemonChannel(transport, daemonKeyPair, events = {}) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const bufferedMessages = [];
|
|
116
|
+
const shouldIgnorePostHelloPlaintext = (data) => {
|
|
117
|
+
try {
|
|
118
|
+
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
119
|
+
const parsed = JSON.parse(text);
|
|
120
|
+
return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const handleHello = async (data) => {
|
|
127
|
+
try {
|
|
128
|
+
const helloText = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(helloText);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw buildInvalidHelloError(helloText);
|
|
135
|
+
}
|
|
136
|
+
if (!isE2EEHelloMessage(parsed)) {
|
|
137
|
+
throw buildInvalidHelloError(helloText, parsed);
|
|
138
|
+
}
|
|
139
|
+
const msg = parsed;
|
|
140
|
+
// Buffer any subsequent messages that arrive while we're doing async
|
|
141
|
+
// WebCrypto work to derive the shared key. Without this, it's possible
|
|
142
|
+
// for the next message (already encrypted) to be misinterpreted as a
|
|
143
|
+
// second hello, causing the handshake to fail.
|
|
144
|
+
const bufferNext = (next) => {
|
|
145
|
+
bufferedMessages.push(next);
|
|
146
|
+
};
|
|
147
|
+
Object.assign(transport, { onmessage: bufferNext });
|
|
148
|
+
const clientPublicKey = importPublicKey(msg.key);
|
|
149
|
+
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
|
|
150
|
+
const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair });
|
|
151
|
+
transport.send(JSON.stringify({ type: "e2ee_ready" }));
|
|
152
|
+
channel.setState("open");
|
|
153
|
+
events.onopen?.();
|
|
154
|
+
for (const buffered of bufferedMessages) {
|
|
155
|
+
if (shouldIgnorePostHelloPlaintext(buffered))
|
|
156
|
+
continue;
|
|
157
|
+
transport.onmessage?.(buffered);
|
|
158
|
+
}
|
|
159
|
+
resolve(channel);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
reject(error);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
Object.assign(transport, {
|
|
166
|
+
onmessage: handleHello,
|
|
167
|
+
onerror: (error) => {
|
|
168
|
+
reject(error);
|
|
169
|
+
},
|
|
170
|
+
onclose: (code, reason) => {
|
|
171
|
+
reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Encrypted channel that wraps a transport with E2EE.
|
|
178
|
+
*/
|
|
179
|
+
export class EncryptedChannel {
|
|
180
|
+
constructor(transport, sharedKey, events = {}, options = {}) {
|
|
181
|
+
this.state = "handshaking";
|
|
182
|
+
this.pendingSends = [];
|
|
183
|
+
this.onOpenCallbacks = [];
|
|
184
|
+
this.onCloseCallbacks = [];
|
|
185
|
+
this.transport = transport;
|
|
186
|
+
this.sharedKey = sharedKey;
|
|
187
|
+
this.events = events;
|
|
188
|
+
this.options = options;
|
|
189
|
+
Object.assign(transport, {
|
|
190
|
+
onmessage: (data) => this.handleMessage(data),
|
|
191
|
+
onclose: (code, reason) => {
|
|
192
|
+
this.state = "closed";
|
|
193
|
+
this.events.onclose?.(code, reason);
|
|
194
|
+
for (const cb of this.onCloseCallbacks)
|
|
195
|
+
cb();
|
|
196
|
+
},
|
|
197
|
+
onerror: (error) => {
|
|
198
|
+
this.events.onerror?.(error);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
setState(state) {
|
|
203
|
+
this.state = state;
|
|
204
|
+
}
|
|
205
|
+
async handleMessage(data) {
|
|
206
|
+
if (this.state === "handshaking") {
|
|
207
|
+
try {
|
|
208
|
+
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
209
|
+
const parsed = JSON.parse(text);
|
|
210
|
+
if (isE2EEReadyMessage(parsed)) {
|
|
211
|
+
this.state = "open";
|
|
212
|
+
this.events.onopen?.();
|
|
213
|
+
for (const cb of this.onOpenCallbacks)
|
|
214
|
+
cb();
|
|
215
|
+
await this.flushPendingSends();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// ignore non-ready handshake traffic
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (this.state !== "open")
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
const ciphertext = await (async () => {
|
|
227
|
+
// Handle (or ignore) any stray plaintext handshake traffic.
|
|
228
|
+
try {
|
|
229
|
+
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
230
|
+
if (text.trim().startsWith("{")) {
|
|
231
|
+
const parsed = JSON.parse(text);
|
|
232
|
+
if (isE2EEHelloMessage(parsed)) {
|
|
233
|
+
if (this.options.daemonKeyPair) {
|
|
234
|
+
await this.handleDaemonRehello(parsed.key);
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (isE2EEReadyMessage(parsed)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
// Any other JSON-looking payload is plaintext app traffic, which
|
|
242
|
+
// means the peer is not encrypting (or we are out of sync).
|
|
243
|
+
throw new Error("Received plaintext frame on encrypted channel");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// If we detected plaintext protocol mismatch, fail hard.
|
|
248
|
+
if (error instanceof Error && error.message.includes("plaintext frame")) {
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
// Otherwise ignore JSON parse/TextDecoder failures and fall back to
|
|
252
|
+
// decoding ciphertext below.
|
|
253
|
+
}
|
|
254
|
+
if (typeof data === "string") {
|
|
255
|
+
return base64ToArrayBuffer(data);
|
|
256
|
+
}
|
|
257
|
+
// Some WebSocket implementations deliver text frames as ArrayBuffer.
|
|
258
|
+
// Our protocol always transmits ciphertext as base64 text.
|
|
259
|
+
try {
|
|
260
|
+
const decoded = new TextDecoder().decode(data);
|
|
261
|
+
return base64ToArrayBuffer(decoded);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return data;
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
if (ciphertext) {
|
|
268
|
+
const plaintext = decrypt(this.sharedKey, ciphertext);
|
|
269
|
+
this.events.onmessage?.(plaintext);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
274
|
+
// Treat decryption/protocol errors as fatal so the peer can reconnect and
|
|
275
|
+
// re-handshake. Emitting an error event here can cause higher-level code
|
|
276
|
+
// to tear down the session without triggering a clean reconnect.
|
|
277
|
+
try {
|
|
278
|
+
this.transport.close(1011, err.message);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async send(data) {
|
|
286
|
+
if (this.state === "handshaking") {
|
|
287
|
+
if (this.pendingSends.length >= MAX_PENDING_SENDS) {
|
|
288
|
+
this.pendingSends.shift();
|
|
289
|
+
}
|
|
290
|
+
this.pendingSends.push(data);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (this.state !== "open") {
|
|
294
|
+
throw new Error("Channel not open");
|
|
295
|
+
}
|
|
296
|
+
const ciphertext = encrypt(this.sharedKey, data);
|
|
297
|
+
// Send as base64 for WebSocket text compatibility
|
|
298
|
+
this.transport.send(arrayBufferToBase64(ciphertext));
|
|
299
|
+
}
|
|
300
|
+
async flushPendingSends() {
|
|
301
|
+
if (this.state !== "open")
|
|
302
|
+
return;
|
|
303
|
+
const pending = this.pendingSends;
|
|
304
|
+
this.pendingSends = [];
|
|
305
|
+
for (const item of pending) {
|
|
306
|
+
await this.send(item);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async handleDaemonRehello(clientKeyB64) {
|
|
310
|
+
if (!this.options.daemonKeyPair)
|
|
311
|
+
return;
|
|
312
|
+
const clientPublicKey = importPublicKey(clientKeyB64);
|
|
313
|
+
const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
|
|
314
|
+
// If it's the same client key (handshake retry), re-send
|
|
315
|
+
// "ready" but do not re-key. Re-keying here would desync
|
|
316
|
+
// the channel and cause decrypt failures.
|
|
317
|
+
if (keysEqual(nextSharedKey, this.sharedKey)) {
|
|
318
|
+
this.transport.send(JSON.stringify({ type: "e2ee_ready" }));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// A different key on an already-open encrypted channel is not an
|
|
322
|
+
// authenticated reconnect. Close and require a fresh transport instead of
|
|
323
|
+
// allowing the relay to switch this channel to an attacker-chosen key.
|
|
324
|
+
this.state = "closed";
|
|
325
|
+
this.transport.close(REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE, REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON);
|
|
326
|
+
}
|
|
327
|
+
close(code = 1000, reason = "Normal closure") {
|
|
328
|
+
this.state = "closed";
|
|
329
|
+
this.transport.close(code, reason);
|
|
330
|
+
}
|
|
331
|
+
isOpen() {
|
|
332
|
+
return this.state === "open";
|
|
333
|
+
}
|
|
334
|
+
onTransitionToOpen(cb) {
|
|
335
|
+
this.onOpenCallbacks.push(cb);
|
|
336
|
+
}
|
|
337
|
+
onClose(cb) {
|
|
338
|
+
this.onCloseCallbacks.push(cb);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function keysEqual(a, b) {
|
|
342
|
+
if (a.byteLength !== b.byteLength)
|
|
343
|
+
return false;
|
|
344
|
+
let difference = 0;
|
|
345
|
+
for (let i = 0; i < a.byteLength; i += 1) {
|
|
346
|
+
difference |= a[i] ^ b[i];
|
|
347
|
+
}
|
|
348
|
+
return difference === 0;
|
|
349
|
+
}
|
|
350
|
+
//# sourceMappingURL=encrypted-channel.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
|
2
|
+
export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
|
|
3
|
+
export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
|
|
4
|
+
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay connection types and interfaces.
|
|
3
|
+
*
|
|
4
|
+
* The relay bridges two WebSocket connections:
|
|
5
|
+
* - Server (daemon): The Paseo server connecting to the relay
|
|
6
|
+
* - Client (app): The mobile/web app connecting to the relay
|
|
7
|
+
*
|
|
8
|
+
* Messages are forwarded bidirectionally without modification.
|
|
9
|
+
*/
|
|
10
|
+
export type ConnectionRole = "server" | "client";
|
|
11
|
+
export interface RelaySessionAttachment {
|
|
12
|
+
serverId: string;
|
|
13
|
+
role: ConnectionRole;
|
|
14
|
+
/**
|
|
15
|
+
* Relay protocol version carried by this socket.
|
|
16
|
+
* v1: single server/client socket pair
|
|
17
|
+
* v2: control + per-client data sockets
|
|
18
|
+
*/
|
|
19
|
+
version?: "1" | "2";
|
|
20
|
+
/**
|
|
21
|
+
* Unique id for the connection. Allows the daemon to create an
|
|
22
|
+
* independent socket + E2EE channel per connected connection.
|
|
23
|
+
*/
|
|
24
|
+
connectionId?: string | null;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay connection types and interfaces.
|
|
3
|
+
*
|
|
4
|
+
* The relay bridges two WebSocket connections:
|
|
5
|
+
* - Server (daemon): The Paseo server connecting to the relay
|
|
6
|
+
* - Client (app): The mobile/web app connecting to the relay
|
|
7
|
+
*
|
|
8
|
+
* Messages are forwarded bidirectionally without modification.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperdrive.bot/paseo-relay",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Paseo relay for bridging daemon and client connections",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"!dist/**/*.map"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"node": "./dist/index.js",
|
|
15
|
+
"import": "./src/index.ts",
|
|
16
|
+
"default": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"./e2ee": {
|
|
19
|
+
"types": "./dist/e2ee.d.ts",
|
|
20
|
+
"node": "./dist/e2ee.js",
|
|
21
|
+
"import": "./src/e2ee.ts",
|
|
22
|
+
"default": "./src/e2ee.ts"
|
|
23
|
+
},
|
|
24
|
+
"./cloudflare": {
|
|
25
|
+
"types": "./dist/cloudflare-adapter.d.ts",
|
|
26
|
+
"node": "./dist/cloudflare-adapter.js",
|
|
27
|
+
"import": "./src/cloudflare-adapter.ts",
|
|
28
|
+
"default": "./src/cloudflare-adapter.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"clean": "node ../../scripts/clean-package-dist.mjs",
|
|
36
|
+
"build": "tsc -p tsconfig.json --incremental false",
|
|
37
|
+
"build:clean": "npm run clean && npm run build",
|
|
38
|
+
"prepack": "npm run build:clean",
|
|
39
|
+
"typecheck": "tsgo --noEmit",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"base64-js": "^1.5.1",
|
|
45
|
+
"tweetnacl": "^1.0.3",
|
|
46
|
+
"ws": "^8.14.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20.9.0",
|
|
50
|
+
"@types/ws": "^8.5.8",
|
|
51
|
+
"typescript": "^5.2.2",
|
|
52
|
+
"vitest": "^4.1.6"
|
|
53
|
+
}
|
|
54
|
+
}
|