peer-term 1.2.5 → 1.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/README.md CHANGED
@@ -14,6 +14,12 @@ This will start a terminal sharing session and give you a 6-digit code.
14
14
 
15
15
  Share this code with your peer. They can view your terminal by going to https://peer-term-relay-production-9b7a.up.railway.app or https://peer-term-relay.onrender.com and entering the code.
16
16
 
17
+ If you started the session with `--secure`, both sides will show a short security fingerprint. Compare it over a trusted channel. If it matches, authorize the session in the host CLI:
18
+
19
+ ```bash
20
+ a <code>
21
+ ```
22
+
17
23
  ## Global Installation
18
24
 
19
25
  If you prefer to install it globally:
@@ -35,6 +41,7 @@ peer-term # starts at home directory
35
41
  peer-term --path ~/projects # starts at ~/projects
36
42
  peer-term --path . # starts in current directory
37
43
  peer-term --readonly # view-only session
44
+ peer-term --secure # require fingerprint verification
38
45
  peer-term --expiry 10m # custom expiry time
39
46
  peer-term --relay wss://custom # use a custom relay server
40
47
  peer-term --verbose # enable debug logging
@@ -43,7 +50,7 @@ peer-term --verbose # enable debug logging
43
50
  ## Features
44
51
 
45
52
  - **No Config**: Works instantly. No port forwarding or firewall configuration needed.
46
- - **End-to-End Encrypted**: Terminal data is encrypted locally using AES-256-GCM with ECDH P-256 key exchange. The relay never sees plaintext.
53
+ - **End-to-End Encrypted**: Terminal data is encrypted locally using AES-256-GCM with ECDH P-256 key exchange. The relay never sees plaintext. A `--secure` flag enables an out-of-band fingerprint check for strict MITM protection.
47
54
  - **WebRTC P2P**: Creates a direct Peer-to-Peer connection on the same LAN for zero-latency, relay-free sessions.
48
55
  - **Read-Only Mode**: Guests can view your terminal but cannot type commands.
49
56
  - **Custom Start Path**: Set the starting directory with `--path`.
@@ -55,5 +62,6 @@ peer-term --verbose # enable debug logging
55
62
  1. **Host** registers a session on the relay and gets a 6-digit code
56
63
  2. **Client** opens the web UI and enters the code
57
64
  3. Both sides perform an **ECDH key exchange** to derive a shared AES-256-GCM secret
58
- 4. All terminal I/O is encrypted end-to-end — the relay only forwards opaque blobs
59
- 5. If both peers are on the same LAN, a **WebRTC DataChannel** is established to bypass the relay entirely
65
+ 4. If `--secure` is used, both sides compare a short fingerprint, then the host authorizes the verified session with `a <code>`
66
+ 5. All terminal I/O is encrypted end-to-end — the relay only forwards opaque blobs
67
+ 6. If both peers are on the same LAN, a **WebRTC DataChannel** is established to bypass the relay entirely
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-term",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "license": "MIT",
5
5
  "description": "Share your terminal instantly using a 6-digit code. Encrypted. No config.",
6
6
  "keywords": [
package/src/crypto.js CHANGED
@@ -75,6 +75,24 @@ export async function deriveSharedKey(privateKey, peerPublicKey) {
75
75
  );
76
76
  }
77
77
 
78
+ /**
79
+ * Compute the short authentication string shown to both users.
80
+ * The relay can forward keys, but it cannot make a MITM split produce the same
81
+ * fingerprint on both sides unless the users skip the out-of-band comparison.
82
+ */
83
+ export async function fingerprintPublicKeys(sessionCode, hostPublicKeyBase64, clientPublicKeyBase64) {
84
+ const transcript = [
85
+ 'PeerTerm SAS v1',
86
+ String(sessionCode || ''),
87
+ String(hostPublicKeyBase64 || ''),
88
+ String(clientPublicKeyBase64 || ''),
89
+ ].join('\n');
90
+
91
+ const digest = await subtle.digest('SHA-256', new TextEncoder().encode(transcript));
92
+ const hex = Buffer.from(digest).toString('hex').toUpperCase().slice(0, 16);
93
+ return hex.match(/.{1,4}/g).join('-');
94
+ }
95
+
78
96
  // ─── Encryption ─────────────────────────────────────────────────────────────
79
97
 
