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 CHANGED
@@ -95,6 +95,12 @@ Prefer a prebuilt image? Pull the relay from GHCR (published on each release):
95
95
  docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
96
96
  ```
97
97
 
98
+ **Hosting it publicly?** [`deploy/`](deploy/README.md) has a Caddy + Docker
99
+ setup that gets a Let's Encrypt certificate automatically — clients then verify
100
+ the server against a real CA (no trust-on-first-use window), and a host that
101
+ once served a valid certificate can never be silently downgraded to a
102
+ self-signed one.
103
+
98
104
  **No Node on the host?** Every release ships a standalone relay binary for
99
105
  macOS and Linux (arm64/x64) — download it from the
100
106
  [releases page](https://github.com/FelipeKreulich/secret-chat-lan/releases),
@@ -116,7 +122,10 @@ and use the `Internet` address from the banner — full walkthrough in
116
122
  Already in the chat? Run `/invite <your-ip>:3600` and send the string (or the
117
123
  QR code) to whoever you want to pull in.
118
124
 
119
- **No server at all?** `npm run p2p` — peers find each other via mDNS.
125
+ **No server at all?** `npm run p2p` — peers find each other via mDNS. P2P mode
126
+ now speaks nearly the same command set as the relay client (presence, `/lock`,
127
+ `/contacts`, `/mentions`, `/topic`, encrypted history and more); only
128
+ `/invite`, `/create` and `/nick` are relay-specific by nature.
120
129
 
121
130
  ## 💬 Commands
122
131
 
@@ -130,6 +139,8 @@ QR code) to whoever you want to pull in.
130
139
  | `/users` | Who's online (with away/status) |
131
140
  | `/msg <nick> <text>` | Private message (DM) |
132
141
  | `/reply <text>` | Reply quoting the last received message |
142
+ | `/me <action>` | Third-person action — *«felipe is compiling»* |
143
+ | `/watch [add\|remove\|clear]` | Alert on a keyword in **any** room, like a mention |
133
144
  | `/invite [host:port]` | Generate a `ciphermesh://` invite + QR code |
134
145
  | `/nick <new>` | Change nickname (before joining — recovers from "nickname taken") |
135
146
  | `/quit` | Leave |
@@ -146,6 +157,7 @@ QR code) to whoever you want to pull in.
146
157
  | `/create <room> <password>` | Create a **private room** 🔒 — see below |
147
158
  | `/rooms` | List rooms (🔒 marks private ones) |
148
159
  | `/room` | Current room + your buffer list |
160
+ | `/topic [text\|clear]` | Show or set the room topic — shown in the status bar and synced to whoever joins later |
149
161
  | `/owner` | Room owner |
150
162
  | `/kick` `/mute` `/ban` | Owner moderation |
151
163
 
@@ -198,7 +210,8 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
198
210
  | `/play [path]` | Play the last received voice note (`afplay`/`sox`/`ffplay`) |
199
211
  | `/accept [id]` / `/reject [id]` | Accept / decline an incoming file offer |
200
212
  | `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
201
- | `/search <term>` | Search the encrypted local history |
213
+ | `/search <term>` | Search the encrypted local history (on disk, across sessions) |
214
+ | `/find [term]` — **Ctrl+F** | Search **this room's scrollback** and press Enter to **jump to the message**, highlighted |
202
215
  | `/history [n]` | Last n messages from history |
203
216
  | `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
204
217
  | `/export [path]` | Export history as .txt or .json (plaintext!) |
package/README.pt-BR.md CHANGED
@@ -95,6 +95,12 @@ Prefere imagem pronta? Baixe o relay do GHCR (publicado a cada release):
95
95
  docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
