lunel-cli 0.1.87 → 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 CHANGED
@@ -931,7 +931,13 @@ async function handleGitDiscard(payload) {
931
931
  }
932
932
  function emitAppEvent(msg) {
933
933
  if (activeV2Transport) {
934
- void activeV2Transport.sendMessage(msg).catch((error) => {
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" | "e2ee_hello" | "e2ee_secure_ready";
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: "client_ready";
42
- header: string;
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" && typeof frame.header === "string";
32
- if (frame.kind === "client_ready")
33
- return typeof frame.header === "string";
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) {
@@ -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 encryptMessage;
38
+ private encryptEnvelope;
36
39
  private ensureKeyPair;
40
+ private computeHandshakeAuth;
37
41
  private sendJsonFrame;
38
42
  private sendBinaryFrame;
39
43
  private markSecure;
@@ -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;
@@ -86,11 +93,15 @@ export class V2SessionTransport {
86
93
  await this.secureReadyPromise;
87
94
  }
88
95
  async sendMessage(message) {
89
- const ciphertext = await this.encryptMessage(message);
96
+ const ciphertext = this.encryptEnvelope({ kind: "request", message });
90
97
  this.sendBinaryFrame(ciphertext);
91
98
  }
92
99
  async sendResponse(response) {
93
- const ciphertext = await this.encryptMessage(response);
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");
@@ -140,24 +154,33 @@ export class V2SessionTransport {
140
154
  if (frame.type !== V2_FRAME_ENCRYPTED_MESSAGE) {
141
155
  throw new Error(`unsupported v2 frame type ${frame.type}`);
142
156
  }
143
- if (this.state !== "secure" || !this.pullState) {
157
+ if (this.state !== "secure" || !this.sessionKeys) {
144
158
  throw new Error("received encrypted frame before secure transport");
145
159
  }
146
- const pulled = sodium.crypto_secretstream_xchacha20poly1305_pull(this.pullState, frame.payload);
147
- if (!pulled.message) {
148
- throw new Error("failed to decrypt v2 frame");
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");
149
169
  }
150
- const parsed = JSON.parse(sodium.to_string(pulled.message));
151
- if (isProtocolResponse(parsed)) {
152
- await this.options.handlers.onProtocolResponse?.(parsed);
170
+ if (parsed.kind === "response") {
171
+ await this.options.handlers.onProtocolResponse?.(parsed.message);
153
172
  return;
154
173
  }
155
- if (isProtocolRequest(parsed)) {
156
- const response = await this.options.handlers.onProtocolRequest(parsed);
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);
157
180
  await this.sendResponse(response);
158
181
  return;
159
182
  }
160
- throw new Error("invalid decrypted protocol message");
183
+ throw new Error("invalid decrypted protocol envelope");
161
184
  }
162
185
  async maybeStartHandshake() {
163
186
  if (this.state === "secure" || this.state === "handshaking")
@@ -179,72 +202,115 @@ export class V2SessionTransport {
179
202
  if (this.options.role !== "cli") {
180
203
  throw new Error("unexpected client_hello on app transport");
181
204
  }
182
- const clientPublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
205
+ this.remotePublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
183
206
  const keyPair = this.ensureKeyPair();
184
- const keys = sodium.crypto_kx_server_session_keys(keyPair.publicKey, keyPair.privateKey, clientPublicKey);
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 = {
207
+ this.sendJsonFrame({
189
208
  t: "lunel_v2",
190
209
  kind: "server_hello",
191
210
  pubkey: sodium.to_base64(keyPair.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
192
- header: sodium.to_base64(pushInit.header, sodium.base64_variants.URLSAFE_NO_PADDING),
193
- };
194
- this.sendJsonFrame(response);
211
+ });
195
212
  return;
196
213
  }
197
214
  if (frame.kind === "server_hello") {
198
215
  if (this.options.role !== "app") {
199
216
  throw new Error("unexpected server_hello on cli transport");
200
217
  }
201
- const serverPublicKey = sodium.from_base64(frame.pubkey, sodium.base64_variants.URLSAFE_NO_PADDING);
202
- 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);
203
219
  const keyPair = this.ensureKeyPair();
204
- const keys = sodium.crypto_kx_client_session_keys(keyPair.publicKey, keyPair.privateKey, serverPublicKey);
205
- this.sessionKeys = { rx: keys.sharedRx, tx: keys.sharedTx };
206
- this.pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(serverHeader, keys.sharedRx);
207
- const pushInit = sodium.crypto_secretstream_xchacha20poly1305_init_push(keys.sharedTx);
208
- this.pushState = pushInit.state;
209
- const ready = {
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({
210
231
  t: "lunel_v2",
211
- kind: "client_ready",
212
- header: sodium.to_base64(pushInit.header, sodium.base64_variants.URLSAFE_NO_PADDING),
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),
213
258
  };
214
- this.sendJsonFrame(ready);
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
+ });
215
264
  this.markSecure();
216
265
  return;
217
266
  }
218
- if (frame.kind === "client_ready") {
219
- if (this.options.role !== "cli") {
220
- throw new Error("unexpected client_ready on app transport");
267
+ if (frame.kind === "server_ready") {
268
+ if (this.options.role !== "app") {
269
+ throw new Error("unexpected server_ready on cli transport");
221
270
  }
222
271
  if (!this.sessionKeys) {
223
- throw new Error("missing session keys before client_ready");
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");
224
277
  }
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
278
  this.markSecure();
228
279
  }
229
280
  }
230
- async encryptMessage(message) {
231
- await sodium.ready;
232
- if (this.state !== "secure" || !this.pushState) {
281
+ encryptEnvelope(envelope) {
282
+ if (this.state !== "secure" || !this.sessionKeys) {
233
283
  throw new Error("secure transport is not active");
234
284
  }
235
- const plaintext = sodium.from_string(JSON.stringify(message));
236
- return sodium.crypto_secretstream_xchacha20poly1305_push(this.pushState, plaintext, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE);
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;
237
291
  }
238
292
  ensureKeyPair() {
239
293
  if (this.keyPair)
240
294
  return this.keyPair;
241
- const pair = sodium.crypto_kx_keypair();
295
+ const pair = sodium.crypto_box_keypair();
242
296
  this.keyPair = {
243
297
  publicKey: pair.publicKey,
244
298
  privateKey: pair.privateKey,
245
299
  };
246
300
  return this.keyPair;
247
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
+ }
248
314
  sendJsonFrame(frame) {
249
315
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
250
316
  throw new Error("v2 transport is not connected");
@@ -256,7 +322,7 @@ export class V2SessionTransport {
256
322
  throw new Error("v2 transport is not connected");
257
323
  }
258
324
  const framed = encodeV2EncryptedFrame(ciphertext);
259
- this.ws.send(Buffer.from(framed));
325
+ this.ws.send(framed);
260
326
  }
261
327
  markSecure() {
262
328
  this.state = "secure";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",