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/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
|
@@ -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 {
|
|
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
|
|
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 } =
|
|
1229
|
-
peer.nickname,
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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 } =
|
|
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);
|
package/src/client/Connection.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|