ciphermesh 1.0.0 β†’ 1.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",
@@ -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) {
@@ -1201,6 +1220,12 @@ export class ChatController {
1201
1220
  this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
1202
1221
  break;
1203
1222
 
1223
+ case '/tips': {
1224
+ this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
1225
+ this.#ui.addTip(tipAt(this.#tipIndex));
1226
+ break;
1227
+ }
1228
+
1204
1229
  case '/deniable': {
1205
1230
  const denArg = parts[1]?.toLowerCase();
1206
1231
  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
- : ` {white-fg}[${time()}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
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; // back at the bottom β€” everything is seen
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 = n === 1 ? 'new message' : 'new messages';
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}`
@@ -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.addInfoMessage(`Your fingerprint: ${controller.fingerprint}`);
200
- ui.addInfoMessage('Use /help to see available commands');
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) {
@@ -1296,6 +1318,12 @@ export class P2PChatController {
1296
1318
  this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
1297
1319
  break;
1298
1320
 
1321
+ case '/tips': {
1322
+ this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
1323
+ this.#ui.addTip(tipAt(this.#tipIndex));
1324
+ break;
1325
+ }
1326
+
1299
1327
  case '/kick':
1300
1328
  case '/mute':
1301
1329
  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.addInfoMessage(`Your fingerprint: ${controller.fingerprint}`);
186
- ui.addInfoMessage('P2P mode β€” peers discovered automatically via mDNS');
187
- ui.addInfoMessage('Use /help to see available commands');
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
+ }