96
96
  ```
97
97
 
98
+ **Vai hospedar publicamente?** A pasta [`deploy/`](deploy/README.md) tem um
99
+ setup com Caddy + Docker que pega certificado Let's Encrypt sozinho — aí os
100
+ clientes verificam o servidor contra uma CA de verdade (sem janela de
101
+ trust-on-first-use), e um host que já apresentou certificado válido nunca pode
102
+ ser rebaixado silenciosamente para um self-signed.
103
+
98
104
  **Sem Node na máquina?** Todo release traz um binário standalone do relay para
99
105
  macOS e Linux (arm64/x64) — baixe da
100
106
  [página de releases](https://github.com/FelipeKreulich/secret-chat-lan/releases),
@@ -116,7 +122,10 @@ em [docs/SETUP.md](docs/SETUP.md).
116
122
  Já está no chat? Rode `/invite <seu-ip>:3600` e mande a string (ou o QR) pra
117
123
  quem você quiser puxar pra conversa.
118
124
 
119
- **Sem servidor nenhum?** `npm run p2p` — os peers se encontram via mDNS.
125
+ **Sem servidor nenhum?** `npm run p2p` — os peers se encontram via mDNS. O modo
126
+ P2P agora fala quase o mesmo conjunto de comandos do cliente relay (presença,
127
+ `/lock`, `/contacts`, `/mentions`, `/topic`, histórico cifrado e mais); só
128
+ `/invite`, `/create` e `/nick` são específicos do relay por natureza.
120
129
 
121
130
  ## 💬 Comandos
122
131
 
@@ -130,6 +139,8 @@ quem você quiser puxar pra conversa.
130
139
  | `/users` | Quem está online (com away/status) |
131
140
  | `/msg <nick> <texto>` | Mensagem privada (DM) |
132
141
  | `/reply <texto>` | Responde citando a última mensagem recebida |
142
+ | `/me <ação>` | Ação em terceira pessoa — *«felipe está compilando»* |
143
+ | `/watch [add\|remove\|clear]` | Alerta quando uma palavra aparece em **qualquer** sala, como uma menção |
133
144
  | `/invite [host:porta]` | Gera convite `ciphermesh://` + QR code |
134
145
  | `/nick <novo>` | Troca de apelido (antes de entrar — recupera de "apelido em uso") |
135
146
  | `/quit` | Sair |
@@ -146,6 +157,7 @@ quem você quiser puxar pra conversa.
146
157
  | `/create <sala> <senha>` | Cria uma **sala privada** 🔒 — veja abaixo |
147
158
  | `/rooms` | Lista salas (🔒 marca as privadas) |
148
159
  | `/room` | Sala atual + sua lista de buffers |
160
+ | `/topic [texto\|clear]` | Mostra ou define o assunto da sala — aparece na barra de status e é sincronizado para quem entra depois |
149
161
  | `/owner` | Dono da sala |
150
162
  | `/kick` `/mute` `/ban` | Moderação (dono da sala) |
151
163
 
@@ -199,7 +211,8 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
199
211
  | `/accept [id]` / `/reject [id]` | Aceita / recusa uma oferta de arquivo recebida |
200
212
  | `/img [caminho]` | Renderiza a última imagem recebida em **alta resolução** (kitty/iTerm2) |
