peer-term 1.2.5 → 1.2.6

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
+ When the viewer connects, 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:
@@ -43,7 +49,7 @@ peer-term --verbose # enable debug logging
43
49
  ## Features
44
50
 
45
51
  - **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.
52
+ - **End-to-End Encrypted**: Terminal data is encrypted locally using AES-256-GCM with ECDH P-256 key exchange and an out-of-band fingerprint check. The relay never sees plaintext after the fingerprint is verified and authorized.
47
53
  - **WebRTC P2P**: Creates a direct Peer-to-Peer connection on the same LAN for zero-latency, relay-free sessions.
48
54
  - **Read-Only Mode**: Guests can view your terminal but cannot type commands.
49
55
  - **Custom Start Path**: Set the starting directory with `--path`.
@@ -55,5 +61,6 @@ peer-term --verbose # enable debug logging
55
61
  1. **Host** registers a session on the relay and gets a 6-digit code
56
62
  2. **Client** opens the web UI and enters the code
57
63
  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
64
+ 4. Both sides compare a short fingerprint, then the host authorizes the verified session with `a <code>`
65
+ 5. All terminal I/O is encrypted end-to-end — the relay only forwards opaque blobs
66
+ 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.6",
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';
@@ -312,7 +313,11 @@ class Session {
312
313
  this.code = null;
313
314
  this.hostToken = null;
314
315
  this.keyPair = null;
316
+ this.hostPublicKeyBase64 = null;
317
+ this.clientPublicKeyBase64 = null;
315
318
  this.sharedKey = null;
319
+ this.securityFingerprint = null;
320
+ this.fingerprintAuthorized = false;
316
321
  this.ptyProcess = null;
317
322
  this.heartbeatInterval = null;
318
323
  this.missedPings = 0;
@@ -343,6 +348,76 @@ class Session {
343
348
  logger.debug(`${prefix} ${msg}`);
344
349
  }
345
350
 
351
+ async _beginKeyExchange() {
352
+ this.keyPair = await generateKeyPair();
353
+ this.hostPublicKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
354
+ this.clientPublicKeyBase64 = null;
355
+ this.sharedKey = null;
356
+ this.securityFingerprint = null;
357
+ this.fingerprintAuthorized = false;
358
+ this.stopHeartbeat();
359
+ this._cleanupWebRTC();
360
+ this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: this.hostPublicKeyBase64 }));
361
+ }
362
+
363
+ async _completeKeyExchange(clientPublicKeyBase64) {
364
+ if (!this.keyPair || !this.hostPublicKeyBase64) return;
365
+
366
+ this.logDebug('Deriving shared secret...');
367
+ this.clientPublicKeyBase64 = clientPublicKeyBase64;
368
+
369
+ const peerPublicKey = await importPublicKey(clientPublicKeyBase64);
370
+ this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
371
+ this.securityFingerprint = await fingerprintPublicKeys(
372
+ this.code,
373
+ this.hostPublicKeyBase64,
374
+ this.clientPublicKeyBase64
375
+ );
376
+ this.fingerprintAuthorized = false;
377
+
378
+ this._printFingerprintAuthorization();
379
+ }
380
+
381
+ _printFingerprintAuthorization() {
382
+ console.log('');
383
+ this.log('Encrypted candidate established. Verify before terminal access.');
384
+ console.log(` Fingerprint: ${this.securityFingerprint}`);
385
+ console.log(' Ask the viewer to compare this exact fingerprint.');
386
+ console.log(` Type "a ${this.code}" after it matches, or "k ${this.code}" to kill the session.`);
387
+ console.log('');
388
+ }
389
+
390
+ async authorizeFingerprint() {
391
+ if (!this.sharedKey || !this.securityFingerprint) {
392
+ logger.warn(`Session ${this.code} has no fingerprint awaiting authorization.`);
393
+ return false;
394
+ }
395
+ if (this.fingerprintAuthorized) {
396
+ logger.info(`Session ${this.code} is already authorized.`);
397
+ return true;
398
+ }
399
+
400
+ this.fingerprintAuthorized = true;
401
+ await this._activateSecureSession();
402
+ return true;
403
+ }
404
+
405
+ async _activateSecureSession() {
406
+ if (!this.sharedKey || !this.fingerprintAuthorized) return;
407
+
408
+ this.log(`Fingerprint authorized: ${this.securityFingerprint}`);
409
+ this.log('Encrypted tunnel active');
410
+ await this._sendSessionConfig();
411
+
412
+ this.startHeartbeat();
413
+
414
+ if (!this.ptyProcess) {
415
+ this.spawnTerminal();
416
+ }
417
+
418
+ this._initiateWebRTC();
419
+ }
420
+
346
421
  async _sendSessionConfig() {
347
422
  if (!this.sharedKey || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
348
423
 
@@ -430,29 +505,12 @@ class Session {
430
505
  this.isClientConnected = true;
431
506
  this.missedPings = 0;
432
507
 
433
- this.keyPair = await generateKeyPair();
434
- const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
435
- this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
508
+ await this._beginKeyExchange();
436
509
  break;
437
510
  }
438
511
 
439
512
  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();
513
+ await this._completeKeyExchange(msg.publicKey);
456
514
  break;
457
515
  }
458
516
 
@@ -513,6 +571,10 @@ class Session {
513
571
  this.awaitingRejoin = true;
514
572
  this.sharedKey = null;
515
573
  this.keyPair = null;
574
+ this.hostPublicKeyBase64 = null;
575
+ this.clientPublicKeyBase64 = null;
576
+ this.securityFingerprint = null;
577
+ this.fingerprintAuthorized = false;
516
578
  this.stopHeartbeat();
517
579
  // Phase 4: Clean up WebRTC on peer disconnect
518
580
  this._cleanupWebRTC();
@@ -601,6 +663,10 @@ class Session {
601
663
  this.awaitingRejoin = true;
602
664
  this.sharedKey = null;
603
665
  this.keyPair = null;
666
+ this.hostPublicKeyBase64 = null;
667
+ this.clientPublicKeyBase64 = null;
668
+ this.securityFingerprint = null;
669
+ this.fingerprintAuthorized = false;
604
670
  this.stopHeartbeat();
605
671
  }
606
672
  }, HEARTBEAT_INTERVAL_MS);
