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 +1 -1
- package/src/client/ChatController.js +25 -0
- package/src/client/UI.js +61 -6
- package/src/client/index.js +9 -2
- package/src/p2p/P2PChatController.js +28 -0
- package/src/p2p/index.js +8 -3
- package/src/shared/tips.js +30 -0
- package/src/shared/trust.js +31 -0
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) {
|
|
@@ -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
|
-
:
|
|
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) {
|
|
@@ -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.
|
|
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
|
+
}
|