ciphermesh 2.3.0 → 2.4.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/README.md +15 -2
- package/README.pt-BR.md +15 -2
- package/package.json +1 -1
- package/src/client/ChatController.js +201 -19
- package/src/client/Connection.js +31 -1
- package/src/client/FileTransfer.js +53 -24
- package/src/client/UI.js +211 -4
- package/src/crypto/CertPinStore.js +39 -3
- package/src/p2p/P2PChatController.js +632 -21
- package/src/p2p/index.js +12 -0
- package/src/protocol/validators.js +10 -1
- package/src/shared/dnd.js +13 -0
package/src/client/UI.js
CHANGED
|
@@ -19,7 +19,10 @@ const COMMAND_INFO = [
|
|
|
19
19
|
['/users', 'List online users'],
|
|
20
20
|
['/msg', 'Private message (DM)'],
|
|
21
21
|
['/reply', 'Reply to the last message'],
|
|
22
|
+
['/me', 'Third-person action message'],
|
|
23
|
+
['/topic', 'Show or set the room topic'],
|
|
22
24
|
['/mentions', 'Recent mentions of you'],
|
|
25
|
+
['/watch', 'Alert on a keyword in any room'],
|
|
23
26
|
['/contacts', 'Contact book — aliases for peers'],
|
|
24
27
|
['/away', 'Mark yourself as away'],
|
|
25
28
|
['/back', 'Clear away status'],
|
|
@@ -41,7 +44,8 @@ const COMMAND_INFO = [
|
|
|
41
44
|
['/img', 'Render a high-resolution image'],
|
|
42
45
|
['/sound', 'Sound notifications'],
|
|
43
46
|
['/notify', 'Desktop notifications'],
|
|
44
|
-
['/search', 'Search history'],
|
|
47
|
+
['/search', 'Search history (on disk)'],
|
|
48
|
+
['/find', 'Find in this room and jump to it'],
|
|
45
49
|
['/history', 'Recent messages from history'],
|
|
46
50
|
['/export', 'Export history'],
|
|
47
51
|
['/backup', 'Back up identity + trust'],
|
|
@@ -89,7 +93,9 @@ export const COMMANDS = [
|
|
|
89
93
|
'/sound',
|
|
90
94
|
'/msg',
|
|
91
95
|
'/reply',
|
|
96
|
+
'/me',
|
|
92
97
|
'/mentions',
|
|
98
|
+
'/watch',
|
|
93
99
|
'/contacts',
|
|
94
100
|
'/away',
|
|
95
101
|
'/back',
|
|
@@ -99,11 +105,13 @@ export const COMMANDS = [
|
|
|
99
105
|
'/dnd',
|
|
100
106
|
'/join',
|
|
101
107
|
'/leave',
|
|
108
|
+
'/topic',
|
|
102
109
|
'/create',
|
|
103
110
|
'/rooms',
|
|
104
111
|
'/room',
|
|
105
112
|
'/invite',
|
|
106
113
|
'/search',
|
|
114
|
+
'/find',
|
|
107
115
|
'/history',
|
|
108
116
|
'/export',
|
|
109
117
|
'/backup',
|
|
@@ -314,6 +322,39 @@ export function renderMarkdown(text) {
|
|
|
314
322
|
return out.join('\n');
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Find `query` in rendered chat lines, returning where each hit is plus a
|
|
327
|
+
* readable preview. Blessed tags are stripped before matching so a search for
|
|
328
|
+
* "fg" never matches colour markup, and the preview is centred on the hit
|
|
329
|
+
* instead of always showing the start of a long line. Pure — exported for
|
|
330
|
+
* testing.
|
|
331
|
+
*
|
|
332
|
+
* @returns {Array<{ lineIndex: number, preview: string }>} newest last
|
|
333
|
+
*/
|
|
334
|
+
export function findInLines(lines, query, maxHits = 200) {
|
|
335
|
+
const needle = String(query || '')
|
|
336
|
+
.trim()
|
|
337
|
+
.toLowerCase();
|
|
338
|
+
if (!needle || !Array.isArray(lines)) {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
const hits = [];
|
|
342
|
+
for (let i = 0; i < lines.length && hits.length < maxHits; i++) {
|
|
343
|
+
const plain = String(lines[i]).replace(/\{[^{}]*\}/g, '');
|
|
344
|
+
const at = plain.toLowerCase().indexOf(needle);
|
|
345
|
+
if (at === -1) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const start = Math.max(0, at - 24);
|
|
349
|
+
const slice = plain.slice(start, start + 76).trim();
|
|
350
|
+
hits.push({
|
|
351
|
+
lineIndex: i,
|
|
352
|
+
preview: `${start > 0 ? '…' : ''}${blessed.escape(slice)}`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return hits;
|
|
356
|
+
}
|
|
357
|
+
|
|
317
358
|
// Sanitizes pasted text while PRESERVING its line structure — the input box is
|
|
318
359
|
// multi-line and fenced code blocks render in markdown, so pasted code must
|
|
319
360
|
// keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
|
|
@@ -504,6 +545,12 @@ export class UI extends EventEmitter {
|
|
|
504
545
|
#activeBuffer; // name of the buffer currently on screen
|
|
505
546
|
#redirecting; // true while add* calls are being written to an inactive buffer
|
|
506
547
|
#bufferBar; // [{ room, active, unread, private }] for the status bar
|
|
548
|
+
#topic; // current room topic, shown in the status bar
|
|
549
|
+
#finder; // Ctrl+F scrollback search overlay
|
|
550
|
+
#finderOpen;
|
|
551
|
+
#finderQuery;
|
|
552
|
+
#finderHits; // [{ lineIndex, preview }]
|
|
553
|
+
#finderMark; // line index currently highlighted by a jump
|
|
507
554
|
|
|
508
555
|
constructor(nickname) {
|
|
509
556
|
super();
|
|
@@ -557,6 +604,11 @@ export class UI extends EventEmitter {
|
|
|
557
604
|
this.#activeBuffer = 'general';
|
|
558
605
|
this.#redirecting = false;
|
|
559
606
|
this.#bufferBar = [];
|
|
607
|
+
this.#topic = null;
|
|
608
|
+
this.#finderOpen = false;
|
|
609
|
+
this.#finderQuery = '';
|
|
610
|
+
this.#finderHits = [];
|
|
611
|
+
this.#finderMark = null;
|
|
560
612
|
|
|
561
613
|
// blessed's terminfo parser can't compile the modern Setulc (underline
|
|
562
614
|
// colour) capability that terminals like ghostty ship, so it dumps a
|
|
@@ -658,6 +710,24 @@ export class UI extends EventEmitter {
|
|
|
658
710
|
},
|
|
659
711
|
});
|
|
660
712
|
|
|
713
|
+
// ── Scrollback finder (Ctrl+F) ───────────────────────
|
|
714
|
+
this.#finder = blessed.list({
|
|
715
|
+
parent: this.#screen,
|
|
716
|
+
hidden: true,
|
|
717
|
+
top: 'center',
|
|
718
|
+
left: 'center',
|
|
719
|
+
width: '80%',
|
|
720
|
+
height: '55%',
|
|
721
|
+
tags: true,
|
|
722
|
+
border: { type: 'line' },
|
|
723
|
+
label: ' Find in this room (Ctrl+F) ',
|
|
724
|
+
style: {
|
|
725
|
+
border: { fg: 'cyan' },
|
|
726
|
+
selected: { bg: 'cyan', fg: 'black' },
|
|
727
|
+
item: { fg: 'white' },
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
|
|
661
731
|
// ── Emoji picker (Ctrl+E) ────────────────────────────
|
|
662
732
|
this.#emojiPicker = blessed.list({
|
|
663
733
|
parent: this.#screen,
|
|
@@ -732,6 +802,11 @@ export class UI extends EventEmitter {
|
|
|
732
802
|
return;
|
|
733
803
|
}
|
|
734
804
|
|
|
805
|
+
if (this.#finderOpen) {
|
|
806
|
+
this.#handleFinderKey(ch, key);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
735
810
|
this.#handleKey(ch, key);
|
|
736
811
|
});
|
|
737
812
|
|
|
@@ -773,6 +848,12 @@ export class UI extends EventEmitter {
|
|
|
773
848
|
return;
|
|
774
849
|
}
|
|
775
850
|
|
|
851
|
+
// Ctrl+F — find in the current room's scrollback
|
|
852
|
+
if (key.ctrl && name === 'f') {
|
|
853
|
+
this.openFinder();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
776
857
|
// Tab — autocomplete
|
|
777
858
|
if (name === 'tab') {
|
|
778
859
|
this.#handleTab();
|
|
@@ -1142,6 +1223,104 @@ export class UI extends EventEmitter {
|
|
|
1142
1223
|
}
|
|
1143
1224
|
}
|
|
1144
1225
|
|
|
1226
|
+
// ── Scrollback finder (Ctrl+F) ───────────────────────
|
|
1227
|
+
// Search what is on screen in THIS room and jump to the hit. Unlike
|
|
1228
|
+
// /search (which queries the encrypted history on disk, possibly from other
|
|
1229
|
+
// sessions), every result here has a real line to scroll to.
|
|
1230
|
+
|
|
1231
|
+
/** Open the finder, optionally pre-filled (used by /search results). */
|
|
1232
|
+
openFinder(query = '') {
|
|
1233
|
+
this.#finderOpen = true;
|
|
1234
|
+
this.#finderQuery = query;
|
|
1235
|
+
this.#refreshFinder();
|
|
1236
|
+
this.#finder.show();
|
|
1237
|
+
this.#finder.setFront();
|
|
1238
|
+
this.#screen.render();
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
#closeFinder() {
|
|
1242
|
+
this.#finderOpen = false;
|
|
1243
|
+
this.#finder.hide();
|
|
1244
|
+
this.#screen.render();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
#refreshFinder() {
|
|
1248
|
+
this.#finderHits = findInLines(this.#lines, this.#finderQuery);
|
|
1249
|
+
this.#finder.setItems(
|
|
1250
|
+
this.#finderHits.length > 0
|
|
1251
|
+
? this.#finderHits.map((h) => ` {#8888aa-fg}${h.lineIndex + 1}{/#8888aa-fg} ${h.preview}`)
|
|
1252
|
+
: [
|
|
1253
|
+
this.#finderQuery
|
|
1254
|
+
? ' {#8888aa-fg}no match in this room{/#8888aa-fg}'
|
|
1255
|
+
: ' {#8888aa-fg}type to search…{/#8888aa-fg}',
|
|
1256
|
+
],
|
|
1257
|
+
);
|
|
1258
|
+
this.#finder.select(0);
|
|
1259
|
+
const q = this.#finderQuery ? ` › ${this.#finderQuery}` : '';
|
|
1260
|
+
const n = this.#finderHits.length ? ` — ${this.#finderHits.length} hit(s)` : '';
|
|
1261
|
+
this.#finder.setLabel(` Find in this room (Ctrl+F)${q}${n} `);
|
|
1262
|
+
this.#screen.render();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Scroll the chat to `lineIndex` and mark it so the eye finds it. */
|
|
1266
|
+
jumpToLine(lineIndex) {
|
|
1267
|
+
if (lineIndex < 0 || lineIndex >= this.#lines.length) {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
this.#clearJumpMark();
|
|
1271
|
+
const original = this.#lines[lineIndex];
|
|
1272
|
+
this.#finderMark = { lineIndex, original };
|
|
1273
|
+
this.#lines[lineIndex] = `{yellow-fg}▶{/yellow-fg}${original}`;
|
|
1274
|
+
this.#chatLog.setContent(this.#lines.join('\n'));
|
|
1275
|
+
// Put the hit a few lines from the top so its context stays visible.
|
|
1276
|
+
this.#chatLog.scrollTo(Math.max(0, lineIndex - 3));
|
|
1277
|
+
this.#screen.render();
|
|
1278
|
+
this.#syncScrollState();
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
#clearJumpMark() {
|
|
1283
|
+
if (!this.#finderMark) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const { lineIndex, original } = this.#finderMark;
|
|
1287
|
+
if (this.#lines[lineIndex] !== undefined) {
|
|
1288
|
+
this.#lines[lineIndex] = original;
|
|
1289
|
+
}
|
|
1290
|
+
this.#finderMark = null;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
#handleFinderKey(ch, key) {
|
|
1294
|
+
const name = key.name || '';
|
|
1295
|
+
if (name === 'escape' || (key.ctrl && name === 'c')) {
|
|
1296
|
+
this.#closeFinder();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (name === 'up' || name === 'down') {
|
|
1300
|
+
this.#finder[name === 'up' ? 'up' : 'down'](1);
|
|
1301
|
+
this.#screen.render();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (name === 'return' || name === 'enter') {
|
|
1305
|
+
const hit = this.#finderHits[this.#finder.selected];
|
|
1306
|
+
this.#closeFinder();
|
|
1307
|
+
if (hit) {
|
|
1308
|
+
this.jumpToLine(hit.lineIndex);
|
|
1309
|
+
}
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (name === 'backspace') {
|
|
1313
|
+
this.#finderQuery = this.#finderQuery.slice(0, -1);
|
|
1314
|
+
this.#refreshFinder();
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const code = ch ? ch.charCodeAt(0) : 0;
|
|
1318
|
+
if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
|
|
1319
|
+
this.#finderQuery += ch;
|
|
1320
|
+
this.#refreshFinder();
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1145
1324
|
// ── Emoji picker (Ctrl+E, fuzzy) ─────────────────────
|
|
1146
1325
|
#openEmoji() {
|
|
1147
1326
|
this.#emojiOpen = true;
|
|
@@ -1416,14 +1595,22 @@ export class UI extends EventEmitter {
|
|
|
1416
1595
|
} else {
|
|
1417
1596
|
room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
|
|
1418
1597
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1598
|
+
// The topic earns its place next to the room name; it is what tells you
|
|
1599
|
+
// what a room is FOR. Truncated so it can never push the hints off-screen.
|
|
1600
|
+
const topic = this.#topic
|
|
1601
|
+
? ` {#9a9ad0-fg}📋 ${blessed.escape(
|
|
1602
|
+
this.#topic.length > 60 ? `${this.#topic.slice(0, 57)}…` : this.#topic,
|
|
1603
|
+
)}{/#9a9ad0-fg}`
|
|
1421
1604
|
: '';
|
|
1605
|
+
const fp =
|
|
1606
|
+
this.#statusFingerprint && !this.#topic
|
|
1607
|
+
? ` {#8888aa-fg}🔑 ${this.#statusFingerprint}{/#8888aa-fg}`
|
|
1608
|
+
: '';
|
|
1422
1609
|
const hint =
|
|
1423
1610
|
this.#bufferBar.length > 1
|
|
1424
1611
|
? '{#7777aa-fg}Alt+1..9 buffers · Ctrl+K commands · /help{/#7777aa-fg}'
|
|
1425
1612
|
: '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
|
|
1426
|
-
return ` ${room}${fp} {|} ${hint} `;
|
|
1613
|
+
return ` ${room}${topic}${fp} {|} ${hint} `;
|
|
1427
1614
|
}
|
|
1428
1615
|
|
|
1429
1616
|
#updateStatusBar() {
|
|
@@ -1433,6 +1620,12 @@ export class UI extends EventEmitter {
|
|
|
1433
1620
|
}
|
|
1434
1621
|
}
|
|
1435
1622
|
|
|
1623
|
+
/** Set (or clear with null) the topic shown in the status bar. */
|
|
1624
|
+
setTopic(text) {
|
|
1625
|
+
this.#topic = text || null;
|
|
1626
|
+
this.#updateStatusBar();
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1436
1629
|
setFingerprint(fingerprint) {
|
|
1437
1630
|
// short prefix of the fingerprint as a persistent identity anchor
|
|
1438
1631
|
this.#statusFingerprint = (fingerprint || '').slice(0, 17);
|
|
@@ -1673,6 +1866,20 @@ export class UI extends EventEmitter {
|
|
|
1673
1866
|
this.updateLine(lineIndex, line);
|
|
1674
1867
|
}
|
|
1675
1868
|
|
|
1869
|
+
// Third-person action (/me). Rendered as a distinct italic line so it never
|
|
1870
|
+
// reads like someone quoting themselves.
|
|
1871
|
+
addActionMessage(nickname, text) {
|
|
1872
|
+
this.#lastSender = null; // an action breaks message grouping
|
|
1873
|
+
const line = ` {white-fg}[${time()}]{/white-fg} {magenta-fg}✦ {bold}${blessed.escape(
|
|
1874
|
+
nickname,
|
|
1875
|
+
)}{/bold} ${renderMarkdown(text)}{/magenta-fg}`;
|
|
1876
|
+
this.#lines.push(line);
|
|
1877
|
+
this.#chatLog.log(line);
|
|
1878
|
+
this.#screen.render();
|
|
1879
|
+
this.#noteIncoming();
|
|
1880
|
+
return { lineIndex: this.#lines.length - 1 };
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1676
1883
|
addSystemMessage(text) {
|
|
1677
1884
|
this.#lastSender = null; // interrupts message grouping
|
|
1678
1885
|
const line = ` {white-fg}[${time()}] * ${blessed.escape(text)}{/white-fg}`;
|
|
@@ -17,6 +17,7 @@ export const PinResult = {
|
|
|
17
17
|
export class CertPinStore {
|
|
18
18
|
#path;
|
|
19
19
|
#store; // Map<host, sha256Fingerprint>
|
|
20
|
+
#caHosts; // Set<host> — hosts that already presented a CA-valid certificate
|
|
20
21
|
|
|
21
22
|
constructor(baseDir = process.cwd()) {
|
|
22
23
|
const dir = join(baseDir, PIN_DIR);
|
|
@@ -25,6 +26,7 @@ export class CertPinStore {
|
|
|
25
26
|
}
|
|
26
27
|
this.#path = join(dir, PIN_FILE);
|
|
27
28
|
this.#store = new Map();
|
|
29
|
+
this.#caHosts = new Set();
|
|
28
30
|
this.#load();
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -32,22 +34,56 @@ export class CertPinStore {
|
|
|
32
34
|
try {
|
|
33
35
|
if (existsSync(this.#path)) {
|
|
34
36
|
const data = JSON.parse(readFileSync(this.#path, 'utf-8'));
|
|
35
|
-
for (const [host,
|
|
36
|
-
|
|
37
|
+
for (const [host, entry] of Object.entries(data)) {
|
|
38
|
+
// v1 stored a bare fingerprint string; v2 stores { fp, ca }.
|
|
39
|
+
if (typeof entry === 'string') {
|
|
40
|
+
this.#store.set(host, entry);
|
|
41
|
+
} else if (entry && typeof entry === 'object') {
|
|
42
|
+
if (typeof entry.fp === 'string') {
|
|
43
|
+
this.#store.set(host, entry.fp);
|
|
44
|
+
}
|
|
45
|
+
if (entry.ca) {
|
|
46
|
+
this.#caHosts.add(host);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
} catch {
|
|
40
52
|
this.#store = new Map();
|
|
53
|
+
this.#caHosts = new Set();
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
#save() {
|
|
45
|
-
|
|
58
|
+
const obj = {};
|
|
59
|
+
// Union of both maps: a host can be CA-validated before (or without) ever
|
|
60
|
+
// having a pinned fingerprint, and that flag must survive a restart.
|
|
61
|
+
for (const host of new Set([...this.#store.keys(), ...this.#caHosts])) {
|
|
62
|
+
obj[host] = { fp: this.#store.get(host) || null, ca: this.#caHosts.has(host) };
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(this.#path, JSON.stringify(obj, null, 2), {
|
|
46
65
|
encoding: 'utf-8',
|
|
47
66
|
mode: 0o600,
|
|
48
67
|
});
|
|
49
68
|
}
|
|
50
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Remember that a host served a certificate that chains to a public CA.
|
|
72
|
+
* Subsequent connections to it demand full TLS verification, so a hosted
|
|
73
|
+
* relay can never be silently downgraded to a self-signed (attacker) cert.
|
|
74
|
+
*/
|
|
75
|
+
markCAValidated(host) {
|
|
76
|
+
if (!this.#caHosts.has(host)) {
|
|
77
|
+
this.#caHosts.add(host);
|
|
78
|
+
this.#save();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if this host must be verified strictly (it was CA-valid before). */
|
|
83
|
+
requiresStrictTLS(host) {
|
|
84
|
+
return this.#caHosts.has(host);
|
|
85
|
+
}
|
|
86
|
+
|
|
51
87
|
/**
|
|
52
88
|
* Check a server's cert fingerprint against the pinned one (pinning on first use).
|
|
53
89
|
* @param {string} host - e.g. "100.73.206.23:3600"
|