@@ -872,28 +938,12 @@ class Session {
872
938
  break;
873
939
  }
874
940
 
875
- this.keyPair = await generateKeyPair();
876
- const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
877
- this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
941
+ await this._beginKeyExchange();
878
942
  break;
879
943
  }
880
944
 
881
945
  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();
946
+ await this._completeKeyExchange(msg.publicKey);
897
947
  break;
898
948
  }
899
949
 
@@ -951,6 +1001,10 @@ class Session {
951
1001
  this.awaitingRejoin = true;
952
1002
  this.sharedKey = null;
953
1003
  this.keyPair = null;
1004
+ this.hostPublicKeyBase64 = null;
1005
+ this.clientPublicKeyBase64 = null;
1006
+ this.securityFingerprint = null;
1007
+ this.fingerprintAuthorized = false;
954
1008
  this.stopHeartbeat();
955
1009
  this._cleanupWebRTC();
956
1010
  break;
@@ -1096,14 +1150,14 @@ class Session {
1096
1150
  }
1097
1151
 
1098
1152
  this.sharedKey = null;
1153
+ this.hostPublicKeyBase64 = null;
1154
+ this.clientPublicKeyBase64 = null;
1155
+ this.securityFingerprint = null;
1156
+ this.fingerprintAuthorized = false;
1099
1157
  this._cleanupWebRTC();
1100
1158
 
1101
- this.keyPair = await generateKeyPair();
1102
- const pubKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
1103
-
1104
1159
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1105
- this.ws.send(JSON.stringify({ type: 'key-exchange', publicKey: pubKeyBase64 }));
1106
- this.startHeartbeat();
1160
+ await this._beginKeyExchange();
1107
1161
  }
1108
1162
  }
1109
1163
 
@@ -1120,7 +1174,9 @@ class Session {
1120
1174
  const elapsed = Date.now() - this.createdAt;
1121
1175
  const remaining = Math.max(0, this.expiryMs - elapsed);
1122
1176
  let status = '';
1123
- if (this.isClientConnected) {
1177
+ if (this.securityFingerprint && !this.fingerprintAuthorized) {
1178
+ status = `verify fingerprint ${this.securityFingerprint}`;
1179
+ } else if (this.isClientConnected) {
1124
1180
  status = 'client connected';
1125
1181
  } else if (this.awaitingRejoin) {
1126
1182
  status = 'awaiting rejoin';
@@ -1182,6 +1238,15 @@ class SessionManager {
1182
1238
  logger.info(`Session ${code} killed.`);
1183
1239
  }
1184
1240
 
1241
+ async authorizeSession(code) {
1242
+ const session = this.sessions.get(code);
1243
+ if (!session) {
1244
+ logger.warn(`Session ${code} not found.`);
1245
+ return;
1246
+ }
1247
+ await session.authorizeFingerprint();
1248
+ }
1249
+
1185
1250
  killAll() {
1186
1251
  for (const [, session] of this.sessions) {
1187
1252
  session.destroy();
@@ -1228,6 +1293,7 @@ async function main() {
1228
1293
  console.log(' Commands:');
1229
1294
  console.log(' [n] New session');
1230
1295
  console.log(' [l] List sessions');
1296
+ console.log(' [a <code>] Authorize verified fingerprint');
1231
1297
  console.log(' [k <code>] Kill session');
1232
1298
  console.log(' [q] Quit all');
1233
1299
  console.log(' ─────────────────────────────────────────');
@@ -1249,6 +1315,9 @@ async function main() {
1249
1315
  await manager.createSession();
1250
1316
  } else if (input === 'l') {
1251
1317
  manager.listSessions();
1318
+ } else if (input.startsWith('a ')) {
1319
+ const code = input.slice(2).trim();
1320
+ await manager.authorizeSession(code);
1252
1321
  } else if (input.startsWith('k ')) {
1253
1322
  const code = input.slice(2).trim();
1254
1323
  manager.killSession(code);
@@ -1258,7 +1327,7 @@ async function main() {
1258
1327
  rl.close();
1259
1328
  process.exit(0);
1260
1329
  } else if (input) {
1261
- console.log(' Unknown command. Use n, l, k <code>, or q.');
1330
+ console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
1262
1331
  }
1263
1332
 
1264
1333
  rl.prompt();