lunel-cli 0.1.87 → 0.1.89
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 +117 -61
- 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,
|
|
3
|
+
import { V2_FRAME_ENCRYPTED_MESSAGE, buildSessionV2WsUrl, decodeV2BinaryFrame, encodeV2EncryptedFrame, isEncryptedProtocolEnvelope, 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;
|
|
@@ -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,6 +114,9 @@ export class V2SessionTransport {
|
|
|
103
114
|
}
|
|
104
115
|
this.ws = null;
|
|
105
116
|
}
|
|
117
|
+
isSecure() {
|
|
118
|
+
return this.state === "secure";
|
|
119
|
+
}
|
|
106
120
|
async handleMessage(data, isBinary) {
|
|
107
121
|
if (!isBinary) {
|
|
108
122
|
const text = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
|
|
@@ -118,19 +132,9 @@ export class V2SessionTransport {
|
|
|
118
132
|
await this.handleHandshakeFrame(raw);
|
|
119
133
|
return;
|
|
120
134
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (isProtocolResponse(raw)) {
|
|
125
|
-
await this.options.handlers.onProtocolResponse?.(raw);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (isProtocolRequest(raw)) {
|
|
129
|
-
const response = await this.options.handlers.onProtocolRequest(raw);
|
|
130
|
-
await this.sendResponse(response);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
return;
|
|
135
|
+
throw new Error(this.state === "secure"
|
|
136
|
+
? "received plaintext app message after secure transport"
|
|
137
|
+
: "received plaintext app message before secure transport");
|
|
134
138
|
}
|
|
135
139
|
const bytes = toUint8Array(data);
|
|
136
140
|
const frame = decodeV2BinaryFrame(bytes);
|
|
@@ -140,24 +144,33 @@ export class V2SessionTransport {
|
|
|
140
144
|
if (frame.type !== V2_FRAME_ENCRYPTED_MESSAGE) {
|
|
141
145
|
throw new Error(`unsupported v2 frame type ${frame.type}`);
|
|
142
146
|
}
|
|
143
|
-
if (this.state !== "secure" || !this.
|
|
147
|
+
if (this.state !== "secure" || !this.sessionKeys) {
|
|
144
148
|
throw new Error("received encrypted frame before secure transport");
|
|
145
149
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
if (frame.payload.length < sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) {
|
|
151
|
+
throw new Error("encrypted frame missing nonce");
|
|
152
|
+
}
|
|
153
|
+
const nonce = frame.payload.subarray(0, sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
154
|
+
const ciphertext = frame.payload.subarray(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
155
|
+
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, this.sessionKeys.rx);
|
|
156
|
+
const parsed = JSON.parse(decodeUtf8(plaintext));
|
|
157
|
+
if (!isEncryptedProtocolEnvelope(parsed)) {
|
|
158
|
+
throw new Error("invalid decrypted protocol envelope");
|
|
149
159
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
await this.options.handlers.onProtocolResponse?.(parsed);
|
|
160
|
+
if (parsed.kind === "response") {
|
|
161
|
+
await this.options.handlers.onProtocolResponse?.(parsed.message);
|
|
153
162
|
return;
|
|
154
163
|
}
|
|
155
|
-
if (
|
|
156
|
-
|
|
164
|
+
if (parsed.kind === "event") {
|
|
165
|
+
await this.options.handlers.onProtocolEvent?.(parsed.message);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (parsed.kind === "request") {
|
|
169
|
+
const response = await this.options.handlers.onProtocolRequest(parsed.message);
|
|
157
170
|
await this.sendResponse(response);
|
|
158
171
|
return;
|
|
159
172
|
}
|
|
160
|
-
throw new Error("invalid decrypted protocol
|
|
173
|
+
throw new Error("invalid decrypted protocol envelope");
|
|
161
174
|
}
|
|
162
175
|
async maybeStartHandshake() {
|
|
163
176
|
if (this.state === "secure" || this.state === "handshaking")
|
|
@@ -179,72 +192,115 @@ export class V2SessionTransport {
|
|
|
179
192
|
if (this.options.role !== "cli") {
|
|
180
193
|
throw new Error("unexpected client_hello on app transport");
|
|
181
194
|
}
|
|
182
|
-
|
|
195
|
+
this.remotePublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
183
196
|
const keyPair = this.ensureKeyPair();
|
|
184
|
-
|
|
185
|
-
this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
|
|
186
|
-
const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
|
|
187
|
-
this.pushState = pushInit.state;
|
|
188
|
-
const response = {
|
|
197
|
+
this.sendJsonFrame({
|
|
189
198
|
t: "lunel_v2",
|
|
190
199
|
kind: "server_hello",
|
|
191
200
|
pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
192
|
-
|
|
193
|
-
};
|
|
194
|
-
this.sendJsonFrame(response);
|
|
201
|
+
});
|
|
195
202
|
return;
|
|
196
203
|
}
|
|
197
204
|
if (frame.kind === "server_hello") {
|
|
198
205
|
if (this.options.role !== "app") {
|
|
199
206
|
throw new Error("unexpected server_hello on cli transport");
|
|
200
207
|
}
|
|
201
|
-
|
|
202
|
-
const serverHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
208
|
+
this.remotePublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
203
209
|
const keyPair = this.ensureKeyPair();
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
const c2sKey = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
|
211
|
+
const s2cKey = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
|
212
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
|
213
|
+
const payload = {
|
|
214
|
+
c2s: sodium.to_base64(c2sKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
215
|
+
s2c: sodium.to_base64(s2cKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
216
|
+
};
|
|
217
|
+
const boxed = sodium.crypto_box_easy(encodeUtf8(JSON.stringify(payload)), nonce, this.remotePublicKey, keyPair.privateKey);
|
|
218
|
+
const auth = this.computeHandshakeAuth("client_key", "app", sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING), nonce, boxed);
|
|
219
|
+
this.sessionKeys = { rx: s2cKey, tx: c2sKey };
|
|
220
|
+
this.sendJsonFrame({
|
|
210
221
|
t: "lunel_v2",
|
|
211
|
-
kind: "
|
|
212
|
-
|
|
222
|
+
kind: "client_key",
|
|
223
|
+
nonce: sodium.to_base64(nonce, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
224
|
+
box: sodium.to_base64(boxed, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
225
|
+
auth,
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (frame.kind === "client_key") {
|
|
230
|
+
if (this.options.role !== "cli") {
|
|
231
|
+
throw new Error("unexpected client_key on app transport");
|
|
232
|
+
}
|
|
233
|
+
if (!this.remotePublicKey) {
|
|
234
|
+
throw new Error("missing client public key before client_key");
|
|
235
|
+
}
|
|
236
|
+
const keyPair = this.ensureKeyPair();
|
|
237
|
+
const nonce = sodium.from_base64(frame.nonce, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
238
|
+
const boxed = sodium.from_base64(frame.box, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
239
|
+
const expectedAuth = this.computeHandshakeAuth("client_key", "app", sodium.to_base64(this.remotePublicKey, sodium.base64_variants.URLSAFE_NO_PADDING), nonce, boxed);
|
|
240
|
+
if (frame.auth !== expectedAuth) {
|
|
241
|
+
throw new Error("client_key authentication failed");
|
|
242
|
+
}
|
|
243
|
+
const opened = sodium.crypto_box_open_easy(boxed, nonce, this.remotePublicKey, keyPair.privateKey);
|
|
244
|
+
const payload = JSON.parse(decodeUtf8(opened));
|
|
245
|
+
this.sessionKeys = {
|
|
246
|
+
rx: sodium.from_base64(payload.c2s, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
247
|
+
tx: sodium.from_base64(payload.s2c, sodium.base64_variants.URLSAFE_NO_PADDING),
|
|
213
248
|
};
|
|
214
|
-
this.sendJsonFrame(
|
|
249
|
+
this.sendJsonFrame({
|
|
250
|
+
t: "lunel_v2",
|
|
251
|
+
kind: "server_ready",
|
|
252
|
+
auth: this.computeHandshakeAuth("server_ready", "cli", sodium.to_base64(this.remotePublicKey, sodium.base64_variants.URLSAFE_NO_PADDING)),
|
|
253
|
+
});
|
|
215
254
|
this.markSecure();
|
|
216
255
|
return;
|
|
217
256
|
}
|
|
218
|
-
if (frame.kind === "
|
|
219
|
-
if (this.options.role !== "
|
|
220
|
-
throw new Error("unexpected
|
|
257
|
+
if (frame.kind === "server_ready") {
|
|
258
|
+
if (this.options.role !== "app") {
|
|
259
|
+
throw new Error("unexpected server_ready on cli transport");
|
|
221
260
|
}
|
|
222
261
|
if (!this.sessionKeys) {
|
|
223
|
-
throw new Error("missing session keys before
|
|
262
|
+
throw new Error("missing session keys before server_ready");
|
|
263
|
+
}
|
|
264
|
+
const expectedAuth = this.computeHandshakeAuth("server_ready", "cli", sodium.to_base64(this.remotePublicKey, sodium.base64_variants.URLSAFE_NO_PADDING));
|
|
265
|
+
if (frame.auth !== expectedAuth) {
|
|
266
|
+
throw new Error("server_ready authentication failed");
|
|
224
267
|
}
|
|
225
|
-
const clientHeader = sodium.from_base64(frame.header, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
226
|
-
this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(clientHeader, this.sessionKeys.rx);
|
|
227
268
|
this.markSecure();
|
|
228
269
|
}
|
|
229
270
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (this.state !== "secure" || !this.pushState) {
|
|
271
|
+
encryptEnvelope(envelope) {
|
|
272
|
+
if (this.state !== "secure" || !this.sessionKeys) {
|
|
233
273
|
throw new Error("secure transport is not active");
|
|
234
274
|
}
|
|
235
|
-
const
|
|
236
|
-
|
|
275
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
276
|
+
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(encodeUtf8(JSON.stringify(envelope)), null, null, nonce, this.sessionKeys.tx);
|
|
277
|
+
const payload = new Uint8Array(nonce.length + ciphertext.length);
|
|
278
|
+
payload.set(nonce, 0);
|
|
279
|
+
payload.set(ciphertext, nonce.length);
|
|
280
|
+
return payload;
|
|
237
281
|
}
|
|
238
282
|
ensureKeyPair() {
|
|
239
283
|
if (this.keyPair)
|
|
240
284
|
return this.keyPair;
|
|
241
|
-
const pair = sodium.
|
|
285
|
+
const pair = sodium.crypto_box_keypair();
|
|
242
286
|
this.keyPair = {
|
|
243
287
|
publicKey: pair.publicKey,
|
|
244
288
|
privateKey: pair.privateKey,
|
|
245
289
|
};
|
|
246
290
|
return this.keyPair;
|
|
247
291
|
}
|
|
292
|
+
computeHandshakeAuth(phase, senderRole, peerPubkeyB64, nonce, boxed) {
|
|
293
|
+
const authKey = sodium.crypto_generichash(sodium.crypto_auth_KEYBYTES, encodeUtf8(this.options.sessionCode), undefined);
|
|
294
|
+
const parts = [
|
|
295
|
+
phase,
|
|
296
|
+
senderRole,
|
|
297
|
+
peerPubkeyB64,
|
|
298
|
+
nonce ? sodium.to_base64(nonce, sodium.base64_variants.URLSAFE_NO_PADDING) : "",
|
|
299
|
+
boxed ? sodium.to_base64(boxed, sodium.base64_variants.URLSAFE_NO_PADDING) : "",
|
|
300
|
+
];
|
|
301
|
+
const tag = sodium.crypto_auth(parts.join(":"), authKey);
|
|
302
|
+
return sodium.to_base64(tag, sodium.base64_variants.URLSAFE_NO_PADDING);
|
|
303
|
+
}
|
|
248
304
|
sendJsonFrame(frame) {
|
|
249
305
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
250
306
|
throw new Error("v2 transport is not connected");
|
|
@@ -256,7 +312,7 @@ export class V2SessionTransport {
|
|
|
256
312
|
throw new Error("v2 transport is not connected");
|
|
257
313
|
}
|
|
258
314
|
const framed = encodeV2EncryptedFrame(ciphertext);
|
|
259
|
-
this.ws.send(
|
|
315
|
+
this.ws.send(framed);
|
|
260
316
|
}
|
|
261
317
|
markSecure() {
|
|
262
318
|
this.state = "secure";
|