201
213
  | `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
202
- | `/search <termo>` | Busca no histórico local cifrado |
214
+ | `/search <termo>` | Busca no histórico local cifrado (em disco, entre sessões) |
215
+ | `/find [termo]` — **Ctrl+F** | Busca **no histórico da sala na tela** e, com Enter, **salta para a mensagem** destacada |
203
216
  | `/history [n]` | Últimas n mensagens do histórico |
204
217
  | `/export [caminho]` | Exporta o histórico em .txt ou .json (texto plano!) |
205
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.3.0",
3
+ "version": "2.4.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",
@@ -52,7 +52,13 @@ import { tipAt, TIPS } from '../shared/tips.js';
52
52
  import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
53
53
  import { panicWipe } from '../shared/panic.js';
54
54
  import { farewellBanner } from '../shared/banner.js';
55
- import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
55
+ import {
56
+ parseDndWindow,
57
+ shouldNotify,
58
+ nowMinutes,
59
+ mentionsMe,
60
+ matchesKeyword,
61
+ } from '../shared/dnd.js';
56
62
  import { saveLastSession } from '../shared/lastSession.js';
57
63
  import { COMMANDS } from './UI.js';
58
64
 
@@ -111,6 +117,7 @@ export class ChatController {
111
117
  #autoAwayTimer = null;
112
118
  #autoAwaySet = false; // whether the current away was set automatically
113
119
  #mentions = []; // session mention log: { nickname, text, room, at }
120
+ #watchWords = new Set(); // /watch — keywords that alert like a mention does
114
121
  #awayUnread = 0; // messages received while away
115
122
  #awayMentions = 0; // …of which mentioned me
116
123
  #autoLockMs = 0; // idle screen-lock timeout (0 = off)
@@ -232,6 +239,12 @@ export class ChatController {
232
239
  this.#ui.addSystemMessage(`Reconnecting in ${delay / 1000}s...`);
233
240
  });
234
241
 
242
+ this.#connection.on('cert-ca-valid', ({ issuer }) => {
243
+ this.#ui.addSystemMessage(
244
+ `TLS verified against a public CA (${issuer}) — no trust-on-first-use window`,
245
+ );
246
+ });
247
+
235
248
  this.#connection.on('cert-pinned', ({ fingerprint }) => {
236
249
  const fp = fingerprint ? fingerprint.slice(0, 17) + '...' : '?';
237
250
  this.#ui.addSystemMessage(`Server certificate pinned (trust on first use): ${fp}`);
@@ -466,6 +479,13 @@ export class ChatController {
466
479
  this.#ui.addErrorMessage(
467
480
  `${msg.message}. Use /nick <other> to pick a different nickname.`,
468
481
  );
482
+ } else if (typeof msg.message === 'string' && msg.message.startsWith('Protocol mismatch')) {
483
+ // Reconnecting cannot fix a version gap — say so instead of letting
484
+ // the user watch an endless retry loop.
485
+ this.#ui.addErrorMessage(msg.message);
486
+ this.#ui.addInfoMessage(
487
+ 'Reconnecting will not help until both sides run the same version.',
488
+ );
469
489
  } else if (msg.code === ERR.ROOM_AUTH_FAILED || msg.code === ERR.ROOM_EXISTS) {
470
490
  // Join/create refused — drop the derived secrets for that attempt.
471
491
  freeRoomSecrets(this.#pendingRoomSecrets);
@@ -592,6 +612,7 @@ export class ChatController {
592
612
  owner,
593
613
  secrets,
594
614
  pins: [],
615
+ topic: null, // { text, by, at } — E2EE among members, never on the relay
595
616
  });
596
617
  this.#bufferOrder.push(room);
597
618
  }
@@ -645,6 +666,7 @@ export class ChatController {
645
666
  this.#currentRoomOwner = buf.owner;
646
667
  this.#ui.switchBuffer(room);
647
668
  this.#rebuildActivePeers();
669
+ this.#applyTopicToUI();
648
670
  this.#updateBufferBar();
649
671
  this.#updatePrivateIndicator();
650
672
  this.#saveLastSession(buf.private);
@@ -663,6 +685,11 @@ export class ChatController {
663
685
  this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
664
686
  }
665
687
 
688
+ #applyTopicToUI() {
689
+ const topic = this.#buffers.get(this.#currentRoom)?.topic;
690
+ this.#ui.setTopic(topic?.text || null);
691
+ }
692
+
666
693
  #updateBufferBar() {
667
694
  this.#ui.setBufferBar(
668
695
  this.#bufferOrder.map((room) => {
@@ -761,6 +788,23 @@ export class ChatController {
761
788
  if (this.#away || this.#statusText) {
762
789
  this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
763
790
  }
791
+
792
+ // …nor the room topic. Everyone who has it answers; the timestamp settles
793
+ // any disagreement, and `silent` keeps the sync out of the chat log.
794
+ const topic = this.#buffers.get(room)?.topic;
795
+ if (topic) {
796
+ this.#sendPayloadToPeer(
797
+ peer.sessionId,
798
+ JSON.stringify({
799
+ action: 'set_topic',
800
+ room,
801
+ text: topic.text,
802
+ at: topic.at,
803
+ silent: true,
804
+ sentAt: Date.now(),
805
+ }),
806
+ );
807
+ }
764
808
  }
765
809
 
766
810
  // One-time-per-session nudge to verify an unverified peer's identity.
@@ -1144,6 +1188,33 @@ export class ChatController {
1144
1188
  return;
1145
1189
  }
1146
1190
 
1191
+ if (data.action === 'set_topic') {
1192
+ const buf = this.#buffers.get(msgRoom);
1193
+ if (buf && typeof data.text === 'string') {
1194
+ const at = Number(data.at) || 0;
1195
+ // Last write wins. On join everyone who knows the topic answers, so
1196
+ // the timestamp is what keeps those replies from fighting.
1197
+ if (!buf.topic || at >= buf.topic.at) {
1198
+ const text = data.text.slice(0, 200);
1199
+ const changed = buf.topic?.text !== text;
1200
+ buf.topic = { text, by: peer.nickname, at };
1201
+ if (msgRoom === this.#currentRoom) {
1202
+ this.#applyTopicToUI();
1203
+ }
1204
+ if (changed && !data.silent) {
1205
+ this.#ui.toBuffer(msgRoom, () => {
1206
+ this.#ui.addSystemMessage(
1207
+ text
1208
+ ? `📋 ${peer.nickname} set the topic of #${msgRoom}: ${text}`
1209
+ : `📋 ${peer.nickname} cleared the topic of #${msgRoom}`,
1210
+ );
1211
+ });
1212
+ }
1213
+ }
1214
+ }
1215
+ return;
1216
+ }
1217
+
1147
1218
  if (data.action === 'pin_message') {
1148
1219
  const pins = roomActive ? this.#pinnedMessages : this.#buffers.get(msgRoom)?.pins;
1149
1220
  pins?.push({
@@ -1194,7 +1265,14 @@ export class ChatController {
1194
1265
  });
1195
1266
  }
1196
1267
 
1197
- const mentioned = this.#mentionsMe(data.text) && !data.isDM;
1268
+ const watchHit = this.#matchedWatch(data.text);
1269
+ // A watched keyword deserves the same attention a mention gets.
1270
+ const mentioned = (this.#mentionsMe(data.text) || !!watchHit) && !data.isDM;
1271
+ if (watchHit) {
1272
+ this.#ui.toBuffer(msgRoom, () => {
1273
+ this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${peer.nickname} in #${msgRoom}`);
1274
+ });
1275
+ }
1198
1276
  if (mentioned) {
1199
1277
  this.#mentions.push({
1200
1278
  nickname: peer.nickname,
@@ -1225,15 +1303,17 @@ export class ChatController {
1225
1303
  if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
1226
1304
  this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
1227
1305
  }
1228
- ({ lineIndex } = this.#ui.addMessage(
1229
- peer.nickname,
1230
- data.text,
1231
- !!data.isDM,
1232
- ephLabel,
1233
- isDeniable || !!data.deniable,
1234
- mentioned,
1235
- trust,
1236
- ));
1306
+ ({ lineIndex } = data.isAction
1307
+ ? this.#ui.addActionMessage(peer.nickname, data.text)
1308
+ : this.#ui.addMessage(
1309
+ peer.nickname,
1310
+ data.text,
1311
+ !!data.isDM,
1312
+ ephLabel,
1313
+ isDeniable || !!data.deniable,
1314
+ mentioned,
1315
+ trust,
1316
+ ));
1237
1317
  });
