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 +10 -3
- package/package.json +1 -1
- package/src/crypto.js +18 -1
- package/src/index.js +113 -44
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.
|
|
59
|
-
5.
|
|
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
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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();
|