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 +11 -3
- package/package.json +1 -1
- package/src/crypto.js +18 -1
- package/src/index.js +125 -46
- package/src/ui.js +2 -0
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.
|
|
59
|
-
5.
|
|
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
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|