1238
1318
  this.#noteBufferUnread(msgRoom, mentioned);
1239
1319
  const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
@@ -1288,6 +1368,16 @@ export class ChatController {
1288
1368
  return mentionsMe(text, this.#nickname);
1289
1369
  }
1290
1370
 
1371
+ // The first /watch keyword present in the text, or null.
1372
+ #matchedWatch(text) {
1373
+ for (const word of this.#watchWords) {
1374
+ if (matchesKeyword(text, word)) {
1375
+ return word;
1376
+ }
1377
+ }
1378
+ return null;
1379
+ }
1380
+
1291
1381
  // ── User input handling ───────────────────────────────────────
1292
1382
  #handleUserInput(text) {
1293
1383
  this.#noteActive();
@@ -1311,7 +1401,9 @@ export class ChatController {
1311
1401
  this.#ui.addInfoMessage(' /users - List online users');
1312
1402
  this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
1313
1403
  this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
1404
+ this.#ui.addInfoMessage(' /me <action> - Third-person action message');
1314
1405
  this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you (this session)');
1406
+ this.#ui.addInfoMessage(' /watch [add|remove|clear] - Alert on a keyword in any room');
1315
1407
  this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book (aliases for peers)');
1316
1408
  this.#ui.addInfoMessage(
1317
1409
  ' /away [reason] - Mark yourself as away (unreads are counted)',
@@ -1323,6 +1415,7 @@ export class ChatController {
1323
1415
  this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
1324
1416
  this.#ui.addInfoMessage(' /join <room> [pass] - Join a room as a new buffer (Alt+1..9)');
1325
1417
  this.#ui.addInfoMessage(' /leave [room] - Leave a room (its buffer closes)');
1418
+ this.#ui.addInfoMessage(' /topic [text|clear] - Show or set the room topic');
1326
1419
  this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
1327
1420
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
1328
1421
  this.#ui.addInfoMessage(' /rooms - List available rooms');
@@ -1346,6 +1439,7 @@ export class ChatController {
1346
1439
  ' /dnd [on|off|mentions|HH:MM-HH:MM] - Do not disturb / mentions only',
1347
1440
  );
1348
1441
  this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
1442
+ this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
1349
1443
  this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
1350
1444
  this.#ui.addInfoMessage(' /export [path] - Export the history (.txt or .json)');
1351
1445
  this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
@@ -1818,6 +1912,83 @@ export class ChatController {
1818
1912
  break;
1819
1913
  }
1820
1914
 
1915
+ case '/topic': {
1916
+ const buf = this.#buffers.get(this.#currentRoom);
1917
+ const newTopic = parts.slice(1).join(' ').trim();
1918
+
1919
+ if (!newTopic) {
1920
+ const t = buf?.topic;
1921
+ this.#ui.addInfoMessage(
1922
+ t?.text
1923
+ ? `Topic of #${this.#currentRoom}: ${t.text} (set by ${t.by})`
1924
+ : `#${this.#currentRoom} has no topic. Set one with /topic <text>`,
1925
+ );
1926
+ break;
1927
+ }
1928
+
1929
+ const text = newTopic === 'clear' ? '' : applyShortcodes(newTopic).slice(0, 200);
1930
+ const at = Date.now();
1931
+ if (buf) {
1932
+ buf.topic = { text, by: this.#nickname, at };
1933
+ }
1934
+ this.#applyTopicToUI();
1935
+ this.#paceOrSend(
1936
+ JSON.stringify({ action: 'set_topic', room: this.#currentRoom, text, at, sentAt: at }),
1937
+ );
1938
+ this.#ui.addSystemMessage(
1939
+ text ? `📋 You set the topic: ${text}` : '📋 You cleared the topic',
1940
+ );
1941
+ break;
1942
+ }
1943
+
1944
+ case '/me': {
1945
+ const actionText = parts.slice(1).join(' ').trim();
1946
+ if (!actionText) {
1947
+ this.#ui.addErrorMessage('Usage: /me <action> — e.g. /me is compiling');
1948
+ break;
1949
+ }
1950
+ this.#sendMessageToAll(actionText, null, true);
1951
+ break;
1952
+ }
1953
+
1954
+ case '/watch': {
1955
+ const sub = (parts[1] || 'list').toLowerCase();
1956
+ if (sub === 'add') {
1957
+ const word = parts.slice(2).join(' ').trim();
1958
+ if (!word) {
1959
+ this.#ui.addErrorMessage('Usage: /watch add <keyword>');
1960
+ break;
1961
+ }
1962
+ this.#watchWords.add(word.toLowerCase());
1963
+ this.#ui.addInfoMessage(`Watching for "${word.toLowerCase()}" in every room`);
1964
+ break;
1965
+ }
1966
+ if (sub === 'remove' || sub === 'rm') {
1967
+ const word = parts.slice(2).join(' ').trim().toLowerCase();
1968
+ if (this.#watchWords.delete(word)) {
1969
+ this.#ui.addInfoMessage(`No longer watching "${word}"`);
1970
+ } else {
1971
+ this.#ui.addErrorMessage(`"${word}" is not being watched`);
1972
+ }
1973
+ break;
1974
+ }
1975
+ if (sub === 'clear') {
1976
+ this.#watchWords.clear();
1977
+ this.#ui.addInfoMessage('Watch list cleared');
1978
+ break;
1979
+ }
1980
+ if (sub !== 'list') {
1981
+ this.#ui.addErrorMessage('Usage: /watch [add <word> | remove <word> | clear]');
1982
+ break;
1983
+ }
1984
+ if (this.#watchWords.size === 0) {
1985
+ this.#ui.addInfoMessage('Not watching any keyword. Use /watch add <word>');
1986
+ break;
1987
+ }
1988
+ this.#ui.addInfoMessage(`Watching: ${[...this.#watchWords].join(', ')}`);
1989
+ break;
1990
+ }
1991
+
1821
1992
  case '/mentions': {
1822
1993
  if (this.#mentions.length === 0) {
1823
1994
  this.#ui.addInfoMessage('No mentions in this session yet.');
@@ -2000,6 +2171,13 @@ export class ChatController {
2000
2171
  break;
2001
2172
  }
2002
2173
 
2174
+ case '/find': {
2175
+ const term = parts.slice(1).join(' ').trim();
2176
+ // Opens the same overlay as Ctrl+F, pre-filled when a term is given.
2177
+ this.#ui.openFinder(term);
2178
+ break;
2179
+ }
2180
+
2003
2181
  case '/search': {
2004
2182
  if (!this.#historyStore?.isOpen) {
2005
2183
  this.#ui.addErrorMessage('History disabled — start the client with a passphrase');
@@ -2019,6 +2197,11 @@ export class ChatController {
2019
2197
  for (const e of results) {
2020
2198
  this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
2021
2199
  }
2200
+ // /search reads the on-disk history (possibly from other sessions);
2201
+ // point at the navigable finder for what is actually on screen.
2202
+ this.#ui.addInfoMessage(
2203
+ `Tip: /find ${term} (or Ctrl+F) searches this room's scrollback and jumps to the message.`,
2204
+ );
2022
2205
  break;
2023
2206
  }
2024
2207
 
@@ -3090,7 +3273,7 @@ export class ChatController {
3090
3273
  }
3091
3274
 
3092
3275
  // ── Send encrypted message to all peers ───────────────────────
3093
- #sendMessageToAll(text, replyTo = null) {
3276
+ #sendMessageToAll(text, replyTo = null, isAction = false) {
3094
3277
  if (!this.#connection.connected) {
3095
3278
  this.#ui.addErrorMessage('No connection to the server — message not sent');
3096
3279
  return;
@@ -3107,6 +3290,9 @@ export class ChatController {
3107
3290
  sentAt: Date.now(),
3108
3291
  messageId,
3109
3292
  };
3293
+ if (isAction) {
3294
+ msgObj.isAction = true;
3295
+ }
3110
3296
  if (replyTo) {
3111
3297
  msgObj.replyTo = replyTo;
3112
3298
  }
@@ -3135,13 +3321,9 @@ export class ChatController {
3135
3321
  this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
3136
3322
  }
3137
3323
  const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
3138
- const { lineIndex } = this.#ui.addMessage(
3139
- this.#nickname,
3140
- text,
3141
- false,
3142
- ephLabel,
3143
- this.#deniableMode,
3144
- );
3324
+ const { lineIndex } = isAction
3325
+ ? this.#ui.addActionMessage(this.#nickname, text)
3326
+ : this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
3145
3327
 
3146
3328
  if (this.#ephemeralMode) {
3147
3329
  this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
@@ -11,6 +11,7 @@ export class Connection extends EventEmitter {
11
11
  #connected;
12
12
  #pinStore;
13
13
  #host;
14
+ #caValidated = false;
14
15
 
15
16
  constructor(url) {
16
17
  super();
@@ -28,6 +29,12 @@ export class Connection extends EventEmitter {
28
29
 
29
30
  // Trust-on-first-use pin of the server TLS certificate. Emits 'cert-pinned'
30
31
  // on first sight and 'cert-mismatch' if it later changes (possible MITM).
32
+ //
33
+ // A publicly hosted relay presents a cert signed by a real CA (Let's Encrypt
34
+ // et al). Node still runs the full chain + hostname check even with
35
+ // rejectUnauthorized:false, so `socket.authorized` tells us which world we
36
+ // are in: CA-valid needs no TOFU window at all (strictly stronger), while a
37
+ // self-signed LAN cert keeps the pin-on-first-use behaviour.
31
38
  #checkCertPin() {
32
39
  if (!this.#url.startsWith('wss://')) {
33
40
  return;
@@ -35,6 +42,18 @@ export class Connection extends EventEmitter {
35
42
  const socket = this.#ws?._socket;
36
43
  const cert = socket?.getPeerCertificate?.();
37
44
  const fingerprint = cert?.fingerprint256 || null;
45
+
46
+ if (socket?.authorized === true) {
47
+ this.#caValidated = true;
48
+ // Remember it: from now on this host is verified strictly, and a
49
+ // legitimate CA cert renewal no longer trips the TOFU alarm.
50
+ this.#pinStore.markCAValidated(this.#host);
51
+ this.#pinStore.repin(this.#host, fingerprint);
52
+ this.emit('cert-ca-valid', { host: this.#host, issuer: cert?.issuer?.O || 'CA' });
53
+ return;
54
+ }
55
+
56
+ this.#caValidated = false;
38
57
  const result = this.#pinStore.check(this.#host, fingerprint);
39
58
  if (result === PinResult.PINNED) {
40
59
  this.emit('cert-pinned', { host: this.#host, fingerprint });
@@ -53,7 +72,13 @@ export class Connection extends EventEmitter {
53
72
  }
54
73
 
55
74
  #createSocket() {
56
- const opts = this.#url.startsWith('wss://') ? { rejectUnauthorized: false } : {};
75
+ // Hosts that already proved they have a CA-signed certificate are verified
76
+ // strictly from then on — an attacker cannot downgrade a hosted relay to a
77
+ // self-signed cert. Everything else (LAN/Tailscale self-signed) connects
78
+ // with verification relaxed and is protected by TOFU pinning instead.
79
+ const opts = this.#url.startsWith('wss://')
80
+ ? { rejectUnauthorized: this.#pinStore.requiresStrictTLS(this.#host) }
81
+ : {};
57
82
  this.#ws = new WebSocket(this.#url, opts);
58
83
 
59
84
  this.#ws.on('open', () => {
@@ -104,6 +129,11 @@ export class Connection extends EventEmitter {
104
129
  }, this.#reconnectDelay);
105
130
  }
106
131
 
132
+ /** True when the server's TLS cert validated against a public CA. */
133
+ get isCAValidated() {
134
+ return this.#caValidated;
135
+ }
136
+
107
137
  send(msg) {
108
138
  if (this.#connected && this.#ws.readyState === WebSocket.OPEN) {
109
139
  this.#ws.send(JSON.stringify(msg));
@@ -71,7 +71,38 @@ export class FileTransfer {
71
71
  // Compute SHA-256
72
72
  const sha256 = await this.#computeSHA256(absPath);
73
73
 
74
- // Send file_offer
74
+ // Register the transfer BEFORE announcing it. The peer can accept the
75
+ // instant the offer lands — on a fast LAN that easily beats reading a
76
+ // large file off disk — and an accept that finds no transfer would be
77
+ // dropped silently, killing the transfer at the accept timeout.
78
+ let resolveP;
79
+ const done = new Promise((r) => {
80
+ resolveP = r;
81
+ });
82
+
83
+ const acceptTimer = setTimeout(() => {
84
+ this.#outgoing.delete(transferId);
85
+ callbacks.onError(`${fileName}: offer was not accepted in time`);
86
+ resolveP();
87
+ }, this.#acceptTimeoutMs);
88
+ if (acceptTimer.unref) {
89
+ acceptTimer.unref();
90
+ }
91
+
92
+ this.#outgoing.set(transferId, {
93
+ interval: null,
94
+ resolve: resolveP,
95
+ chunks: null, // filled in once the file is read
96
+ skip: new Set(),
97
+ pending: true,
98
+ acceptedEarly: false, // accepted while we were still reading
99
+ acceptTimer,
100
+ broadcastFn,
101
+ callbacks,
102
+ fileName,
103
+ totalChunks,
104
+ });
105
+
75
106
  broadcastFn({
76
107
  action: 'file_offer',
77
108
  transferId,
@@ -87,29 +118,19 @@ export class FileTransfer {
87
118
  // (so files are never pushed without consent).
88
119
  const chunks = await this.#readChunks(absPath);
89
120
 
90
- return new Promise((resolveP) => {
91
- const acceptTimer = setTimeout(() => {
92
- this.#outgoing.delete(transferId);
93
- callbacks.onError(`${fileName}: offer was not accepted in time`);
94
- resolveP();
95
- }, this.#acceptTimeoutMs);
96
- if (acceptTimer.unref) {
97
- acceptTimer.unref();
98
- }
121
+ const transfer = this.#outgoing.get(transferId);
122
+ if (!transfer) {
123
+ return done; // rejected or timed out while we were reading
124
+ }
125
+ transfer.chunks = chunks;
99
126
 
100
- this.#outgoing.set(transferId, {
101
- interval: null,
102
- resolve: resolveP,
103
- chunks,
104
- skip: new Set(),
105
- pending: true,
106
- acceptTimer,
107
- broadcastFn,
108
- callbacks,
109
- fileName,
110
- totalChunks,
111
- });
112
- });
127
+ // The peer already said yes — start now that the bytes are ready.
128
+ if (transfer.acceptedEarly) {
129
+ transfer.pending = false;
130
+ this.#beginStreaming(transferId);
131
+ }
132
+
133
+ return done;
113
134
  }
114
135
 
115
136
  /** Receiver accepted the offer — start streaming (honouring resume `have`). */
@@ -118,7 +139,6 @@ export class FileTransfer {
118
139
  if (!transfer || !transfer.pending) {
119
140
  return;
120
141
  }
121
- transfer.pending = false;
122
142
  clearTimeout(transfer.acceptTimer);
123
143
  if (Array.isArray(data.have)) {
124
144
  for (const i of data.have) {
@@ -127,6 +147,15 @@ export class FileTransfer {
127
147
  }
128
148
  }
129
149
  }
150
+
151
+ // Accepted before the file finished being read: remember the consent and
152
+ // let initSend start streaming as soon as the chunks are ready.
153
+ if (!transfer.chunks) {
154
+ transfer.acceptedEarly = true;
155
+ return;
156
+ }
157
+
158
+ transfer.pending = false;
130
159
  this.#beginStreaming(data.transferId);
131
160
  }
132
161