lunel-cli 0.1.86 → 0.1.88
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/index.js +15 -2
- package/dist/transport/protocol.d.ts +27 -5
- package/dist/transport/protocol.js +18 -3
- package/dist/transport/v2.d.ts +8 -4
- package/dist/transport/v2.js +120 -53
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -931,7 +931,13 @@ async function handleGitDiscard(payload) {
|
|
|
931
931
|
}
|
|
932
932
|
function emitAppEvent(msg) {
|
|
933
933
|
if (activeV2Transport) {
|
|
934
|
-
|
|
934
|
+
if (!activeV2Transport.isSecure()) {
|
|
935
|
+
if (DEBUG_MODE) {
|
|
936
|
+
console.error("[transport:v2] dropped event before secure session:", `${msg.ns}.${msg.action}`);
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
void activeV2Transport.sendEvent(msg).catch((error) => {
|
|
935
941
|
if (DEBUG_MODE)
|
|
936
942
|
console.error("[transport:v2] failed to send event:", error instanceof Error ? error.message : String(error));
|
|
937
943
|
});
|
|
@@ -2551,11 +2557,15 @@ async function connectWebSocketV2() {
|
|
|
2551
2557
|
if (!currentSessionPassword) {
|
|
2552
2558
|
throw new Error("missing password for websocket connect");
|
|
2553
2559
|
}
|
|
2560
|
+
if (!currentSessionCode) {
|
|
2561
|
+
throw new Error("missing session code for secure transport");
|
|
2562
|
+
}
|
|
2554
2563
|
console.log(`Connecting to gateway ${gatewayUrl}...`);
|
|
2555
2564
|
activeGatewayUrl = gatewayUrl;
|
|
2556
2565
|
const transport = new V2SessionTransport({
|
|
2557
2566
|
gatewayUrl,
|
|
2558
2567
|
password: currentSessionPassword,
|
|
2568
|
+
sessionCode: currentSessionCode,
|
|
2559
2569
|
role: "cli",
|
|
2560
2570
|
debugLog: DEBUG_MODE ? debugLog : undefined,
|
|
2561
2571
|
handlers: {
|
|
@@ -2564,7 +2574,6 @@ async function connectWebSocketV2() {
|
|
|
2564
2574
|
return;
|
|
2565
2575
|
if (message.type === "peer_connected") {
|
|
2566
2576
|
console.log("App connected!\n");
|
|
2567
|
-
startPortSync();
|
|
2568
2577
|
return;
|
|
2569
2578
|
}
|
|
2570
2579
|
if (message.type === "peer_disconnected") {
|
|
@@ -2593,6 +2602,9 @@ async function connectWebSocketV2() {
|
|
|
2593
2602
|
onProtocolResponse: async () => {
|
|
2594
2603
|
// CLI does not currently await app responses outside request/reply routing.
|
|
2595
2604
|
},
|
|
2605
|
+
onProtocolEvent: async (message) => {
|
|
2606
|
+
await processMessage(message);
|
|
2607
|
+
},
|
|
2596
2608
|
onClose: (reason) => {
|
|
2597
2609
|
if (shuttingDown)
|
|
2598
2610
|
return;
|
|
@@ -2609,6 +2621,7 @@ async function connectWebSocketV2() {
|
|
|
2609
2621
|
});
|
|
2610
2622
|
activeV2Transport = transport;
|
|
2611
2623
|
await transport.connect();
|
|
2624
|
+
startPortSync();
|
|
2612
2625
|
console.log("Connected to gateway (single secure session).\n");
|
|
2613
2626
|
}
|
|
2614
2627
|
async function handleConnectionDrop(reason) {
|
|
@@ -17,12 +17,18 @@ export interface Response {
|
|
|
17
17
|
message: string;
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
export interface EventMessage {
|
|
21
|
+
v: 1;
|
|
22
|
+
id: string;
|
|
23
|
+
ns: string;
|
|
24
|
+
action: string;
|
|
25
|
+
payload: Record<string, unknown>;
|
|
26
|
+
}
|
|
20
27
|
export interface SystemMessage {
|
|
21
|
-
type: "connected" | "peer_connected" | "peer_disconnected" | "error" | "app_disconnected" | "close_connection"
|
|
28
|
+
type: "connected" | "peer_connected" | "peer_disconnected" | "error" | "app_disconnected" | "close_connection";
|
|
22
29
|
role?: string;
|
|
23
30
|
channel?: string;
|
|
24
31
|
peer?: string;
|
|
25
|
-
pubkey?: string;
|
|
26
32
|
reconnectDeadline?: number;
|
|
27
33
|
reason?: string;
|
|
28
34
|
payload?: Record<string, unknown>;
|
|
@@ -35,11 +41,26 @@ export type V2HandshakeFrame = {
|
|
|
35
41
|
t: "lunel_v2";
|
|
36
42
|
kind: "server_hello";
|
|
37
43
|
pubkey: string;
|
|
38
|
-
header: string;
|
|
39
44
|
} | {
|
|
40
45
|
t: "lunel_v2";
|
|
41
|
-
kind: "
|
|
42
|
-
|
|
46
|
+
kind: "client_key";
|
|
47
|
+
nonce: string;
|
|
48
|
+
box: string;
|
|
49
|
+
auth: string;
|
|
50
|
+
} | {
|
|
51
|
+
t: "lunel_v2";
|
|
52
|
+
kind: "server_ready";
|
|
53
|
+
auth: string;
|
|
54
|
+
};
|
|
55
|
+
export type EncryptedProtocolEnvelope = {
|
|
56
|
+
kind: "request";
|
|
57
|
+
message: Message;
|
|
58
|
+
} | {
|
|
59
|
+
kind: "response";
|
|
60
|
+
message: Response;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "event";
|
|
63
|
+
message: EventMessage;
|
|
43
64
|
};
|
|
44
65
|
export declare const V2_BINARY_MAGIC_0 = 76;
|
|
45
66
|
export declare const V2_BINARY_MAGIC_1 = 50;
|
|
@@ -47,6 +68,7 @@ export declare const V2_FRAME_ENCRYPTED_MESSAGE = 1;
|
|
|
47
68
|
export declare function isProtocolRequest(value: unknown): value is Message;
|
|
48
69
|
export declare function isProtocolResponse(value: unknown): value is Response;
|
|
49
70
|
export declare function isV2HandshakeFrame(value: unknown): value is V2HandshakeFrame;
|
|
71
|
+
export declare function isEncryptedProtocolEnvelope(value: unknown): value is EncryptedProtocolEnvelope;
|
|
50
72
|
export declare function encodeV2EncryptedFrame(payload: Uint8Array): Uint8Array;
|
|
51
73
|
export declare function decodeV2BinaryFrame(data: Uint8Array): {
|
|
52
74
|
type: number;
|
|
@@ -28,9 +28,24 @@ export function isV2HandshakeFrame(value) {
|
|
|
28
28
|
if (frame.kind === "client_hello")
|
|
29
29
|
return typeof frame.pubkey === "string";
|
|
30
30
|
if (frame.kind === "server_hello")
|
|
31
|
-
return typeof frame.pubkey === "string"
|
|
32
|
-
if (frame.kind === "
|
|
33
|
-
return typeof frame.
|
|
31
|
+
return typeof frame.pubkey === "string";
|
|
32
|
+
if (frame.kind === "client_key") {
|
|
33
|
+
return typeof frame.nonce === "string" && typeof frame.box === "string" && typeof frame.auth === "string";
|
|
34
|
+
}
|
|
35
|
+
if (frame.kind === "server_ready")
|
|
36
|
+
return typeof frame.auth === "string";
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export function isEncryptedProtocolEnvelope(value) {
|
|
40
|
+
if (!value || typeof value !== "object")
|
|
41
|
+
return false;
|
|
42
|
+
const envelope = value;
|
|
43
|
+
if (envelope.kind === "request")
|
|
44
|
+
return isProtocolRequest(envelope.message);
|
|
45
|
+
if (envelope.kind === "response")
|
|
46
|
+
return isProtocolResponse(envelope.message);
|
|
47
|
+
if (envelope.kind === "event")
|
|
48
|
+
return isProtocolRequest(envelope.message);
|
|
34
49
|
return false;
|
|
35
50
|
}
|
|
36
51
|
export function encodeV2EncryptedFrame(payload) {
|
package/dist/transport/v2.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type { Message, Response, SystemMessage } from "./protocol.js";
|
|
1
|
+
import type { EventMessage, Message, Response, SystemMessage } from "./protocol.js";
|
|
2
2
|
export interface V2TransportHandlers {
|
|
3
3
|
onSystemMessage: (message: SystemMessage) => Promise<void> | void;
|
|
4
4
|
onProtocolRequest: (message: Message) => Promise<Response>;
|
|
5
5
|
onProtocolResponse?: (message: Response) => Promise<void> | void;
|
|
6
|
+
onProtocolEvent?: (message: EventMessage) => Promise<void> | void;
|
|
6
7
|
onClose: (reason: string) => void;
|
|
7
8
|
}
|
|
8
9
|
export interface V2TransportOptions {
|
|
9
10
|
gatewayUrl: string;
|
|
10
11
|
password: string;
|
|
12
|
+
sessionCode: string;
|
|
11
13
|
role: "cli" | "app";
|
|
12
14
|
handlers: V2TransportHandlers;
|
|
13
15
|
debugLog?: (message: string, ...args: unknown[]) => void;
|
|
@@ -18,9 +20,8 @@ export declare class V2SessionTransport {
|
|
|
18
20
|
private closed;
|
|
19
21
|
private state;
|
|
20
22
|
private keyPair;
|
|
23
|
+
private remotePublicKey;
|
|
21
24
|
private sessionKeys;
|
|
22
|
-
private pushState;
|
|
23
|
-
private pullState;
|
|
24
25
|
private secureReadyResolve;
|
|
25
26
|
private secureReadyReject;
|
|
26
27
|
private secureReadyPromise;
|
|
@@ -28,12 +29,15 @@ export declare class V2SessionTransport {
|
|
|
28
29
|
connect(): Promise<void>;
|
|
29
30
|
sendMessage(message: Message): Promise<void>;
|
|
30
31
|
sendResponse(response: Response): Promise<void>;
|
|
32
|
+
sendEvent(message: EventMessage): Promise<void>;
|
|
31
33
|
close(): void;
|
|
34
|
+
isSecure(): boolean;
|
|
32
35
|
private handleMessage;
|
|
33
36
|
private maybeStartHandshake;
|
|
34
37
|
private handleHandshakeFrame;
|
|
35
|
-
private
|
|
38
|
+
private encryptEnvelope;
|
|
36
39
|
private ensureKeyPair;
|
|
40
|
+
private computeHandshakeAuth;
|
|
37
41
|
private sendJsonFrame;
|
|
38
42
|
private sendBinaryFrame;
|
|
39
43
|
private markSecure;
|
package/dist/transport/v2.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { WebSocket } from "ws";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
|
-
import { V2_FRAME_ENCRYPTED_MESSAGE, buildSessionV2WsUrl, decodeV2BinaryFrame, encodeV2EncryptedFrame, isProtocolRequest, isProtocolResponse, isV2HandshakeFrame, } from "./protocol.js";
|
|
3
|
+
import { V2_FRAME_ENCRYPTED_MESSAGE, buildSessionV2WsUrl, decodeV2BinaryFrame, encodeV2EncryptedFrame, isEncryptedProtocolEnvelope, isProtocolRequest, isProtocolResponse, isV2HandshakeFrame, } from "./protocol.js";
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
4
6
|
const require = createRequire(import.meta.url);
|
|
5
7
|
const sodium = require("libsodium-wrappers");
|
|
6
8
|
function toUint8Array(data) {
|
|
@@ -10,15 +12,20 @@ function toUint8Array(data) {
|
|
|
10
12
|
return new Uint8Array(Buffer.concat(data.map((chunk) => Buffer.from(chunk))));
|
|
11
13
|
return new Uint8Array(data);
|
|
12
14
|
}
|
|
15
|
+
function encodeUtf8(value) {
|
|
16
|
+
return encoder.encode(value);
|
|
17
|
+
}
|
|
18
|
+
function decodeUtf8(value) {
|
|
19
|
+
return decoder.decode(value);
|
|
20
|
+
}
|
|
13
21
|
export class V2SessionTransport {
|
|
14
22
|
options;
|
|
15
23
|
ws = null;
|
|
16
24
|
closed = false;
|
|
17
25
|
state = "idle";
|
|
18
26
|
keyPair = null;
|
|
27
|
+
remotePublicKey = null;
|
|
19
28
|
sessionKeys = null;
|
|
20
|
-
pushState = null;
|
|
21
|
-
pullState = null;
|
|
22
29
|
secureReadyResolve = null;
|
|
23
30
|
secureReadyReject = null;
|
|
24
31
|
secureReadyPromise = null;
|
|
@@ -49,9 +56,9 @@ export class V2SessionTransport {
|
|
|
49
56
|
this.state = "open";
|
|
50
57
|
resolve();
|
|
51
58
|
});
|
|
52
|
-
ws.on("message", async (data) => {
|
|
59
|
+
ws.on("message", async (data, isBinary) => {
|
|
53
60
|
try {
|
|
54
|
-
await this.handleMessage(data);
|
|
61
|
+
await this.handleMessage(data, isBinary);
|
|
55
62
|
}
|
|
56
63
|
catch (error) {
|
|
57
64
|
this.options.debugLog?.("[transport:v2] message handling failed", error);
|
|
@@ -86,11 +93,15 @@ export class V2SessionTransport {
|
|
|
86
93
|
await this.secureReadyPromise;
|
|
87
94
|
}
|
|
88
95
|
async sendMessage(message) {
|
|
89
|
-
const ciphertext =
|
|
96
|
+
const ciphertext = this.encryptEnvelope({ kind: "request", message });
|
|
90
97
|
this.sendBinaryFrame(ciphertext);
|
|
91
98
|
}
|
|
92
99
|
async sendResponse(response) {
|
|
93
|
-
const ciphertext =
|
|
100
|
+
const ciphertext = this.encryptEnvelope({ kind: "response", message: response });
|
|
101
|
+
this.sendBinaryFrame(ciphertext);
|
|
102
|
+
}
|
|
103
|
+
async sendEvent(message) {
|
|
104
|
+
const ciphertext = this.encryptEnvelope({ kind: "event", message });
|
|
94
105
|
this.sendBinaryFrame(ciphertext);
|
|
95
106
|
}
|
|
96
107
|
close() {
|
|
@@ -103,9 +114,13 @@ export class V2SessionTransport {
|
|
|
103
114
|
}
|
|
104
115
|
this.ws = null;
|
|
105
116
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
isSecure() {
|
|
118
|
+
return this.state === "secure";
|
|
119
|
+
}
|
|
120
|
+
async handleMessage(data, isBinary) {
|
|
121
|
+
if (!isBinary) {
|
|
122
|
+
const text = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
|
|
123
|
+
const raw = JSON.parse(text);
|
|
109
124
|
if ("type" in raw) {
|
|
110
125
|
await this.options.handlers.onSystemMessage(raw);
|
|
111
126
|
if (raw.type === "peer_connected") {
|
|
@@ -139,24 +154,33 @@ export class V2SessionTransport {
|
|
|
139
154
|
if (frame.type !== V2_FRAME_ENCRYPTED_MESSAGE) {
|
|
140
155
|
throw new Error(`unsupported v2 frame type ${frame.type}`);
|
|
141
156
|
}
|
|
142
|
-
if (this.state !== "secure" || !this.
|
|
157
|
+
if (this.state !== "secure" || !this.sessionKeys) {
|
|
143
158
|
throw new Error("received encrypted frame before secure transport");
|
|
144
159
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
160
|
+
if (frame.payload.length < sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) {
|
|
161
|
+
throw new Error("encrypted frame missing nonce");
|
|
162
|
+
}
|
|
163
|
+
const nonce = frame.payload.subarray(0, sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
164
|
+
const ciphertext = frame.payload.subarray(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
165
|
+
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, this.sessionKeys.rx);
|
|
166
|
+
const parsed = JSON.parse(decodeUtf8(plaintext));
|
|
167
|
+
if (!isEncryptedProtocolEnvelope(parsed)) {
|
|
168
|
+
throw new Error("invalid decrypted protocol envelope");
|
|
148
169
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
await this.options.handlers.onProtocolResponse?.(parsed);
|
|
170
|
+
if (parsed.kind === "response") {
|
|
171
|
+
await this.options.handlers.onProtocolResponse?.(parsed.message);
|
|
152
172
|
return;
|
|
153
173
|
}
|
|
154
|
-
if (
|
|
155
|
-
|
|
174
|
+
if (parsed.kind === "event") {
|
|
175
|
+
await this.options.handlers.onProtocolEvent?.(parsed.message);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (parsed.kind === "request") {
|
|
179
|
+
const response = await this.options.handlers.onProtocolRequest(parsed.message);
|
|
156
180
|
await this.sendResponse(response);
|
|
157
181
|
return;
|
|
158
182
|
}
|
|
159
|
-
throw new Error("invalid decrypted protocol
|
|
183
|
+
throw new Error("invalid decrypted protocol envelope");
|
|
160
184
|
}
|
|
161
185
|
async maybeStartHandshake() {
|
|
162
186
|
if (this.state === "secure" || this.state === "handshaking")
|
|
@@ -178,72 +202,115 @@ export class V2SessionTransport {
|
|
|
178
202
|
if (this.options.role !== "cli") {
|
|
179
203
|
throw new Error("unexpected client_hello on app transport");
|
|
180
204
|
}
|
|
181
|
-
|
|
205
|
+
this.remotePublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
182
206
|
const keyPair = this.ensureKeyPair();
|
|
183
|
-
|
|
184
|
-
this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
|
|
185
|
-
const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
|
|
186
|
-
this.pushState = pushInit.state;
|
|
187
|
-
const response = {
|
|
207
|
+
this.sendJsonFrame({
|
|
188
208
|
t: "lunel_v2",
|
|
189
209
|
kind: "server_hello",
|
|
190
210
|
pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
191
|
-
|
|
192
|
-
};
|
|
193
|
-
this.sendJsonFrame(response);
|
|
211
|
+
});
|
|
194
212
|
return;
|
|
195
213
|
}
|
|
196
214
|
if (frame.kind === "server_hello") {
|
|
197
215
|
if (this.options.role !== "app") {
|
|
198
216
|
throw new Error("unexpected server_hello on cli transport");
|
|
199
217
|
}
|
|
200
|
-
|
|
201
|
-
const serverHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
218
|
+
this.remotePublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
202
219
|
const keyPair = this.ensureKeyPair();
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
const c2sKey = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
|
221
|
+
const s2cKey = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
|
222
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
|
223
|
+
const payload = {
|
|
224
|
+
c2s: sodium.to_base64(c2sKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
225
|
+
s2c: sodium.to_base64(s2cKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
226
|
+
};
|
|
227
|
+
const boxed = sodium.crypto_box_easy(encodeUtf8(JSON.stringify(payload)), nonce, this.remotePublicKey, keyPair.privateKey);
|
|
228
|
+
const auth = this.computeHandshakeAuth("client_key", "app", frame.pubkey, nonce, boxed);
|
|
229
|
+
this.sessionKeys = { rx: s2cKey, tx: c2sKey };
|
|
230
|
+
this.sendJsonFrame({
|
|
209
231
|
t: "lunel_v2",
|
|
210
|
-
kind: "
|
|
211
|
-
|
|
232
|
+
kind: "client_key",
|
|
233
|
+
nonce: sodium.to_base64(nonce, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
234
|
+
box: sodium.to_base64(boxed, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
235
|
+
auth,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (frame.kind === "client_key") {
|
|
240
|
+
if (this.options.role !== "cli") {
|
|
241
|
+
throw new Error("unexpected client_key on app transport");
|
|
242
|
+
}
|
|
243
|
+
if (!this.remotePublicKey) {
|
|
244
|
+
throw new Error("missing client public key before client_key");
|
|
245
|
+
}
|
|
246
|
+
const keyPair = this.ensureKeyPair();
|
|
247
|
+
const nonce = sodium.from_base64(frame.nonce, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
248
|
+
const boxed = sodium.from_base64(frame.box, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
249
|
+
const expectedAuth = this.computeHandshakeAuth("client_key", "app", sodium.to_base64(this.remotePublicKey, sodium.base64_variants.URLSAFE_NO_PADDING), nonce, boxed);
|
|
250
|
+
if (frame.auth !== expectedAuth) {
|
|
251
|
+
throw new Error("client_key authentication failed");
|
|
252
|
+
}
|
|
253
|
+
const opened = sodium.crypto_box_open_easy(boxed, nonce, this.remotePublicKey, keyPair.privateKey);
|
|
254
|
+
const payload = JSON.parse(decodeUtf8(opened));
|
|
255
|
+
this.sessionKeys = {
|
|
256
|
+
rx: sodium.from_base64(payload.c2s, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
257
|
+
tx: sodium.from_base64(payload.s2c, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
212
258
|
};
|
|
213
|
-
this.sendJsonFrame(
|
|
259
|
+
this.sendJsonFrame({
|
|
260
|
+
t: "lunel_v2",
|
|
261
|
+
kind: "server_ready",
|
|
262
|
+
auth: this.computeHandshakeAuth("server_ready", "cli", sodium.to_base64(this.remotePublicKey, sodium.base64_variants.URLSAFE_NO_PADDING)),
|
|
263
|
+
});
|
|
214
264
|
this.markSecure();
|
|
215
265
|
return;
|
|
216
266
|
}
|
|
217
|
-
if (frame.kind === "
|
|
218
|
-
if (this.options.role !== "
|
|
219
|
-
throw new Error("unexpected
|
|
267
|
+
if (frame.kind === "server_ready") {
|
|
268
|
+
if (this.options.role !== "app") {
|
|
269
|
+
throw new Error("unexpected server_ready on cli transport");
|
|
220
270
|
}
|
|
221
271
|
if (!this.sessionKeys) {
|
|
222
|
-
throw new Error("missing session keys before
|
|
272
|
+
throw new Error("missing session keys before server_ready");
|
|
273
|
+
}
|
|
274
|
+
const expectedAuth = this.computeHandshakeAuth("server_ready", "cli", sodium.to_base64(this.remotePublicKey, sodium.base64_variants.URLSAFE_NO_PADDING));
|
|
275
|
+
if (frame.auth !== expectedAuth) {
|
|
276
|
+
throw new Error("server_ready authentication failed");
|
|
223
277
|
}
|
|
224
|
-
const clientHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
225
|
-
this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(clientHeader, this.sessionKeys.rx);
|
|
226
278
|
this.markSecure();
|
|
227
279
|
}
|
|
228
280
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (this.state !== "secure" || !this.pushState) {
|
|
281
|
+
encryptEnvelope(envelope) {
|
|
282
|
+
if (this.state !== "secure" || !this.sessionKeys) {
|
|
232
283
|
throw new Error("secure transport is not active");
|
|
233
284
|
}
|
|
234
|
-
const
|
|
235
|
-
|
|
285
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
286
|
+
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(encodeUtf8(JSON.stringify(envelope)), null, null, nonce, this.sessionKeys.tx);
|
|
287
|
+
const payload = new Uint8Array(nonce.length + ciphertext.length);
|
|
288
|
+
payload.set(nonce, 0);
|
|
289
|
+
payload.set(ciphertext, nonce.length);
|
|
290
|
+
return payload;
|
|
236
291
|
}
|
|
237
292
|
ensureKeyPair() {
|
|
238
293
|
if (this.keyPair)
|
|
239
294
|
return this.keyPair;
|
|
240
|
-
const pair = sodium.
|
|
295
|
+
const pair = sodium.crypto_box_keypair();
|
|
241
296
|
this.keyPair = {
|
|
242
297
|
publicKey: pair.publicKey,
|
|
243
298
|
privateKey: pair.privateKey,
|
|
244
299
|
};
|
|
245
300
|
return this.keyPair;
|
|
246
301
|
}
|
|
302
|
+
computeHandshakeAuth(phase, senderRole, peerPubkeyB64, nonce, boxed) {
|
|
303
|
+
const authKey = sodium.crypto_generichash(sodium.crypto_auth_KEYBYTES, encodeUtf8(this.options.sessionCode), undefined);
|
|
304
|
+
const parts = [
|
|
305
|
+
phase,
|
|
306
|
+
senderRole,
|
|
307
|
+
peerPubkeyB64,
|
|
308
|
+
nonce ? sodium.to_base64(nonce, sodium.base64_variants.URLSAFE_NO_PADDING) : "",
|
|
309
|
+
boxed ? sodium.to_base64(boxed, sodium.base64_variants.URLSAFE_NO_PADDING) : "",
|
|
310
|
+
];
|
|
311
|
+
const tag = sodium.crypto_auth(parts.join(":"), authKey);
|
|
312
|
+
return sodium.to_base64(tag, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
313
|
+
}
|
|
247
314
|
sendJsonFrame(frame) {
|
|
248
315
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
249
316
|
throw new Error("v2 transport is not connected");
|
|
@@ -255,7 +322,7 @@ export class V2SessionTransport {
|
|
|
255
322
|
throw new Error("v2 transport is not connected");
|
|
256
323
|
}
|
|
257
324
|
const framed = encodeV2EncryptedFrame(ciphertext);
|
|
258
|
-
this.ws.send(
|
|
325
|
+
this.ws.send(framed);
|
|
259
326
|
}
|
|
260
327
|
markSecure() {
|
|
261
328
|
this.state = "secure";
|