cli-setu 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-setu",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Bridge your terminal to your phone — run and approve Claude Code, Codex CLI and other AI coding agents from anywhere.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -44,6 +44,17 @@ function loadOrCreateConfig() {
44
44
  return { deviceName: defaultDeviceName(), deviceToken: null };
45
45
  }
46
46
 
47
+ // Section 1 E2E: this device's own X25519 keypair, generated once (first
48
+ // `cli-setu pair`) and reused for the lifetime of this pairing - same
49
+ // lifetime as deviceToken itself, so a re-pair naturally mints a fresh
50
+ // keypair too, same as it mints a fresh token. Private key stored
51
+ // base64-encoded in the same config.json deviceToken already lives in -
52
+ // no new file, no new permissions model; this machine's own user-profile
53
+ // access already protects deviceToken the same way.
54
+ function hasKeypair(config) {
55
+ return !!(config.devicePrivateKey && config.devicePublicKey);
56
+ }
57
+
47
58
  module.exports = {
48
59
  CONFIG_DIR,
49
60
  CONFIG_FILE,
@@ -51,4 +62,5 @@ module.exports = {
51
62
  readConfig,
52
63
  writeConfig,
53
64
  loadOrCreateConfig,
65
+ hasKeypair,
54
66
  };
package/src/crypto.js ADDED
@@ -0,0 +1,104 @@
1
+ // Section 1 E2E: real end-to-end encryption for terminal.input/output,
2
+ // so the backend relay can forward packets without being able to read
3
+ // them (previously it could - see this file's own commit message / the
4
+ // privacy policy fix this exists to make true instead of false).
5
+ //
6
+ // Design: X25519 ECDH key exchange, one keypair per laptop, generated
7
+ // once at pair time and reused for the life of that pairing (same
8
+ // lifetime as the device token itself - a re-pair generates a fresh
9
+ // keypair, same as it mints a fresh token). The shared secret is
10
+ // derived once per phone public key via HKDF-SHA256 and cached; AES-
11
+ // 256-GCM encrypts each terminal payload with a fresh random IV.
12
+ //
13
+ // The backend only ever sees: this device's PUBLIC key (at pairing) and
14
+ // ciphertext (at relay time) - never the private key, never the derived
15
+ // secret, never plaintext. That's what makes this actually end-to-end,
16
+ // not just "encrypted somewhere."
17
+ const crypto = require('crypto');
18
+
19
+ // Node's 'x25519' curve via the generic Diffie-Hellman API - available
20
+ // since Node 13, well within this project's >=18 engines requirement.
21
+ function generateKeypair() {
22
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
23
+ return { publicKey, privateKey };
24
+ }
25
+
26
+ // Node's KeyObject export format for a raw X25519 public key is a DER-
27
+ // wrapped SPKI blob, not the raw 32 bytes - stripping to raw bytes here
28
+ // so the wire format matches what @noble/curves on the app side produces
29
+ // and expects directly (no ASN.1 parsing needed on that end).
30
+ const RAW_PUBLIC_KEY_LEN = 32;
31
+
32
+ function publicKeyToRaw(publicKey) {
33
+ const der = publicKey.export({ type: 'spki', format: 'der' });
34
+ return der.subarray(der.length - RAW_PUBLIC_KEY_LEN);
35
+ }
36
+
37
+ function publicKeyFromRaw(rawBytes) {
38
+ // SPKI header for X25519 is fixed (12 bytes) - prepending it lets
39
+ // Node's crypto module import a raw 32-byte public key the same way
40
+ // it exports one above.
41
+ const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
42
+ const der = Buffer.concat([SPKI_PREFIX, rawBytes]);
43
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
44
+ }
45
+
46
+ function publicKeyToBase64(publicKey) {
47
+ return publicKeyToRaw(publicKey).toString('base64');
48
+ }
49
+
50
+ // Private key storage shape: Node's native PKCS8 DER export, base64-
51
+ // encoded - config.js stores it this way in config.json alongside
52
+ // deviceToken (same file, same OS-level file permissions already
53
+ // protecting that token, so no new secret-storage mechanism needed).
54
+ function privateKeyToBase64(privateKey) {
55
+ return privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64');
56
+ }
57
+
58
+ function privateKeyFromBase64(base64) {
59
+ return crypto.createPrivateKey({ key: Buffer.from(base64, 'base64'), format: 'der', type: 'pkcs8' });
60
+ }
61
+
62
+ function deriveSharedSecret(privateKey, peerPublicKeyBase64) {
63
+ const peerPublicKey = publicKeyFromRaw(Buffer.from(peerPublicKeyBase64, 'base64'));
64
+ const rawSecret = crypto.diffieHellman({ privateKey, publicKey: peerPublicKey });
65
+ // HKDF-SHA256, no salt (both sides derive deterministically from the
66
+ // same ECDH output - a salt would need its own exchange for no real
67
+ // benefit here), fixed info string scoping this key to this exact
68
+ // purpose so it can never collide with a key derived elsewhere for a
69
+ // different purpose from the same raw ECDH secret.
70
+ return Buffer.from(crypto.hkdfSync('sha256', rawSecret, Buffer.alloc(0), 'cli-setu-terminal-v1', 32));
71
+ }
72
+
73
+ // AES-256-GCM: a fresh random 12-byte IV per message (GCM's own
74
+ // requirement - reusing an IV with the same key breaks its security
75
+ // guarantees entirely), the 16-byte auth tag appended, both prepended/
76
+ // appended to the ciphertext so the wire format is one self-contained
77
+ // base64 string: IV (12) || ciphertext || authTag (16).
78
+ function encrypt(sharedSecret, plaintext) {
79
+ const iv = crypto.randomBytes(12);
80
+ const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
81
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
82
+ const authTag = cipher.getAuthTag();
83
+ return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
84
+ }
85
+
86
+ function decrypt(sharedSecret, payloadBase64) {
87
+ const buf = Buffer.from(payloadBase64, 'base64');
88
+ const iv = buf.subarray(0, 12);
89
+ const authTag = buf.subarray(buf.length - 16);
90
+ const ciphertext = buf.subarray(12, buf.length - 16);
91
+ const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
92
+ decipher.setAuthTag(authTag);
93
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
94
+ }
95
+
96
+ module.exports = {
97
+ generateKeypair,
98
+ publicKeyToBase64,
99
+ privateKeyToBase64,
100
+ privateKeyFromBase64,
101
+ deriveSharedSecret,
102
+ encrypt,
103
+ decrypt,
104
+ };
package/src/pair.js CHANGED
@@ -5,11 +5,25 @@
5
5
  // of a hand-typed argv, and the result is saved to ~/.cli-setu/config.json
6
6
  // instead of a per-device-name file in the install folder.
7
7
  const qrcode = require('qrcode-terminal');
8
- const { loadOrCreateConfig, writeConfig } = require('./config');
8
+ const { loadOrCreateConfig, writeConfig, hasKeypair } = require('./config');
9
+ const { generateKeypair, publicKeyToBase64, privateKeyToBase64 } = require('./crypto');
9
10
 
10
11
  async function pair(authServer, authServerPublic) {
11
12
  const config = loadOrCreateConfig();
12
13
 
14
+ // Section 1 E2E: generate this device's own keypair up front, before
15
+ // either pairing path below, so both qr-start and pair/start can send
16
+ // the SAME public key no matter which path the user actually takes
17
+ // (scan vs typed code - both start by calling this function). A
18
+ // re-pair (config.deviceToken was cleared just below, or this is a
19
+ // genuinely fresh config) always gets a FRESH keypair - never reuses
20
+ // a stale one left over from a previous, now-dead pairing.
21
+ if (!hasKeypair(config)) {
22
+ const { privateKey, publicKey } = generateKeypair();
23
+ config.devicePrivateKey = privateKeyToBase64(privateKey);
24
+ config.devicePublicKey = publicKeyToBase64(publicKey);
25
+ }
26
+
13
27
  if (config.deviceToken) {
14
28
  // Real bug found by testing: unpairing from the phone deletes the
15
29
  // device server-side, but the laptop's local config.json still has
@@ -55,7 +69,7 @@ async function pair(authServer, authServerPublic) {
55
69
  const startRes = await fetch(`${authServer}/device/pair/qr-start`, {
56
70
  method: 'POST',
57
71
  headers: { 'Content-Type': 'application/json' },
58
- body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing' }),
72
+ body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing', devicePublicKey: config.devicePublicKey }),
59
73
  });
60
74
  const { qrToken } = await startRes.json();
61
75
 
@@ -69,7 +83,7 @@ async function pair(authServer, authServerPublic) {
69
83
  const codeRes = await fetch(`${authServer}/device/pair/start`, {
70
84
  method: 'POST',
71
85
  headers: { 'Content-Type': 'application/json' },
72
- body: JSON.stringify({ deviceName: config.deviceName }),
86
+ body: JSON.stringify({ deviceName: config.deviceName, devicePublicKey: config.devicePublicKey }),
73
87
  });
74
88
  const { code: typedCode, pairId: typedPairId } = await codeRes.json();
75
89
  console.log(`[cli-setu] Or type this code in the app's Devices tab: ${typedCode}`);
@@ -84,6 +98,10 @@ async function pair(authServer, authServerPublic) {
84
98
  if (status.status === 'CONFIRMED') {
85
99
  confirmed = true;
86
100
  config.deviceToken = status.deviceToken;
101
+ // Section 1 E2E: the phone's public key, so this laptop can derive
102
+ // the shared secret for terminal.input/output - see crypto.js's
103
+ // deriveSharedSecret and supervisor.js's own use of it.
104
+ if (status.phonePublicKey) config.phonePublicKey = status.phonePublicKey;
87
105
  writeConfig(config);
88
106
  console.log(`[cli-setu] ✓ Paired via QR and saved to ~/.cli-setu/config.json`);
89
107
  break;
@@ -100,6 +118,7 @@ async function pair(authServer, authServerPublic) {
100
118
  if (typedStatus.status === 'CONFIRMED') {
101
119
  confirmed = true;
102
120
  config.deviceToken = typedStatus.deviceToken;
121
+ if (typedStatus.phonePublicKey) config.phonePublicKey = typedStatus.phonePublicKey;
103
122
  writeConfig(config);
104
123
  console.log(`[cli-setu] ✓ Paired via typed code and saved to ~/.cli-setu/config.json`);
105
124
  break;
package/src/status.js CHANGED
@@ -24,26 +24,49 @@ async function status(authServer) {
24
24
  console.log('[cli-setu] Not paired. Run `cli-setu pair` to get started.');
25
25
  return;
26
26
  }
27
- console.log(`[cli-setu] Paired as "${config.deviceName}".`);
28
27
 
29
- // Real gap found by testing with multiple real accounts across multiple
30
- // laptops: there was no way to confirm which Google account a laptop was
31
- // paired to without checking the phone app directly - the device token
32
- // itself carries no human-readable identity. Best-effort: a network
33
- // failure here shouldn't block the rest of `status` from reporting what
34
- // it already knows locally.
28
+ // Real bug found by testing: this used to print "Paired as X" straight
29
+ // from the local config file with no server check at all - unpairing
30
+ // from the PHONE deletes the device server-side, but the laptop's own
31
+ // config.json still has the old (now-dead) token, so `status` kept
32
+ // claiming the device was paired indefinitely, with nothing telling the
33
+ // user their session was actually dead until something else failed
34
+ // later. Same real distinction pair.js's own re-pair check already
35
+ // makes: a 401 means the server genuinely doesn't recognize this token
36
+ // anymore (deliberately re-pairable from here); a network error/
37
+ // unreachable server is a THIRD, distinct outcome, not treated as
38
+ // "unpaired" - it falls through to reporting local state as before,
39
+ // same as this check not existing, so a flaky-wifi run doesn't get
40
+ // told it's unpaired when it might just be offline.
41
+ let serverSaysUnpaired = false;
42
+ let userId = null;
35
43
  try {
36
44
  const whoamiRes = await fetch(`${authServer}/device/whoami`, {
37
45
  headers: { Authorization: `Bearer ${config.deviceToken}` },
38
46
  });
39
47
  if (whoamiRes.ok) {
40
- const { userId } = await whoamiRes.json();
41
- console.log(`[cli-setu] Paired to account: ${userId}`);
48
+ ({ userId } = await whoamiRes.json());
49
+ } else if (whoamiRes.status === 401) {
50
+ serverSaysUnpaired = true;
42
51
  }
43
52
  } catch {
44
53
  // Offline, or the server's unreachable - status still works from local state alone.
45
54
  }
46
55
 
56
+ if (serverSaysUnpaired) {
57
+ // Deliberately NOT returning here - a `cli-setu` process can still be
58
+ // genuinely running (pointlessly retrying against this now-dead
59
+ // token, see supervisor.js's own 401 handling), and the user should
60
+ // still be told that so they know to `cli-setu stop` it rather than
61
+ // it silently retrying in the background forever.
62
+ console.log(`[cli-setu] "${config.deviceName}" was unpaired from the app. Run \`cli-setu pair\` to reconnect.`);
63
+ } else {
64
+ console.log(`[cli-setu] Paired as "${config.deviceName}".`);
65
+ if (userId) {
66
+ console.log(`[cli-setu] Paired to account: ${userId}`);
67
+ }
68
+ }
69
+
47
70
  let files;
48
71
  try {
49
72
  files = fs.readdirSync(CONFIG_DIR).filter((f) => f.startsWith('session.') && f.endsWith('.pid'));
package/src/supervisor.js CHANGED
@@ -42,6 +42,7 @@ const execAsync = promisify(exec);
42
42
  // data, not part of the command syntax itself.
43
43
  const execFileAsync = promisify(execFile);
44
44
  const { getAdapter } = require('./cli-adapters');
45
+ const setuCrypto = require('./crypto');
45
46
 
46
47
  // Real bug found by testing: this script's own console.log/console.error
47
48
  // calls write to the SAME process.stdout the PTY's raw output is echoed
@@ -166,6 +167,25 @@ let ws = null;
166
167
  let azureReady = false;
167
168
  const pendingPrompts = new Map();
168
169
 
170
+ // Section 1 E2E: derived once at startup, same lifetime as this process.
171
+ // Null whenever either half is missing - a device paired before this
172
+ // feature existed has devicePrivateKey/phonePublicKey absent from its
173
+ // config.json, and terminal.input/output/snapshot below deliberately
174
+ // fall back to sending PLAINTEXT for exactly that case (see this
175
+ // project's own decision: no forced re-pair, upgrade happens naturally
176
+ // on the next real re-pair). Never silently "half encrypt" - either a
177
+ // real secret is available and used for every payload this session, or
178
+ // none is and nothing here pretends otherwise.
179
+ let sharedSecret = null;
180
+ if (config.devicePrivateKey && config.phonePublicKey) {
181
+ try {
182
+ const privateKey = setuCrypto.privateKeyFromBase64(config.devicePrivateKey);
183
+ sharedSecret = setuCrypto.deriveSharedSecret(privateKey, config.phonePublicKey);
184
+ } catch (err) {
185
+ console.error('[cli-setu] Could not derive E2E encryption key (falling back to unencrypted relay):', err.message);
186
+ }
187
+ }
188
+
169
189
  // Phase 6, Section 7: a rolling raw-byte scrollback so a phone that opens
170
190
  // terminal.html AFTER the session has been running for a while gets caught
171
191
  // up instantly via session.snapshot, instead of an empty screen. Capped so
@@ -283,7 +303,28 @@ async function connectToAzure() {
283
303
  // Section 11f Step 2: typed input from the phone, written to the PTY
284
304
  // exactly like a local keystroke - this is the "arbitrary remote
285
305
  // input" half of the DoD, not just approve/reject.
286
- ptyProcess.write(msg.data);
306
+ //
307
+ // Section 1 E2E: msg.data is ciphertext whenever sharedSecret exists
308
+ // (the phone encrypts before sending - see TerminalScreen.js's own
309
+ // sendInput). msg.encrypted distinguishes the two wire shapes so a
310
+ // pre-upgrade phone build (no E2E yet) and a pre-upgrade laptop
311
+ // build talking to each other don't try to decrypt plaintext or
312
+ // vice versa - see this file's own sharedSecret comment for why
313
+ // that fallback has to be all-or-nothing, never silent guessing.
314
+ let data = msg.data;
315
+ if (msg.encrypted) {
316
+ if (!sharedSecret) {
317
+ log('[supervisor] Dropped an encrypted terminal.input - this device has no shared secret to decrypt it (re-pair to fix).');
318
+ return;
319
+ }
320
+ try {
321
+ data = setuCrypto.decrypt(sharedSecret, msg.data);
322
+ } catch (err) {
323
+ log('[supervisor] Failed to decrypt terminal.input, dropping:', err.message);
324
+ return;
325
+ }
326
+ }
327
+ ptyProcess.write(data);
287
328
  }
288
329
  if (msg.type === 'terminal.resize' && msg.sessionId === SESSION_ID) {
289
330
  // Real bug found by testing: this used to call ptyProcess.resize()
@@ -371,14 +412,17 @@ async function sendSnapshot() {
371
412
  // pre-TUI history too, not just whatever the current alt-screen
372
413
  // buffer still happens to show.
373
414
  const history = await preservedHistoryText();
374
- const screen = history ? `${history}\n${scrollback}` : scrollback;
415
+ const plainScreen = history ? `${history}\n${scrollback}` : scrollback;
416
+ // Section 1 E2E: see terminal.input's own comment on why this is
417
+ // all-or-nothing, never a silent per-message guess.
418
+ const screen = sharedSecret ? setuCrypto.encrypt(sharedSecret, plainScreen) : plainScreen;
375
419
  await fetch(`${AUTH_SERVER}/relay/terminal-snapshot`, {
376
420
  method: 'POST',
377
421
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
378
422
  // cols/rows: the real PTY's actual geometry, so a phone attaching
379
423
  // can lock its terminal to match instead of guessing its own size -
380
424
  // see auth-server.js's matching comment for why this matters.
381
- body: JSON.stringify({ sessionId: SESSION_ID, screen, cols: PTY_COLS, rows: PTY_ROWS }),
425
+ body: JSON.stringify({ sessionId: SESSION_ID, screen, encrypted: !!sharedSecret, cols: PTY_COLS, rows: PTY_ROWS }),
382
426
  });
383
427
  } catch (err) {
384
428
  log('[supervisor] Failed to send snapshot:', err.message);
@@ -555,10 +599,13 @@ function flushOutputBuffer() {
555
599
  // fetch() itself should resolve quickly regardless of Azure's own cost.
556
600
  sendInFlight = sendInFlight.then(async () => {
557
601
  try {
602
+ // Section 1 E2E: see terminal.input's own comment on why this is
603
+ // all-or-nothing, never a silent per-message guess.
604
+ const data = sharedSecret ? setuCrypto.encrypt(sharedSecret, batch) : batch;
558
605
  await fetch(`${AUTH_SERVER}/relay/terminal-output`, {
559
606
  method: 'POST',
560
607
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
561
- body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data: batch }),
608
+ body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data, encrypted: !!sharedSecret }),
562
609
  });
563
610
  } catch (err) {
564
611
  // A dropped terminal.output batch isn't fatal the way a dropped