ciphermesh 2.2.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 +23 -2
- package/README.pt-BR.md +23 -2
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +36 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +217 -27
- 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/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/p2p/P2PChatController.js +632 -21
- package/src/p2p/index.js +12 -0
- package/src/protocol/messages.js +21 -13
- package/src/protocol/validators.js +16 -2
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +15 -2
- package/src/shared/dnd.js +13 -0
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ forwarding, survives CGNAT).
|
|
|
42
42
|
|-----|---------|----------|
|
|
43
43
|
| 🔐 | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` — never touch disk |
|
|
44
44
|
| 🔄 | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today ≠ read yesterday |
|
|
45
|
+
| 🛡️ | **Hybrid post-quantum** | X25519 **+ ML-KEM-768** folded into the ratchet root — beats "harvest now, decrypt later" while staying ≥ classical security ([details](docs/ARCHITECTURE.md)) |
|
|
45
46
|
| 🕶️ | **Metadata resistance** | **Sealed sender** — the relay never sees who sent a message — plus fixed-bucket length padding on every ciphertext and opt-in cover traffic (`/cover`) |
|
|
46
47
|
| 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **✓/✗** trust badges next to names |
|
|
47
48
|
| 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
|
|
@@ -94,6 +95,19 @@ Prefer a prebuilt image? Pull the relay from GHCR (published on each release):
|
|
|
94
95
|
docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
|
|
95
96
|
```
|
|
96
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
|
+
|
|
104
|
+
**No Node on the host?** Every release ships a standalone relay binary for
|
|
105
|
+
macOS and Linux (arm64/x64) — download it from the
|
|
106
|
+
[releases page](https://github.com/FelipeKreulich/secret-chat-lan/releases),
|
|
107
|
+
`chmod +x`, run. Nothing to install. (The TUI client still needs Node/npx: it
|
|
108
|
+
depends on blessed, which resolves its widgets at runtime and cannot be
|
|
109
|
+
bundled.)
|
|
110
|
+
|
|
97
111
|
**Everyone** (including the host):
|
|
98
112
|
|
|
99
113
|
```bash
|
|
@@ -108,7 +122,10 @@ and use the `Internet` address from the banner — full walkthrough in
|
|
|
108
122
|
Already in the chat? Run `/invite <your-ip>:3600` and send the string (or the
|
|
109
123
|
QR code) to whoever you want to pull in.
|
|
110
124
|
|
|
111
|
-
**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.
|
|
112
129
|
|
|
113
130
|
## 💬 Commands
|
|
114
131
|
|
|
@@ -122,6 +139,8 @@ QR code) to whoever you want to pull in.
|
|
|
122
139
|
| `/users` | Who's online (with away/status) |
|
|
123
140
|
| `/msg <nick> <text>` | Private message (DM) |
|
|
124
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 |
|
|
125
144
|
| `/invite [host:port]` | Generate a `ciphermesh://` invite + QR code |
|
|
126
145
|
| `/nick <new>` | Change nickname (before joining — recovers from "nickname taken") |
|
|
127
146
|
| `/quit` | Leave |
|
|
@@ -138,6 +157,7 @@ QR code) to whoever you want to pull in.
|
|
|
138
157
|
| `/create <room> <password>` | Create a **private room** 🔒 — see below |
|
|
139
158
|
| `/rooms` | List rooms (🔒 marks private ones) |
|
|
140
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 |
|
|
141
161
|
| `/owner` | Room owner |
|
|
142
162
|
| `/kick` `/mute` `/ban` | Owner moderation |
|
|
143
163
|
|
|
@@ -190,7 +210,8 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
|
|
|
190
210
|
| `/play [path]` | Play the last received voice note (`afplay`/`sox`/`ffplay`) |
|
|
191
211
|
| `/accept [id]` / `/reject [id]` | Accept / decline an incoming file offer |
|
|
192
212
|
| `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
|
|
193
|
-
| `/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 |
|
|
194
215
|
| `/history [n]` | Last n messages from history |
|
|
195
216
|
| `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
|
|
196
217
|
| `/export [path]` | Export history as .txt or .json (plaintext!) |
|
package/README.pt-BR.md
CHANGED
|
@@ -42,6 +42,7 @@ forwarding, imune a CGNAT).
|
|
|
42
42
|
|-----|---------|--------|
|
|
43
43
|
| 🔐 | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` — nunca tocam o disco |
|
|
44
44
|
| 🔄 | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem — comprometer hoje ≠ ler ontem |
|
|
45
|
+
| 🛡️ | **Pós-quântico híbrido** | X25519 **+ ML-KEM-768** misturado na raiz do ratchet — vence o "grava hoje, decifra depois" mantendo segurança ≥ à clássica ([detalhes](docs/ARCHITECTURE.md)) |
|
|
45
46
|
| 🕶️ | **Resistência a metadados** | **Sealed sender** — o relay nunca vê quem enviou a mensagem — + padding de comprimento em buckets fixos em todo ciphertext e cover traffic opcional (`/cover`) |
|
|
46
47
|
| 🕵️ | **TOFU + SAS** | Alarme de troca de chave (MITM), código de 6 dígitos verificável por voz e badges de confiança **✓/✗** inline ao lado dos nomes |
|
|
47
48
|
| 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereço alcançável no banner |
|
|
@@ -94,6 +95,19 @@ Prefere imagem pronta? Baixe o relay do GHCR (publicado a cada release):
|
|
|
94
95
|
docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
|
|
95
96
|
```
|
|
96
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
|
+
|
|
104
|
+
**Sem Node na máquina?** Todo release traz um binário standalone do relay para
|
|
105
|
+
macOS e Linux (arm64/x64) — baixe da
|
|
106
|
+
[página de releases](https://github.com/FelipeKreulich/secret-chat-lan/releases),
|
|
107
|
+
`chmod +x`, rode. Nada para instalar. (O cliente TUI ainda precisa de Node/npx:
|
|
108
|
+
ele depende do blessed, que resolve os widgets em runtime e não pode ser
|
|
109
|
+
empacotado.)
|
|
110
|
+
|
|
97
111
|
**Todo mundo** (incluindo quem hospeda):
|
|
98
112
|
|
|
99
113
|
```bash
|
|
@@ -108,7 +122,10 @@ em [docs/SETUP.md](docs/SETUP.md).
|
|
|
108
122
|
Já está no chat? Rode `/invite <seu-ip>:3600` e mande a string (ou o QR) pra
|
|
109
123
|
quem você quiser puxar pra conversa.
|
|
110
124
|
|
|
111
|
-
**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.
|
|
112
129
|
|
|
113
130
|
## 💬 Comandos
|
|
114
131
|
|
|
@@ -122,6 +139,8 @@ quem você quiser puxar pra conversa.
|
|
|
122
139
|
| `/users` | Quem está online (com away/status) |
|
|
123
140
|
| `/msg <nick> <texto>` | Mensagem privada (DM) |
|
|
124
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 |
|
|
125
144
|
| `/invite [host:porta]` | Gera convite `ciphermesh://` + QR code |
|
|
126
145
|
| `/nick <novo>` | Troca de apelido (antes de entrar — recupera de "apelido em uso") |
|
|
127
146
|
| `/quit` | Sair |
|
|
@@ -138,6 +157,7 @@ quem você quiser puxar pra conversa.
|
|
|
138
157
|
| `/create <sala> <senha>` | Cria uma **sala privada** 🔒 — veja abaixo |
|
|
139
158
|
| `/rooms` | Lista salas (🔒 marca as privadas) |
|
|
140
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 |
|
|
141
161
|
| `/owner` | Dono da sala |
|
|
142
162
|
| `/kick` `/mute` `/ban` | Moderação (dono da sala) |
|
|
143
163
|
|
|
@@ -191,7 +211,8 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
|
|
|
191
211
|
| `/accept [id]` / `/reject [id]` | Aceita / recusa uma oferta de arquivo recebida |
|
|
192
212
|
| `/img [caminho]` | Renderiza a última imagem recebida em **alta resolução** (kitty/iTerm2) |
|
|
193
213
|
| `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
|
|
194
|
-
| `/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 |
|
|
195
216
|
| `/history [n]` | Últimas n mensagens do histórico |
|
|
196
217
|
| `/export [caminho]` | Exporta o histórico em .txt ou .json (texto plano!) |
|
|
197
218
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Entry point for the standalone relay binary (`bun build --compile`).
|
|
2
|
+
//
|
|
3
|
+
// Server-only on purpose: the TUI client depends on blessed, which resolves
|
|
4
|
+
// its widgets through dynamic requires that no bundler can follow. The relay
|
|
5
|
+
// has no such dependency, and "run a CipherMesh relay without installing
|
|
6
|
+
// Node" is exactly what self-hosters need.
|
|
7
|
+
import '../src/server/index.js';
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -741,6 +741,42 @@ fingerprint = SHA-256(publicKey)
|
|
|
741
741
|
= "A1B2:C3D4:E5F6:7890"
|
|
742
742
|
```
|
|
743
743
|
|
|
744
|
+
### 6.10 Hybrid Post-Quantum Handshake (X25519 + ML-KEM-768)
|
|
745
|
+
|
|
746
|
+
Protects against **"harvest now, decrypt later"**: traffic recorded today stays
|
|
747
|
+
unreadable to a future quantum adversary. The construction is **hybrid** — the
|
|
748
|
+
KEM secret is folded INTO the classical root, never replacing it, so security
|
|
749
|
+
is at least that of X25519 even if ML-KEM were broken (`src/crypto/PQHybrid.js`).
|
|
750
|
+
|
|
751
|
+
```
|
|
752
|
+
Each client publishes an ML-KEM-768 key alongside its X25519 key (join,
|
|
753
|
+
peer lists, peer_joined). Per pair, at ratchet creation:
|
|
754
|
+
|
|
755
|
+
initiator (lower sessionId):
|
|
756
|
+
(ct, ss) = ML-KEM.Encaps(peer.pqPublicKey)
|
|
757
|
+
root' = BLAKE2b(root ‖ ss ‖ "ciphermesh/pq-hybrid-v3")
|
|
758
|
+
→ ct rides in the message envelope (sealed to the recipient)
|
|
759
|
+
|
|
760
|
+
responder, on the first envelope carrying ct:
|
|
761
|
+
ss = ML-KEM.Decaps(ct, mySecretKey)
|
|
762
|
+
root' = BLAKE2b(root ‖ ss ‖ "ciphermesh/pq-hybrid-v3") ← same value
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
**Why this can't desynchronize:** the mix happens exactly once, at ratchet
|
|
766
|
+
initialization, before any chain key is derived — never mid-stream. There is no
|
|
767
|
+
window in which one side has mixed and the other has not while messages are in
|
|
768
|
+
flight: an envelope without the `ct` simply fails its MAC (fails closed, never
|
|
769
|
+
garbles). Once the peer replies successfully, the initiator stops attaching
|
|
770
|
+
`ct` (~1KB saved per envelope).
|
|
771
|
+
|
|
772
|
+
**Compatibility:** a peer without `pqPublicKey` (pre-2.3 client) gets a
|
|
773
|
+
classical-only session — same behaviour as before. `/trustlist` shows `[PQ]`
|
|
774
|
+
next to peers whose session is hybrid.
|
|
775
|
+
|
|
776
|
+
**Not yet hybrid (documented, planned for v3.1):** sealed-sender envelopes and
|
|
777
|
+
the private-room content layer remain classical; they protect metadata and
|
|
778
|
+
room content respectively, not the message stream's forward secrecy.
|
|
779
|
+
|
|
744
780
|
### 6.9 Private Rooms (password-protected, zero-knowledge)
|
|
745
781
|
|
|
746
782
|
`/create <room> <password>` creates a room the server can gate **without ever
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ciphermesh",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"docker:logs": "docker compose logs -f"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@noble/post-quantum": "0.6.1",
|
|
54
55
|
"blessed": "0.1.81",
|
|
55
56
|
"bonjour-service": "1.4.3",
|
|
56
57
|
"boxen": "8.0.1",
|
|
@@ -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)
|
|
@@ -206,7 +213,9 @@ export class ChatController {
|
|
|
206
213
|
// attached the listener).
|
|
207
214
|
#onConnected() {
|
|
208
215
|
this.#ui.setConnectionState('online');
|
|
209
|
-
this.#connection.send(
|
|
216
|
+
this.#connection.send(
|
|
217
|
+
createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
218
|
+
);
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
// ── Connection event handlers ─────────────────────────────────
|
|
@@ -230,6 +239,12 @@ export class ChatController {
|
|
|
230
239
|
this.#ui.addSystemMessage(`Reconnecting in ${delay / 1000}s...`);
|
|
231
240
|
});
|
|
232
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
|
+
|
|
233
248
|
this.#connection.on('cert-pinned', ({ fingerprint }) => {
|
|
234
249
|
const fp = fingerprint ? fingerprint.slice(0, 17) + '...' : '?';
|
|
235
250
|
this.#ui.addSystemMessage(`Server certificate pinned (trust on first use): ${fp}`);
|
|
@@ -464,6 +479,13 @@ export class ChatController {
|
|
|
464
479
|
this.#ui.addErrorMessage(
|
|
465
480
|
`${msg.message}. Use /nick <other> to pick a different nickname.`,
|
|
466
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
|
+
);
|
|
467
489
|
} else if (msg.code === ERR.ROOM_AUTH_FAILED || msg.code === ERR.ROOM_EXISTS) {
|
|
468
490
|
// Join/create refused — drop the derived secrets for that attempt.
|
|
469
491
|
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
@@ -536,7 +558,7 @@ export class ChatController {
|
|
|
536
558
|
// Migrate ratchet from old sessionId to new sessionId
|
|
537
559
|
this.#handshake.migrateRatchet(oldSid, peer.sessionId);
|
|
538
560
|
} else if (!oldSid) {
|
|
539
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
561
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
540
562
|
}
|
|
541
563
|
|
|
542
564
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
@@ -590,6 +612,7 @@ export class ChatController {
|
|
|
590
612
|
owner,
|
|
591
613
|
secrets,
|
|
592
614
|
pins: [],
|
|
615
|
+
topic: null, // { text, by, at } — E2EE among members, never on the relay
|
|
593
616
|
});
|
|
594
617
|
this.#bufferOrder.push(room);
|
|
595
618
|
}
|
|
@@ -643,6 +666,7 @@ export class ChatController {
|
|
|
643
666
|
this.#currentRoomOwner = buf.owner;
|
|
644
667
|
this.#ui.switchBuffer(room);
|
|
645
668
|
this.#rebuildActivePeers();
|
|
669
|
+
this.#applyTopicToUI();
|
|
646
670
|
this.#updateBufferBar();
|
|
647
671
|
this.#updatePrivateIndicator();
|
|
648
672
|
this.#saveLastSession(buf.private);
|
|
@@ -661,6 +685,11 @@ export class ChatController {
|
|
|
661
685
|
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
662
686
|
}
|
|
663
687
|
|
|
688
|
+
#applyTopicToUI() {
|
|
689
|
+
const topic = this.#buffers.get(this.#currentRoom)?.topic;
|
|
690
|
+
this.#ui.setTopic(topic?.text || null);
|
|
691
|
+
}
|
|
692
|
+
|
|
664
693
|
#updateBufferBar() {
|
|
665
694
|
this.#ui.setBufferBar(
|
|
666
695
|
this.#bufferOrder.map((room) => {
|
|
@@ -740,7 +769,7 @@ export class ChatController {
|
|
|
740
769
|
publicKey: peer.publicKey,
|
|
741
770
|
rooms: new Set([room]),
|
|
742
771
|
});
|
|
743
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
772
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
744
773
|
}
|
|
745
774
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
746
775
|
this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
|
|
@@ -759,6 +788,23 @@ export class ChatController {
|
|
|
759
788
|
if (this.#away || this.#statusText) {
|
|
760
789
|
this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
|
|
761
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
|
+
}
|
|
762
808
|
}
|
|
763
809
|
|
|
764
810
|
// One-time-per-session nudge to verify an unverified peer's identity.
|
|
@@ -864,6 +910,7 @@ export class ChatController {
|
|
|
864
910
|
ephPub,
|
|
865
911
|
msg.payload.counter,
|
|
866
912
|
msg.payload.previousCounter,
|
|
913
|
+
msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
|
|
867
914
|
);
|
|
868
915
|
}
|
|
869
916
|
|
|
@@ -1141,6 +1188,33 @@ export class ChatController {
|
|
|
1141
1188
|
return;
|
|
1142
1189
|
}
|
|
1143
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
|
+
|
|
1144
1218
|
if (data.action === 'pin_message') {
|
|
1145
1219
|
const pins = roomActive ? this.#pinnedMessages : this.#buffers.get(msgRoom)?.pins;
|
|
1146
1220
|
pins?.push({
|
|
@@ -1191,7 +1265,14 @@ export class ChatController {
|
|
|
1191
1265
|
});
|
|
1192
1266
|
}
|
|
1193
1267
|
|
|
1194
|
-
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
|
+
}
|
|
1195
1276
|
if (mentioned) {
|
|
1196
1277
|
this.#mentions.push({
|
|
1197
1278
|
nickname: peer.nickname,
|
|
@@ -1222,15 +1303,17 @@ export class ChatController {
|
|
|
1222
1303
|
if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
|
|
1223
1304
|
this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
|
|
1224
1305
|
}
|
|
1225
|
-
({ lineIndex } =
|
|
1226
|
-
peer.nickname,
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
+
));
|
|
1234
1317
|
});
|
|
1235
1318
|
this.#noteBufferUnread(msgRoom, mentioned);
|
|
1236
1319
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
@@ -1285,6 +1368,16 @@ export class ChatController {
|
|
|
1285
1368
|
return mentionsMe(text, this.#nickname);
|
|
1286
1369
|
}
|
|
1287
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
|
+
|
|
1288
1381
|
// ── User input handling ───────────────────────────────────────
|
|
1289
1382
|
#handleUserInput(text) {
|
|
1290
1383
|
this.#noteActive();
|
|
@@ -1308,7 +1401,9 @@ export class ChatController {
|
|
|
1308
1401
|
this.#ui.addInfoMessage(' /users - List online users');
|
|
1309
1402
|
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
1310
1403
|
this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
|
|
1404
|
+
this.#ui.addInfoMessage(' /me <action> - Third-person action message');
|
|
1311
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');
|
|
1312
1407
|
this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book (aliases for peers)');
|
|
1313
1408
|
this.#ui.addInfoMessage(
|
|
1314
1409
|
' /away [reason] - Mark yourself as away (unreads are counted)',
|
|
@@ -1320,6 +1415,7 @@ export class ChatController {
|
|
|
1320
1415
|
this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
|
|
1321
1416
|
this.#ui.addInfoMessage(' /join <room> [pass] - Join a room as a new buffer (Alt+1..9)');
|
|
1322
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');
|
|
1323
1419
|
this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
|
|
1324
1420
|
this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
|
|
1325
1421
|
this.#ui.addInfoMessage(' /rooms - List available rooms');
|
|
@@ -1343,6 +1439,7 @@ export class ChatController {
|
|
|
1343
1439
|
' /dnd [on|off|mentions|HH:MM-HH:MM] - Do not disturb / mentions only',
|
|
1344
1440
|
);
|
|
1345
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)');
|
|
1346
1443
|
this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
|
|
1347
1444
|
this.#ui.addInfoMessage(' /export [path] - Export the history (.txt or .json)');
|
|
1348
1445
|
this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
|
|
@@ -1516,7 +1613,7 @@ export class ChatController {
|
|
|
1516
1613
|
break;
|
|
1517
1614
|
}
|
|
1518
1615
|
this.#ui.addInfoMessage('Trust status:');
|
|
1519
|
-
for (const p of
|
|
1616
|
+
for (const [sid, p] of this.#peers) {
|
|
1520
1617
|
const record = this.#trustStore.getPeerRecord(p.nickname);
|
|
1521
1618
|
let status;
|
|
1522
1619
|
if (!record) {
|
|
@@ -1526,8 +1623,11 @@ export class ChatController {
|
|
|
1526
1623
|
} else {
|
|
1527
1624
|
status = 'trusted (TOFU)';
|
|
1528
1625
|
}
|
|
1529
|
-
this
|
|
1626
|
+
// [PQ] = this session's ratchet root also includes an ML-KEM secret.
|
|
1627
|
+
const pq = this.#handshake.isHybrid(sid) ? ' {green-fg}[PQ]{/green-fg}' : '';
|
|
1628
|
+
this.#ui.addInfoMessage(` ${p.nickname}: ${status}${pq}`);
|
|
1530
1629
|
}
|
|
1630
|
+
this.#ui.addInfoMessage(' [PQ] = hybrid post-quantum session (X25519 + ML-KEM-768)');
|
|
1531
1631
|
break;
|
|
1532
1632
|
}
|
|
1533
1633
|
|
|
@@ -1812,6 +1912,83 @@ export class ChatController {
|
|
|
1812
1912
|
break;
|
|
1813
1913
|
}
|
|
1814
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
|
+
|
|
1815
1992
|
case '/mentions': {
|
|
1816
1993
|
if (this.#mentions.length === 0) {
|
|
1817
1994
|
this.#ui.addInfoMessage('No mentions in this session yet.');
|
|
@@ -1994,6 +2171,13 @@ export class ChatController {
|
|
|
1994
2171
|
break;
|
|
1995
2172
|
}
|
|
1996
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
|
+
|
|
1997
2181
|
case '/search': {
|
|
1998
2182
|
if (!this.#historyStore?.isOpen) {
|
|
1999
2183
|
this.#ui.addErrorMessage('History disabled — start the client with a passphrase');
|
|
@@ -2013,6 +2197,11 @@ export class ChatController {
|
|
|
2013
2197
|
for (const e of results) {
|
|
2014
2198
|
this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
|
|
2015
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
|
+
);
|
|
2016
2205
|
break;
|
|
2017
2206
|
}
|
|
2018
2207
|
|
|
@@ -2440,7 +2629,9 @@ export class ChatController {
|
|
|
2440
2629
|
// "nickname taken"): the server still accepts a JOIN on this socket.
|
|
2441
2630
|
this.#nickname = newNick;
|
|
2442
2631
|
this.#ui.setNickname(newNick);
|
|
2443
|
-
this.#connection.send(
|
|
2632
|
+
this.#connection.send(
|
|
2633
|
+
createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
2634
|
+
);
|
|
2444
2635
|
this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
|
|
2445
2636
|
break;
|
|
2446
2637
|
}
|
|
@@ -2668,7 +2859,7 @@ export class ChatController {
|
|
|
2668
2859
|
rooms: new Set([msg.room]),
|
|
2669
2860
|
});
|
|
2670
2861
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
2671
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
2862
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
2672
2863
|
}
|
|
2673
2864
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
2674
2865
|
}
|
|
@@ -2703,7 +2894,7 @@ export class ChatController {
|
|
|
2703
2894
|
rooms: new Set([msg.room]),
|
|
2704
2895
|
});
|
|
2705
2896
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
2706
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
2897
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
2707
2898
|
}
|
|
2708
2899
|
}
|
|
2709
2900
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
@@ -3082,7 +3273,7 @@ export class ChatController {
|
|
|
3082
3273
|
}
|
|
3083
3274
|
|
|
3084
3275
|
// ── Send encrypted message to all peers ───────────────────────
|
|
3085
|
-
#sendMessageToAll(text, replyTo = null) {
|
|
3276
|
+
#sendMessageToAll(text, replyTo = null, isAction = false) {
|
|
3086
3277
|
if (!this.#connection.connected) {
|
|
3087
3278
|
this.#ui.addErrorMessage('No connection to the server — message not sent');
|
|
3088
3279
|
return;
|
|
@@ -3099,6 +3290,9 @@ export class ChatController {
|
|
|
3099
3290
|
sentAt: Date.now(),
|
|
3100
3291
|
messageId,
|
|
3101
3292
|
};
|
|
3293
|
+
if (isAction) {
|
|
3294
|
+
msgObj.isAction = true;
|
|
3295
|
+
}
|
|
3102
3296
|
if (replyTo) {
|
|
3103
3297
|
msgObj.replyTo = replyTo;
|
|
3104
3298
|
}
|
|
@@ -3127,13 +3321,9 @@ export class ChatController {
|
|
|
3127
3321
|
this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
|
|
3128
3322
|
}
|
|
3129
3323
|
const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
|
|
3130
|
-
const { lineIndex } =
|
|
3131
|
-
this.#nickname,
|
|
3132
|
-
text,
|
|
3133
|
-
false,
|
|
3134
|
-
ephLabel,
|
|
3135
|
-
this.#deniableMode,
|
|
3136
|
-
);
|
|
3324
|
+
const { lineIndex } = isAction
|
|
3325
|
+
? this.#ui.addActionMessage(this.#nickname, text)
|
|
3326
|
+
: this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
|
|
3137
3327
|
|
|
3138
3328
|
if (this.#ephemeralMode) {
|
|
3139
3329
|
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|