ciphermesh 1.0.0 → 1.2.1
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 +4 -1
- package/README.pt-BR.md +4 -1
- package/package.json +1 -1
- package/src/client/ChatController.js +27 -0
- package/src/client/UI.js +61 -6
- package/src/client/index.js +9 -2
- package/src/p2p/P2PChatController.js +30 -0
- package/src/p2p/index.js +8 -3
- package/src/shared/tips.js +30 -0
- package/src/shared/trust.js +31 -0
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ forwarding, survives CGNAT).
|
|
|
43
43
|
| 🔐 | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` — never touch disk |
|
|
44
44
|
| 🔄 | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today ≠ read yesterday |
|
|
45
45
|
| 🕶️ | **Metadata resistance** | Fixed-bucket length padding on every ciphertext + opt-in cover traffic (`/cover`) to blur when you chat |
|
|
46
|
-
| 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm)
|
|
46
|
+
| 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **✓/✗** trust badges next to names |
|
|
47
47
|
| 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
|
|
48
48
|
| 📨 | **Invites with QR** | `/invite` prints a `ciphermesh://` string + QR — paste it, you're in the right room |
|
|
49
49
|
| ✓✓ | **Encrypted read receipts** | The ✓✓ travels as ordinary ciphertext — the server can't tell it apart |
|
|
@@ -116,6 +116,7 @@ QR code) to whoever you want to pull in.
|
|
|
116
116
|
| Command | Description |
|
|
117
117
|
|---------|-------------|
|
|
118
118
|
| `/help` | All commands |
|
|
119
|
+
| `/tips` | Show a rotating security/UX tip |
|
|
119
120
|
| `/users` | Who's online (with away/status) |
|
|
120
121
|
| `/msg <nick> <text>` | Private message (DM) |
|
|
121
122
|
| `/reply <text>` | Reply quoting the last received message |
|
|
@@ -156,6 +157,8 @@ QR code) to whoever you want to pull in.
|
|
|
156
157
|
| `/receipts [on\|off]` | Send read receipts (✓✓) |
|
|
157
158
|
| `/audit [n]` | Local audit log |
|
|
158
159
|
|
|
160
|
+
A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a key that changed since you last saw it (possible MITM). A newly-arrived unverified peer triggers a one-time reminder to `/verify` them.
|
|
161
|
+
|
|
159
162
|
</details>
|
|
160
163
|
|
|
161
164
|
<details>
|
package/README.pt-BR.md
CHANGED
|
@@ -43,7 +43,7 @@ forwarding, imune a CGNAT).
|
|
|
43
43
|
| 🔐 | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` — nunca tocam o disco |
|
|
44
44
|
| 🔄 | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem — comprometer hoje ≠ ler ontem |
|
|
45
45
|
| 🕶️ | **Resistência a metadados** | Padding de comprimento em buckets fixos em todo ciphertext + cover traffic opcional (`/cover`) pra borrar quando você conversa |
|
|
46
|
-
| 🕵️ | **TOFU + SAS** | Alarme de troca de chave (MITM)
|
|
46
|
+
| 🕵️ | **TOFU + SAS** | Alarme de troca de chave (MITM), código de 6 dígitos verificável por voz e badges de confiança **✓/✗** inline ao lado dos nomes |
|
|
47
47
|
| 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereço alcançável no banner |
|
|
48
48
|
| 📨 | **Convites com QR** | `/invite` gera uma string `ciphermesh://` + QR — colou, caiu na sala certa |
|
|
49
49
|
| ✓✓ | **Read receipts cifrados** | O ✓✓ viaja como ciphertext comum — o servidor não distingue de mensagem |
|
|
@@ -116,6 +116,7 @@ quem você quiser puxar pra conversa.
|
|
|
116
116
|
| Comando | Descrição |
|
|
117
117
|
|---------|-----------|
|
|
118
118
|
| `/help` | Todos os comandos |
|
|
119
|
+
| `/tips` | Mostra uma dica rotativa de segurança/UX |
|
|
119
120
|
| `/users` | Quem está online (com away/status) |
|
|
120
121
|
| `/msg <nick> <texto>` | Mensagem privada (DM) |
|
|
121
122
|
| `/reply <texto>` | Responde citando a última mensagem recebida |
|
|
@@ -156,6 +157,8 @@ quem você quiser puxar pra conversa.
|
|
|
156
157
|
| `/receipts [on\|off]` | Envio de confirmação de leitura (✓✓) |
|
|
157
158
|
| `/audit [n]` | Log de auditoria local |
|
|
158
159
|
|
|
160
|
+
Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗** vermelho sinaliza uma chave que mudou desde a última vez (possível MITM). Um peer novo não-verificado dispara um lembrete único para `/verify`.
|
|
161
|
+
|
|
159
162
|
</details>
|
|
160
163
|
|
|
161
164
|
<details>
|
package/package.json
CHANGED
|
@@ -35,6 +35,8 @@ import { detectImageProtocol, encodeInlineImage } from '../shared/terminalGraphi
|
|
|
35
35
|
import { suggestCommand } from '../shared/commandSuggest.js';
|
|
36
36
|
import { nextCoverDelay, coverPayload, isCover } from '../shared/coverTraffic.js';
|
|
37
37
|
import { recordVoiceNote, playVoiceNote, isAudioFile } from '../shared/voiceNote.js';
|
|
38
|
+
import { trustBadge } from '../shared/trust.js';
|
|
39
|
+
import { tipAt, TIPS } from '../shared/tips.js';
|
|
38
40
|
import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
|
|
39
41
|
import { panicWipe } from '../shared/panic.js';
|
|
40
42
|
import { farewellBanner } from '../shared/banner.js';
|
|
@@ -57,6 +59,8 @@ export class ChatController {
|
|
|
57
59
|
#peerTypingTimers; // Map<sessionId, timeoutId>
|
|
58
60
|
#fileTransfer;
|
|
59
61
|
#pendingFileOffers = new Map(); // transferId -> { from, data, nickname }
|
|
62
|
+
#verifyNudged = new Set(); // peers already nudged to /verify this session
|
|
63
|
+
#tipIndex = -1; // rotates through TIPS for /tips
|
|
60
64
|
#lastImagePath = null; // last received image (for /img full-res render)
|
|
61
65
|
#lastAudioPath = null; // last received voice note (for /play)
|
|
62
66
|
#keyRotationTimer;
|
|
@@ -464,6 +468,7 @@ export class ChatController {
|
|
|
464
468
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
465
469
|
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
466
470
|
this.#ui.handshakeConnect(peer.nickname);
|
|
471
|
+
this.#nudgeVerify(peer.nickname);
|
|
467
472
|
this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname });
|
|
468
473
|
|
|
469
474
|
// A newcomer doesn't know my presence — send only to them
|
|
@@ -472,6 +477,18 @@ export class ChatController {
|
|
|
472
477
|
}
|
|
473
478
|
}
|
|
474
479
|
|
|
480
|
+
// One-time-per-session nudge to verify an unverified peer's identity.
|
|
481
|
+
#nudgeVerify(nickname) {
|
|
482
|
+
const key = nickname.toLowerCase();
|
|
483
|
+
if (this.#trustStore.isVerified(nickname) || this.#verifyNudged.has(key)) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.#verifyNudged.add(key);
|
|
487
|
+
this.#ui.addSystemMessage(
|
|
488
|
+
`🔑 ${nickname} is unverified — run /verify ${nickname} to confirm their identity`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
475
492
|
// ── Peer left ─────────────────────────────────────────────────
|
|
476
493
|
#onPeerLeft(msg) {
|
|
477
494
|
const peer = this.#peers.get(msg.sessionId);
|
|
@@ -812,6 +829,7 @@ export class ChatController {
|
|
|
812
829
|
|
|
813
830
|
const mentioned = this.#mentionsMe(data.text) && !data.isDM;
|
|
814
831
|
const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
|
|
832
|
+
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
815
833
|
const { lineIndex } = this.#ui.addMessage(
|
|
816
834
|
peer.nickname,
|
|
817
835
|
data.text,
|
|
@@ -819,6 +837,7 @@ export class ChatController {
|
|
|
819
837
|
ephLabel,
|
|
820
838
|
isDeniable || !!data.deniable,
|
|
821
839
|
mentioned,
|
|
840
|
+
trust,
|
|
822
841
|
);
|
|
823
842
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
824
843
|
if (notify) {
|
|
@@ -889,6 +908,7 @@ export class ChatController {
|
|
|
889
908
|
case '/help':
|
|
890
909
|
this.#ui.addInfoMessage('Available commands:');
|
|
891
910
|
this.#ui.addInfoMessage(' /help - Show this help');
|
|
911
|
+
this.#ui.addInfoMessage(' /tips - Show a security/UX tip');
|
|
892
912
|
this.#ui.addInfoMessage(' /users - List online users');
|
|
893
913
|
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
894
914
|
this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
|
|
@@ -906,6 +926,7 @@ export class ChatController {
|
|
|
906
926
|
this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm peer verification');
|
|
907
927
|
this.#ui.addInfoMessage(" /trust <nick> - Accept a peer's new key");
|
|
908
928
|
this.#ui.addInfoMessage(" /trustlist - Peers' trust status");
|
|
929
|
+
this.#ui.addInfoMessage(' (✓ = verified peer · ✗ = key changed — shown next to a name)');
|
|
909
930
|
this.#ui.addInfoMessage(' /clear - Clear the chat');
|
|
910
931
|
this.#ui.addInfoMessage(' /file <path> - Send a file (max 50MB)');
|
|
911
932
|
this.#ui.addInfoMessage(
|
|
@@ -1201,6 +1222,12 @@ export class ChatController {
|
|
|
1201
1222
|
this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
|
|
1202
1223
|
break;
|
|
1203
1224
|
|
|
1225
|
+
case '/tips': {
|
|
1226
|
+
this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
|
|
1227
|
+
this.#ui.addTip(tipAt(this.#tipIndex));
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1204
1231
|
case '/deniable': {
|
|
1205
1232
|
const denArg = parts[1]?.toLowerCase();
|
|
1206
1233
|
if (denArg === 'off') {
|
package/src/client/UI.js
CHANGED
|
@@ -15,6 +15,7 @@ const INPUT_MAX_LINES = 8; // input box grows up to this many text lines
|
|
|
15
15
|
// Command → one-line description for the Ctrl+K fuzzy command palette.
|
|
16
16
|
const COMMAND_INFO = [
|
|
17
17
|
['/help', 'Show help'],
|
|
18
|
+
['/tips', 'Show a security/UX tip'],
|
|
18
19
|
['/users', 'List online users'],
|
|
19
20
|
['/msg', 'Private message (DM)'],
|
|
20
21
|
['/reply', 'Reply to the last message'],
|
|
@@ -64,6 +65,7 @@ const COMMAND_INFO = [
|
|
|
64
65
|
|
|
65
66
|
export const COMMANDS = [
|
|
66
67
|
'/help',
|
|
68
|
+
'/tips',
|
|
67
69
|
'/nick',
|
|
68
70
|
'/users',
|
|
69
71
|
'/fingerprint',
|
|
@@ -455,6 +457,7 @@ export class UI extends EventEmitter {
|
|
|
455
457
|
#shimmerTimer;
|
|
456
458
|
#shimmerPos;
|
|
457
459
|
#unseenCount;
|
|
460
|
+
#unseenMentions;
|
|
458
461
|
#pillTimer;
|
|
459
462
|
#pillFrame;
|
|
460
463
|
#palette;
|
|
@@ -501,6 +504,7 @@ export class UI extends EventEmitter {
|
|
|
501
504
|
this.#shimmerTimer = null;
|
|
502
505
|
this.#shimmerPos = 0;
|
|
503
506
|
this.#unseenCount = 0;
|
|
507
|
+
this.#unseenMentions = 0;
|
|
504
508
|
this.#pillTimer = null;
|
|
505
509
|
this.#pillFrame = 0;
|
|
506
510
|
this.#paletteOpen = false;
|
|
@@ -508,10 +512,18 @@ export class UI extends EventEmitter {
|
|
|
508
512
|
this.#emojiOpen = false;
|
|
509
513
|
this.#emojiQuery = '';
|
|
510
514
|
|
|
515
|
+
// blessed's terminfo parser can't compile the modern Setulc (underline
|
|
516
|
+
// colour) capability that terminals like ghostty ship, so it dumps a
|
|
517
|
+
// compile error when it resets the terminal on exit (e.g. Ctrl+C). Those
|
|
518
|
+
// terminals are xterm-256color-compatible for everything we render — images
|
|
519
|
+
// use their own escape sequences, not blessed — so pin tput to xterm-256color
|
|
520
|
+
// and sidestep the broken capability.
|
|
521
|
+
const term = process.env.TERM || '';
|
|
511
522
|
this.#screen = blessed.screen({
|
|
512
523
|
smartCSR: true,
|
|
513
524
|
fullUnicode: true, // renders emojis and characters outside the BMP
|
|
514
525
|
title: 'CipherMesh',
|
|
526
|
+
terminal: /ghostty/i.test(term) ? 'xterm-256color' : undefined,
|
|
515
527
|
});
|
|
516
528
|
|
|
517
529
|
// ── Header ──────────────────────────────────────────
|
|
@@ -1320,6 +1332,7 @@ export class UI extends EventEmitter {
|
|
|
1320
1332
|
ephemeralLabel = null,
|
|
1321
1333
|
deniable = false,
|
|
1322
1334
|
mentioned = false,
|
|
1335
|
+
trust = 'none',
|
|
1323
1336
|
) {
|
|
1324
1337
|
this.#daySeparator();
|
|
1325
1338
|
|
|
@@ -1330,6 +1343,15 @@ export class UI extends EventEmitter {
|
|
|
1330
1343
|
const dmLabel = isDM ? ' {magenta-fg}(DM){/magenta-fg}' : '';
|
|
1331
1344
|
const ephLabel = ephemeralLabel ? ` {yellow-fg}[${ephemeralLabel}]{/yellow-fg}` : '';
|
|
1332
1345
|
const denLabel = deniable ? ' {magenta-fg}[D]{/magenta-fg}' : '';
|
|
1346
|
+
// Trust badge next to the name: check = SAS-verified, cross = key changed.
|
|
1347
|
+
const trustGlyph =
|
|
1348
|
+
trust === 'verified'
|
|
1349
|
+
? ' {green-fg}✓{/green-fg}'
|
|
1350
|
+
: trust === 'mismatch'
|
|
1351
|
+
? ' {red-fg}✗{/red-fg}'
|
|
1352
|
+
: '';
|
|
1353
|
+
// A yellow left rule makes a line that @-mentions you jump out of the log.
|
|
1354
|
+
const bar = mentioned && !isSelf ? '{yellow-fg}▏{/yellow-fg}' : ' ';
|
|
1333
1355
|
const mentionMark = mentioned && !isSelf ? '{yellow-fg}\ud83d\udd14 {/yellow-fg}' : '';
|
|
1334
1356
|
|
|
1335
1357
|
// Consecutive messages from the same peer collapse the avatar/name into a
|
|
@@ -1337,19 +1359,19 @@ export class UI extends EventEmitter {
|
|
|
1337
1359
|
const grouped = !isSelf && !isDM && this.#lastSender === nickname;
|
|
1338
1360
|
const core = grouped
|
|
1339
1361
|
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}`
|
|
1340
|
-
: `${avatar} {${tag}}${nickname}{/${tag}}${dmLabel}: ${renderMarkdown(text)}`;
|
|
1362
|
+
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}`;
|
|
1341
1363
|
|
|
1342
1364
|
// My own messages on the right (timestamp at the end), others on the left
|
|
1343
1365
|
const line = isSelf
|
|
1344
1366
|
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${time()}]{/white-fg}`)
|
|
1345
|
-
:
|
|
1367
|
+
: `${bar}{white-fg}[${time()}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
|
|
1346
1368
|
|
|
1347
1369
|
this.#lines.push(line);
|
|
1348
1370
|
this.#chatLog.log(line);
|
|
1349
1371
|
this.#screen.render();
|
|
1350
1372
|
this.#lastSender = isSelf ? 'self' : nickname;
|
|
1351
1373
|
if (!isSelf) {
|
|
1352
|
-
this.#noteIncoming();
|
|
1374
|
+
this.#noteIncoming(mentioned || isDM);
|
|
1353
1375
|
}
|
|
1354
1376
|
return { lineIndex: this.#lines.length - 1 };
|
|
1355
1377
|
}
|
|
@@ -1424,6 +1446,33 @@ export class UI extends EventEmitter {
|
|
|
1424
1446
|
this.#screen.render();
|
|
1425
1447
|
}
|
|
1426
1448
|
|
|
1449
|
+
// A one-line security/UX tip (💡). Plain text — no blessed tags interpreted.
|
|
1450
|
+
addTip(text) {
|
|
1451
|
+
this.#lastSender = null;
|
|
1452
|
+
const line = ` {yellow-fg}💡{/yellow-fg} {#9a9ad0-fg}${blessed.escape(text)}{/#9a9ad0-fg}`;
|
|
1453
|
+
this.#lines.push(line);
|
|
1454
|
+
this.#chatLog.log(line);
|
|
1455
|
+
this.#screen.render();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// A framed "getting started" panel for the empty chat. `lines` may contain
|
|
1459
|
+
// blessed tags (the caller styles them); the title is escaped.
|
|
1460
|
+
addWelcome(title, lines) {
|
|
1461
|
+
this.#lastSender = null;
|
|
1462
|
+
const push = (l) => {
|
|
1463
|
+
this.#lines.push(l);
|
|
1464
|
+
this.#chatLog.log(l);
|
|
1465
|
+
};
|
|
1466
|
+
push('');
|
|
1467
|
+
push(` {#7b2dff-fg}╭─{/#7b2dff-fg} {bold}${blessed.escape(title)}{/bold}`);
|
|
1468
|
+
for (const l of lines) {
|
|
1469
|
+
push(` {#7b2dff-fg}│{/#7b2dff-fg} ${l}`);
|
|
1470
|
+
}
|
|
1471
|
+
push(` {#7b2dff-fg}╰──────────────────────────────────────────{/#7b2dff-fg}`);
|
|
1472
|
+
push('');
|
|
1473
|
+
this.#screen.render();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1427
1476
|
addQuoteLine(nickname, excerpt, alignRight = false) {
|
|
1428
1477
|
const quoted = `{#888888-fg}↩ ${blessed.escape(nickname)}: "${blessed.escape(excerpt)}"{/#888888-fg}`;
|
|
1429
1478
|
const line = alignRight ? this.#alignRight(quoted) : ` ${quoted}`;
|
|
@@ -1463,7 +1512,8 @@ export class UI extends EventEmitter {
|
|
|
1463
1512
|
if (scrolledUp !== this.#scrolledUp) {
|
|
1464
1513
|
this.#scrolledUp = scrolledUp;
|
|
1465
1514
|
if (!scrolledUp) {
|
|
1466
|
-
this.#unseenCount = 0;
|
|
1515
|
+
this.#unseenCount = 0;
|
|
1516
|
+
this.#unseenMentions = 0; // back at the bottom — everything is seen
|
|
1467
1517
|
}
|
|
1468
1518
|
this.#refreshScrollIndicator();
|
|
1469
1519
|
}
|
|
@@ -1472,9 +1522,12 @@ export class UI extends EventEmitter {
|
|
|
1472
1522
|
|
|
1473
1523
|
// Count a fresh arrival while the user is reading history, and pulse the
|
|
1474
1524
|
// "new messages" pill so they know to page down.
|
|
1475
|
-
#noteIncoming() {
|
|
1525
|
+
#noteIncoming(important = false) {
|
|
1476
1526
|
if (this.#scrolledUp) {
|
|
1477
1527
|
this.#unseenCount++;
|
|
1528
|
+
if (important) {
|
|
1529
|
+
this.#unseenMentions++;
|
|
1530
|
+
}
|
|
1478
1531
|
this.#refreshScrollIndicator();
|
|
1479
1532
|
}
|
|
1480
1533
|
}
|
|
@@ -1504,7 +1557,9 @@ export class UI extends EventEmitter {
|
|
|
1504
1557
|
|
|
1505
1558
|
#renderPill() {
|
|
1506
1559
|
const n = this.#unseenCount;
|
|
1507
|
-
const plural =
|
|
1560
|
+
const plural =
|
|
1561
|
+
(n === 1 ? 'new message' : 'new messages') +
|
|
1562
|
+
(this.#unseenMentions > 0 ? `, ${this.#unseenMentions} @you` : '');
|
|
1508
1563
|
const bright = this.#pillFrame % 2 === 0;
|
|
1509
1564
|
const label = bright
|
|
1510
1565
|
? `{black-fg}{yellow-bg} ↓ ${n} ${plural} — PageDown {/yellow-bg}{/black-fg}`
|
package/src/client/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { parseInvite } from '../shared/invite.js';
|
|
|
18
18
|
import { importBackup } from '../crypto/IdentityBackup.js';
|
|
19
19
|
import { questionHidden } from '../shared/prompt.js';
|
|
20
20
|
import { loadConfig, startupCommands } from '../shared/config.js';
|
|
21
|
+
import { randomTip } from '../shared/tips.js';
|
|
21
22
|
import { setTheme } from '../shared/themes.js';
|
|
22
23
|
import { Connection } from './Connection.js';
|
|
23
24
|
import { UI } from './UI.js';
|
|
@@ -196,8 +197,14 @@ const controller = new ChatController(
|
|
|
196
197
|
);
|
|
197
198
|
|
|
198
199
|
ui.setFingerprint(controller.fingerprint);
|
|
199
|
-
ui.
|
|
200
|
-
|
|
200
|
+
ui.addWelcome('Welcome to CipherMesh', [
|
|
201
|
+
`{#8888aa-fg}Your fingerprint{/#8888aa-fg} ${controller.fingerprint}`,
|
|
202
|
+
'',
|
|
203
|
+
'{white-fg}No peers yet — bring someone in:{/white-fg}',
|
|
204
|
+
' {cyan-fg}/invite{/cyan-fg} QR + link for your room {cyan-fg}/help{/cyan-fg} all commands',
|
|
205
|
+
' {cyan-fg}/verify <nick>{/cyan-fg} confirm a peer out-of-band (a green ✓ appears)',
|
|
206
|
+
]);
|
|
207
|
+
ui.addTip(randomTip());
|
|
201
208
|
|
|
202
209
|
if (restoredState?.handshake) {
|
|
203
210
|
ui.addSystemMessage('Previous session restored — ratchets preserved');
|
|
@@ -31,6 +31,8 @@ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
|
|
|
31
31
|
import { panicWipe } from '../shared/panic.js';
|
|
32
32
|
import { farewellBanner } from '../shared/banner.js';
|
|
33
33
|
import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
|
|
34
|
+
import { trustBadge } from '../shared/trust.js';
|
|
35
|
+
import { tipAt, TIPS } from '../shared/tips.js';
|
|
34
36
|
import { COMMANDS } from '../client/UI.js';
|
|
35
37
|
|
|
36
38
|
const TYPING_SEND_INTERVAL = 2000;
|
|
@@ -50,6 +52,8 @@ export class P2PChatController {
|
|
|
50
52
|
#peerTypingTimers;
|
|
51
53
|
#fileTransfer;
|
|
52
54
|
#pendingFileOffers = new Map(); // transferId -> { data, nickname }
|
|
55
|
+
#verifyNudged = new Set(); // peers already nudged to /verify this session
|
|
56
|
+
#tipIndex = -1; // rotates through TIPS for /tips
|
|
53
57
|
#knownPeers = new Set(); // nicknames seen this session (for store-and-forward)
|
|
54
58
|
#sfQueue = new Map(); // nickname -> [{ payload, queuedAt }] for offline peers
|
|
55
59
|
#currentRoom = 'general';
|
|
@@ -191,6 +195,7 @@ export class P2PChatController {
|
|
|
191
195
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
192
196
|
this.#ui.setPeerNames([...this.#peers.keys()]);
|
|
193
197
|
this.#ui.handshakeConnect(nickname);
|
|
198
|
+
this.#nudgeVerify(nickname);
|
|
194
199
|
this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname });
|
|
195
200
|
|
|
196
201
|
this.#knownPeers.add(nickname);
|
|
@@ -204,6 +209,18 @@ export class P2PChatController {
|
|
|
204
209
|
this.#flushSFQueue(nickname);
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
// One-time-per-session nudge to verify an unverified peer's identity.
|
|
213
|
+
#nudgeVerify(nickname) {
|
|
214
|
+
const key = nickname.toLowerCase();
|
|
215
|
+
if (this.#trustStore.isVerified(nickname) || this.#verifyNudged.has(key)) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.#verifyNudged.add(key);
|
|
219
|
+
this.#ui.addSystemMessage(
|
|
220
|
+
`🔑 ${nickname} is unverified — run /verify ${nickname} to confirm their identity`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
// ── Store-and-forward (P2P) ──────────────────────────────────
|
|
208
225
|
#enqueueSF(nickname, payload) {
|
|
209
226
|
let queue = this.#sfQueue.get(nickname);
|
|
@@ -539,6 +556,10 @@ export class P2PChatController {
|
|
|
539
556
|
}
|
|
540
557
|
const mentioned = mentionsMe(data.text, this.#nickname) && !data.isDM;
|
|
541
558
|
const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
|
|
559
|
+
const trust = trustBadge(
|
|
560
|
+
this.#trustStore.getPeerRecord(fromNickname),
|
|
561
|
+
this.#findPeer(fromNickname)?.publicKey,
|
|
562
|
+
);
|
|
542
563
|
const { lineIndex } = this.#ui.addMessage(
|
|
543
564
|
fromNickname,
|
|
544
565
|
data.text,
|
|
@@ -546,6 +567,7 @@ export class P2PChatController {
|
|
|
546
567
|
ephLabel,
|
|
547
568
|
isDeniable || !!data.deniable,
|
|
548
569
|
mentioned,
|
|
570
|
+
trust,
|
|
549
571
|
);
|
|
550
572
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
551
573
|
if (notify) {
|
|
@@ -655,6 +677,7 @@ export class P2PChatController {
|
|
|
655
677
|
case '/help':
|
|
656
678
|
this.#ui.addInfoMessage('Available commands (P2P mode):');
|
|
657
679
|
this.#ui.addInfoMessage(' /help - Show this help');
|
|
680
|
+
this.#ui.addInfoMessage(' /tips - Show a security/UX tip');
|
|
658
681
|
this.#ui.addInfoMessage(' /users - List connected peers');
|
|
659
682
|
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
660
683
|
this.#ui.addInfoMessage(' /fingerprint - Show your fingerprint');
|
|
@@ -663,6 +686,7 @@ export class P2PChatController {
|
|
|
663
686
|
this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm verification');
|
|
664
687
|
this.#ui.addInfoMessage(' /trust <nick> - Accept a new key');
|
|
665
688
|
this.#ui.addInfoMessage(' /trustlist - Trust status');
|
|
689
|
+
this.#ui.addInfoMessage(' (✓ = verified peer · ✗ = key changed — shown next to a name)');
|
|
666
690
|
this.#ui.addInfoMessage(' /clear - Clear the chat');
|
|
667
691
|
this.#ui.addInfoMessage(' /file <path> - Send a file (max 50MB)');
|
|
668
692
|
this.#ui.addInfoMessage(
|
|
@@ -1296,6 +1320,12 @@ export class P2PChatController {
|
|
|
1296
1320
|
this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
|
|
1297
1321
|
break;
|
|
1298
1322
|
|
|
1323
|
+
case '/tips': {
|
|
1324
|
+
this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
|
|
1325
|
+
this.#ui.addTip(tipAt(this.#tipIndex));
|
|
1326
|
+
break;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1299
1329
|
case '/kick':
|
|
1300
1330
|
case '/mute':
|
|
1301
1331
|
case '/ban':
|
package/src/p2p/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { KeyManager } from '../crypto/KeyManager.js';
|
|
|
16
16
|
import { StateManager } from '../crypto/StateManager.js';
|
|
17
17
|
import { questionHidden } from '../shared/prompt.js';
|
|
18
18
|
import { loadConfig, startupCommands } from '../shared/config.js';
|
|
19
|
+
import { randomTip } from '../shared/tips.js';
|
|
19
20
|
import { setTheme } from '../shared/themes.js';
|
|
20
21
|
import { importBackup } from '../crypto/IdentityBackup.js';
|
|
21
22
|
import { Discovery } from './Discovery.js';
|
|
@@ -182,9 +183,13 @@ const controller = new P2PChatController(
|
|
|
182
183
|
);
|
|
183
184
|
|
|
184
185
|
ui.setFingerprint(controller.fingerprint);
|
|
185
|
-
ui.
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
ui.addWelcome('Welcome to CipherMesh — P2P', [
|
|
187
|
+
`{#8888aa-fg}Your fingerprint{/#8888aa-fg} ${controller.fingerprint}`,
|
|
188
|
+
'',
|
|
189
|
+
'{white-fg}Discovering peers on the LAN via mDNS…{/white-fg}',
|
|
190
|
+
' {cyan-fg}/help{/cyan-fg} all commands {cyan-fg}/verify <nick>{/cyan-fg} confirm a peer',
|
|
191
|
+
]);
|
|
192
|
+
ui.addTip(randomTip());
|
|
188
193
|
|
|
189
194
|
if (restoredState?.handshake) {
|
|
190
195
|
ui.addSystemMessage('Previous session restored — ratchets preserved');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Short, security-forward tips surfaced one-at-a-time at startup and via /tips.
|
|
2
|
+
// Kept dependency-free so it can be unit-tested and reused by both modes.
|
|
3
|
+
|
|
4
|
+
export const TIPS = [
|
|
5
|
+
'Run /verify <nick> to confirm a peer out-of-band — a green ✓ then appears next to their name.',
|
|
6
|
+
'/invite generates a QR code + ciphermesh:// link to pull someone into your room.',
|
|
7
|
+
'/ephemeral 5m makes new messages self-destruct after the timer.',
|
|
8
|
+
'/cover on adds decoy traffic so the relay can’t tell when you’re really chatting.',
|
|
9
|
+
'/panic wipes every on-disk secret and exits — for a lost or seized device.',
|
|
10
|
+
'/backup saves your identity + verified peers as an encrypted file.',
|
|
11
|
+
'Ctrl+K opens the command palette; Ctrl+E the emoji picker; PgUp/PgDn scrolls.',
|
|
12
|
+
'A ✗ next to a name means their key changed since you last saw it — verify before trusting.',
|
|
13
|
+
'/deniable on switches to plausibly-deniable messages (no cryptographic proof you sent them).',
|
|
14
|
+
'No server? Run it in P2P mode — peers find each other on the LAN via mDNS, no relay at all.',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Pick a tip by index (wraps around). Deterministic — the caller decides the
|
|
18
|
+
// index (e.g. a random one at startup, or an incrementing one for /tips).
|
|
19
|
+
export function tipAt(index, tips = TIPS) {
|
|
20
|
+
if (tips.length === 0) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const i = ((index % tips.length) + tips.length) % tips.length;
|
|
24
|
+
return tips[i];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One random tip — used at startup. (Runtime only; not used in workflow scripts.)
|
|
28
|
+
export function randomTip(tips = TIPS) {
|
|
29
|
+
return tipAt(Math.floor(Math.random() * tips.length), tips);
|
|
30
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pure trust-verdict helpers, decoupled from the TrustStore so they can be
|
|
2
|
+
// unit-tested and shared by both controllers. The store records peers as
|
|
3
|
+
// { fingerprint, publicKey, firstSeen, lastSeen, verified }.
|
|
4
|
+
|
|
5
|
+
export const TrustBadge = {
|
|
6
|
+
VERIFIED: 'verified', // SAS-confirmed identity
|
|
7
|
+
MISMATCH: 'mismatch', // stored key != current key (possible MITM / rotation)
|
|
8
|
+
NONE: 'none', // unknown peer, or TOFU-trusted but identity unconfirmed
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Decide which badge to show next to a peer, from their stored record (or null)
|
|
12
|
+
// and the public key they are presenting right now.
|
|
13
|
+
// - no record -> NONE (brand-new peer)
|
|
14
|
+
// - key changed -> MISMATCH
|
|
15
|
+
// - verified & key match -> VERIFIED
|
|
16
|
+
// - else -> NONE (TOFU-trusted, not verified)
|
|
17
|
+
export function trustBadge(record, currentPublicKey) {
|
|
18
|
+
// No record, or we don't know the key they're presenting right now (e.g. a
|
|
19
|
+
// store-and-forward message from a peer who isn't currently connected) — we
|
|
20
|
+
// can't make a claim, so show nothing rather than a false mismatch.
|
|
21
|
+
if (!record || !currentPublicKey) {
|
|
22
|
+
return TrustBadge.NONE;
|
|
23
|
+
}
|
|
24
|
+
if (record.publicKey !== currentPublicKey) {
|
|
25
|
+
return TrustBadge.MISMATCH;
|
|
26
|
+
}
|
|
27
|
+
if (record.verified) {
|
|
28
|
+
return TrustBadge.VERIFIED;
|
|
29
|
+
}
|
|
30
|
+
return TrustBadge.NONE;
|
|
31
|
+
}
|