80
98
  /**
@@ -144,4 +162,3 @@ export async function decrypt(sharedKey, base64data) {
144
162
  // 4. Return as string
145
163
  return new TextDecoder().decode(plaintext);
146
164
  }
147
-
package/src/index.js CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  exportPublicKey,
36
36
  importPublicKey,
37
37
  deriveSharedKey,
38
+ fingerprintPublicKeys,
38
39
  encrypt,
39
40
  decrypt,
40
41
  } from './crypto.js';
@@ -60,7 +61,7 @@ try {
60
61
  // ─── CLI Argument Parsing ────────────────────────────────────────────────────
61
62
 
62
63
  const argv = minimist(process.argv.slice(2), {
63
- boolean: ['readonly', 'verbose', 'help', 'version'],
64
+ boolean: ['readonly', 'verbose', 'help', 'version', 'secure'],
64
65
  string: ['expiry', 'rejoin', 'relay', 'path'],
65
66
  alias: { h: 'help', v: 'version', V: 'verbose' },
66
67
  });
@@ -307,12 +308,17 @@ class Session {
307
308
  this.readOnly = readOnly;
308
309
  this.startPath = startPath;
309
310
  this.onDestroy = onDestroy;
311
+ this.secureMode = argv.secure || false;
310
312
 
311
313
  this.ws = null;
312
314
  this.code = null;
313
315
  this.hostToken = null;
314
316
  this.keyPair = null;
317
+ this.hostPublicKeyBase64 = null;
318
+ this.clientPublicKeyBase64 = null;
315
319
  this.sharedKey = null;
320
+ this.securityFingerprint = null;
321
+ this.fingerprintAuthorized = false;
316
322
  this.ptyProcess = null;
317
323
  this.heartbeatInterval = null;
318
324
  this.missedPings = 0;
@@ -343,6 +349,85 @@ class Session {
343
349
  logger.debug(`${prefix} ${msg}`);
344
350
  }
345
351
 
352
+ async _beginKeyExchange() {
353
+ this.keyPair = await generateKeyPair();
354
+ this.hostPublicKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
355
+ this.clientPublicKeyBase64 = null;
356
+ this.sharedKey = null;
357
+ this.securityFingerprint = null;
358
+ this.fingerprintAuthorized = false;
359
+ this.stopHeartbeat();
360
+ this._cleanupWebRTC();
361
+ this.ws.send(JSON.stringify({
362
+ type: 'key-exchange',
363
+ publicKey: this.hostPublicKeyBase64,
364
+ secureMode: this.secureMode
365
+ }));
366
+ }
367
+
368
+ async _completeKeyExchange(clientPublicKeyBase64) {
369
+ if (!this.keyPair || !this.hostPublicKeyBase64) return;
370
+
371
+ this.logDebug('Deriving shared secret...');
372
+ this.clientPublicKeyBase64 = clientPublicKeyBase64;
373
+
374
+ const peerPublicKey = await importPublicKey(clientPublicKeyBase64);
375
+ this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
376
+
377
+ if (this.secureMode) {
378
+ this.securityFingerprint = await fingerprintPublicKeys(
379
+ this.code,
380
+ this.hostPublicKeyBase64,
381
+ this.clientPublicKeyBase64
382
+ );
383
+ this.fingerprintAuthorized = false;
384
+ this._printFingerprintAuthorization();
385
+ } else {
386
+ this.fingerprintAuthorized = true;
387
+ await this._activateSecureSession();
388
+ }
389
+ }
390
+
391
+ _printFingerprintAuthorization() {
392
+ console.log('');
393
+ this.log('Encrypted candidate established. Verify before terminal access.');
394
+ console.log(` Fingerprint: ${this.securityFingerprint}`);
395
+ console.log(' Ask the viewer to compare this exact fingerprint.');
396
+ console.log(` Type "a ${this.code}" after it matches, or "k ${this.code}" to kill the session.`);
397
+ console.log('');
398
+ }
399
+
400
+ async authorizeFingerprint() {
401
+ if (!this.sharedKey || !this.securityFingerprint) {
402
+ logger.warn(`Session ${this.code} has no fingerprint awaiting authorization.`);
403
+ return false;
404
+ }
405
+ if (this.fingerprintAuthorized) {
406
+ logger.info(`Session ${this.code} is already authorized.`);
407
+ return true;
408
+ }
409
+
410
+ this.fingerprintAuthorized = true;
411
+ await this._activateSecureSession();
412
+ return true;
413
+ }
414
+
415
+ async _activateSecureSession() {
416
+ if (!this.sharedKey || !this.fingerprintAuthorized) return;
417
+
418
+ this.log(`Fingerprint authorized: ${this.securityFingerprint}`);
419
+ this.log('Encrypted tunnel active');
420
+ await this._sendSessionConfig();
421
+
422
+ this.startHeartbeat();
423
+
424
+ if (!this.ptyProcess) {
425
+ this.spawnTerminal();
426
+ }
427
+
428
+ this._initiateWebRTC();
429
+ }
430
+
346
431
  async _sendSessionConfig() {
347
432
  if (!this.sharedKey || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
348
433
 
@@ -411,7 +496,7 @@ class Session {
411
496
  code: this.code,
412
497
  expiry: formatDuration(this.expiryMs),
413
498
  rejoinWindow: formatDuration(this.rejoinMs),
414
- mode: this.readOnly ? 'Read-Only' : 'Read-Write',
499
+ mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
415
500
  shell: this.shell,
416
501
  startPath: this.startPath,
417
502
  shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
@@ -430,29 +515,12 @@ class Session {
430
515
  this.isClientConnected = true;
431
516
  this.missedPings = 0;
432
517
 
433
- this.keyPair = await generateKeyPair();
434
- const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
435
- this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
518
+ await this._beginKeyExchange();
436
519
  break;
437
520
  }
438
521
 
439
522
  case 'key-exchange': {
440
- if (!this.keyPair) return;
441
- this.logDebug('Deriving shared secret...');
442
-
443
- const peerPublicKey = await importPublicKey(msg.publicKey);
444
- this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
445
- this.log('Encrypted tunnel active');
446
- await this._sendSessionConfig();
447
-
448
- this.startHeartbeat();
449
-
450
- if (!this.ptyProcess) {
451
- this.spawnTerminal();
452
- }
453
-
454
- // Phase 4: Initiate WebRTC after encryption is ready
455
- this._initiateWebRTC();
523
+ await this._completeKeyExchange(msg.publicKey);
456
524
  break;
457
525
  }
458
526
 
@@ -513,6 +581,10 @@ class Session {
513
581
  this.awaitingRejoin = true;
514
582
  this.sharedKey = null;
515
583
  this.keyPair = null;
584
+ this.hostPublicKeyBase64 = null;
585
+ this.clientPublicKeyBase64 = null;
586
+ this.securityFingerprint = null;
587
+ this.fingerprintAuthorized = false;
516
588
  this.stopHeartbeat();
517
589
  // Phase 4: Clean up WebRTC on peer disconnect
518
590
  this._cleanupWebRTC();
@@ -601,6 +673,10 @@ class Session {
601
673
  this.awaitingRejoin = true;
602
674
  this.sharedKey = null;
603
675
  this.keyPair = null;
676
+ this.hostPublicKeyBase64 = null;
677
+ this.clientPublicKeyBase64 = null;
678
+ this.securityFingerprint = null;
679
+ this.fingerprintAuthorized = false;
604
680
  this.stopHeartbeat();
605
681
  }
606
682
  }, HEARTBEAT_INTERVAL_MS);
@@ -872,28 +948,12 @@ class Session {
872
948
  break;
873
949
  }
874
950
 
875
- this.keyPair = await generateKeyPair();
876
- const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
877
- this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
951
+ await this._beginKeyExchange();
878
952
  break;
879
953
  }
880
954
 
881
955
  case 'key-exchange': {
882
- if (!this.keyPair) return;
883
- this.logDebug('Deriving shared secret...');
884
-
885
- const peerPublicKey = await importPublicKey(msg.publicKey);
886
- this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
887
- this.log('Encrypted tunnel active');
888
- await this._sendSessionConfig();
889
-
890
- this.startHeartbeat();
891
-
892
- if (!this.ptyProcess) {
893
- this.spawnTerminal();
894
- }
895
-
896
- this._initiateWebRTC();
956
+ await this._completeKeyExchange(msg.publicKey);
897
957
  break;
898
958
  }
899
959
 
@@ -951,6 +1011,10 @@ class Session {
951
1011
  this.awaitingRejoin = true;
952
1012
  this.sharedKey = null;
953
1013
  this.keyPair = null;
1014
+ this.hostPublicKeyBase64 = null;
1015
+ this.clientPublicKeyBase64 = null;
1016
+ this.securityFingerprint = null;
1017
+ this.fingerprintAuthorized = false;
954
1018
  this.stopHeartbeat();
955
1019
  this._cleanupWebRTC();
956
1020
  break;
@@ -1096,14 +1160,14 @@ class Session {
1096
1160
  }
1097
1161
 
1098
1162
  this.sharedKey = null;
1163
+ this.hostPublicKeyBase64 = null;
1164
+ this.clientPublicKeyBase64 = null;
1165
+ this.securityFingerprint = null;
1166
+ this.fingerprintAuthorized = false;
1099
1167
  this._cleanupWebRTC();
1100
1168
 
1101
- this.keyPair = await generateKeyPair();
1102
- const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
1103
-
1104
1169
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1105
- this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
1106
- this.startHeartbeat();
1170
+ await this._beginKeyExchange();
1107
1171
  }
1108
1172
  }
1109
1173
 
@@ -1120,7 +1184,9 @@ class Session {
1120
1184
  const elapsed = Date.now() - this.createdAt;
1121
1185
  const remaining = Math.max(0, this.expiryMs - elapsed);
1122
1186
  let status = '';
1123
- if (this.isClientConnected) {
1187
+ if (this.securityFingerprint && !this.fingerprintAuthorized) {
1188
+ status = `verify fingerprint ${this.securityFingerprint}`;
1189
+ } else if (this.isClientConnected) {
1124
1190
  status = 'client connected';
1125
1191
  } else if (this.awaitingRejoin) {
1126
1192
  status = 'awaiting rejoin';
@@ -1182,6 +1248,15 @@ class SessionManager {
1182
1248
  logger.info(`Session ${code} killed.`);
1183
1249
  }
1184
1250
 
1251
+ async authorizeSession(code) {
1252
+ const session = this.sessions.get(code);
1253
+ if (!session) {
1254
+ logger.warn(`Session ${code} not found.`);
1255
+ return;
1256
+ }
1257
+ await session.authorizeFingerprint();
1258
+ }
1259
+
1185
1260
  killAll() {
1186
1261
  for (const [, session] of this.sessions) {
1187
1262
  session.destroy();
@@ -1228,6 +1303,7 @@ async function main() {
1228
1303
  console.log(' Commands:');
1229
1304
  console.log(' [n] New session');
1230
1305
  console.log(' [l] List sessions');
1306
+ console.log(' [a <code>] Authorize verified fingerprint');
1231
1307
  console.log(' [k <code>] Kill session');
1232
1308
  console.log(' [q] Quit all');
1233
1309
  console.log(' ─────────────────────────────────────────');
@@ -1249,6 +1325,9 @@ async function main() {
1249
1325
  await manager.createSession();
1250
1326
  } else if (input === 'l') {
1251
1327
  manager.listSessions();
1328
+ } else if (input.startsWith('a ')) {
1329
+ const code = input.slice(2).trim();
1330
+ await manager.authorizeSession(code);
1252
1331
  } else if (input.startsWith('k ')) {
1253
1332
  const code = input.slice(2).trim();
1254
1333
  manager.killSession(code);
@@ -1258,7 +1337,7 @@ async function main() {
1258
1337
  rl.close();
1259
1338
  process.exit(0);
1260
1339
  } else if (input) {
1261
- console.log(' Unknown command. Use n, l, k <code>, or q.');
1340
+ console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
1262
1341
  }
1263
1342
 
1264
1343
  rl.prompt();
package/src/ui.js CHANGED
@@ -101,6 +101,7 @@ Options:
101
101
  --expiry <time> Session expiry time (e.g. 5m, 30s, 1h) [default: 5m]
102
102
  --rejoin <time> Reconnection window (e.g. 5m, 1h, 6h) [default: 2m]
103
103
  --readonly Share in view-only mode
104
+ --secure Enable fingerprint verification for MITM protection
104
105
  --path <dir> Starting directory for the terminal session [default: home]
105
106
  --relay <url> Custom relay server URL
106
107
  --verbose Enable debug logging
@@ -112,6 +113,7 @@ Examples:
112
113
  peer-term --expiry 10m Custom expiry
113
114
  peer-term --rejoin 30m Custom reconnection window
114
115
  peer-term --readonly View-only session
116
+ peer-term --secure Require fingerprint verification
115
117
  peer-term --path ~/projects Start in ~/projects
116
118
  peer-term --verbose Debug logs
117
119
  peer-term --relay wss://custom Use custom relay server