ciphermesh 2.8.0 → 2.9.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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  Notable changes per release. Older versions are reconstructed from the git
4
4
  history — the commit bodies and pull requests remain the fuller record.
5
5
 
6
+ ## 2.9.0
7
+
8
+ ### Added
9
+
10
+ - `/block` now works in **P2P** as well. It matters more there than on a relay:
11
+ P2P has no room owners at all, so `/kick`, `/mute` and `/ban` have nobody to
12
+ act on anyone's behalf. Refusing to listen is the only protection there is, and
13
+ the refusal message for the moderation commands now says so instead of leaving
14
+ the user with nothing.
15
+
6
16
  ## 2.8.0
7
17
 
8
18
  ### Added
package/README.md CHANGED
@@ -41,28 +41,28 @@ forwarding, survives CGNAT).
41
41
 
42
42
  ## ✨ Highlights
43
43
 
44
- | | Feature | The gist |
45
- |-----|---------|----------|
46
- | 🔐 | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` — never touch disk |
47
- | 🔄 | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today ≠ read yesterday |
48
- | 🛡️ | **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)) |
49
- | 🕶️ | **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`) |
50
- | 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **✓/✗** trust badges next to names |
51
- | 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
52
- | 📨 | **Invites with QR** | `/invite` prints a `ciphermesh://` string + QR — paste it, you're in the right room |
53
- | ✓✓ | **Encrypted read receipts** | The ✓✓ travels as ordinary ciphertext — the server can't tell it apart |
54
- | 🗂️ | **Encrypted local history** | Opt-in (passphrase only), Argon2id + XSalsa20-Poly1305, `/search` & `/export` |
55
- | 🖼️ | **Image previews** | Received photos render right in the chat as colored half-blocks |
56
- | 📎 | **Resumable transfers** | Lost chunks are re-requested; reconnects resume from where they stopped |
57
- | 💬 | **Modern chat feel** | Right-aligned own messages, per-user emoji avatars, replies with quotes, `:fire:` → 🔥 |
58
- | 🎞️ | **Animated UI** | Splash intro, reconnect spinner, live transfer bars (shimmer + ETA), a lock-closing handshake on connect, and a pulsing "new messages ↓" pill |
59
- | 👻 | **Deniable & ephemeral** | Symmetric-crypto deniable mode; ephemeral messages *burn away* char-by-char when they expire |
60
- | 🔒 | **Private rooms** | `/create <room> <password>` — zero-knowledge: the password never leaves your machine (Argon2id → Ed25519 challenge-response) and room content gets an extra symmetric layer the relay can't fake its way into |
61
- | 🗂️ | **Multi-room buffers** | Be in several rooms at once — **Alt+1..9** switches, unread badges per room. Which room a message belongs to travels *inside* the encrypted payload: the relay never learns it |
62
- | 🩺 | **It explains itself** | `/doctor` diagnoses a failing connection layer by layer — address, DNS, TCP, TLS, protocol — and tells you what to do about each failure |
63
- | 🔐 | **Screen lock** | `/lock` and `/autolock` put the session behind your passphrase when you step away; `/panic` is still there for the worse moment |
64
- | 🛰️ | **Serverless P2P mode** | mDNS peer discovery on the LAN — no relay at all, and nearly the same command set |
65
- | 🧩 | **Plugins** | Drop a JS file in `~/.ciphermesh/plugins` and get new slash-commands — `/roll` and `/poll` examples included ([Plugin API](docs/PLUGINS.md)) |
44
+ | | Feature | The gist |
45
+ | --- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | 🔐 | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` — never touch disk |
47
+ | 🔄 | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today ≠ read yesterday |
48
+ | 🛡️ | **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)) |
49
+ | 🕶️ | **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`) |
50
+ | 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **✓/✗** trust badges next to names |
51
+ | 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
52
+ | 📨 | **Invites with QR** | `/invite` prints a `ciphermesh://` string + QR — paste it, you're in the right room |
53
+ | ✓✓ | **Encrypted read receipts** | The ✓✓ travels as ordinary ciphertext — the server can't tell it apart |
54
+ | 🗂️ | **Encrypted local history** | Opt-in (passphrase only), Argon2id + XSalsa20-Poly1305, `/search` & `/export` |
55
+ | 🖼️ | **Image previews** | Received photos render right in the chat as colored half-blocks |
56
+ | 📎 | **Resumable transfers** | Lost chunks are re-requested; reconnects resume from where they stopped |
57
+ | 💬 | **Modern chat feel** | Right-aligned own messages, per-user emoji avatars, replies with quotes, `:fire:` → 🔥 |
58
+ | 🎞️ | **Animated UI** | Splash intro, reconnect spinner, live transfer bars (shimmer + ETA), a lock-closing handshake on connect, and a pulsing "new messages ↓" pill |
59
+ | 👻 | **Deniable & ephemeral** | Symmetric-crypto deniable mode; ephemeral messages _burn away_ char-by-char when they expire |
60
+ | 🔒 | **Private rooms** | `/create <room> <password>` — zero-knowledge: the password never leaves your machine (Argon2id → Ed25519 challenge-response) and room content gets an extra symmetric layer the relay can't fake its way into |
61
+ | 🗂️ | **Multi-room buffers** | Be in several rooms at once — **Alt+1..9** switches, unread badges per room. Which room a message belongs to travels _inside_ the encrypted payload: the relay never learns it |
62
+ | 🩺 | **It explains itself** | `/doctor` diagnoses a failing connection layer by layer — address, DNS, TCP, TLS, protocol — and tells you what to do about each failure |
63
+ | 🔐 | **Screen lock** | `/lock` and `/autolock` put the session behind your passphrase when you step away; `/panic` is still there for the worse moment |
64
+ | 🛰️ | **Serverless P2P mode** | mDNS peer discovery on the LAN — no relay at all, and nearly the same command set |
65
+ | 🧩 | **Plugins** | Drop a JS file in `~/.ciphermesh/plugins` and get new slash-commands — `/roll` and `/poll` examples included ([Plugin API](docs/PLUGINS.md)) |
66
66
 
67
67
  ## 🚀 Quick start
68
68
 
@@ -131,10 +131,10 @@ download from the
131
131
  > v2.7.2 never embedded its native addon and only ran on the machine that built
132
132
  > it, so those attachments have been removed.
133
133
 
134
- | Binary | What it is |
135
- |---|---|
136
- | `ciphermesh-<platform>` | Everything: client, relay and P2P. `ciphermesh server` and `ciphermesh p2p` work exactly as they do on npm. |
137
- | `ciphermesh-server-<platform>` | Relay only, for self-hosters who want nothing else on the machine. |
134
+ | Binary | What it is |
135
+ | ------------------------------ | ----------------------------------------------------------------------------------------------------------- |
136
+ | `ciphermesh-<platform>` | Everything: client, relay and P2P. `ciphermesh server` and `ciphermesh p2p` work exactly as they do on npm. |
137
+ | `ciphermesh-server-<platform>` | Relay only, for self-hosters who want nothing else on the machine. |
138
138
 
139
139
  **Everyone** (including the host):
140
140
 
@@ -200,39 +200,40 @@ software was built for.
200
200
  <details>
201
201
  <summary><b>Essentials</b></summary>
202
202
 
203
- | Command | Description |
204
- |---------|-------------|
205
- | `/help` | All commands |
206
- | `/tips` | Show a rotating security/UX tip |
207
- | `/users` | Who's online (with away/status) |
208
- | `/msg <nick> <text>` | Private message (DM) |
209
- | `/reply <text>` | Reply quoting the last received message |
210
- | `/me <action>` | Third-person action — felipe is compiling»* |
211
- | `/watch [add\|remove\|clear]` | Alert on a keyword in **any** room, like a mention |
212
- | `/invite [host:port]` | Generate a `ciphermesh://` invite + QR code |
213
- | `/nick <new>` | Change nickname (before joining — recovers from "nickname taken") |
214
- | `/quit` | Leave |
203
+ | Command | Description |
204
+ | ----------------------------- | ----------------------------------------------------------------- |
205
+ | `/help` | All commands |
206
+ | `/tips` | Show a rotating security/UX tip |
207
+ | `/users` | Who's online (with away/status) |
208
+ | `/msg <nick> <text>` | Private message (DM) |
209
+ | `/reply <text>` | Reply quoting the last received message |
210
+ | `/me <action>` | Third-person action — felipe is compiling»_ |
211
+ | `/watch [add\|remove\|clear]` | Alert on a keyword in **any** room, like a mention |
212
+ | `/invite [host:port]` | Generate a `ciphermesh://` invite + QR code |
213
+ | `/nick <new>` | Change nickname (before joining — recovers from "nickname taken") |
214
+ | `/quit` | Leave |
215
215
 
216
216
  </details>
217
217
 
218
218
  <details>
219
219
  <summary><b>Rooms</b></summary>
220
220
 
221
- | Command | Description |
222
- |---------|-------------|
223
- | `/join <room> [password]` | Open a room as a **new buffer** — you stay in your other rooms (IRC style) |
224
- | `/leave [room]` | Leave a room; its buffer closes (the last room is protected) |
225
- | `/create <room> <password>` | Create a **private room** 🔒 — see below |
226
- | `/rooms` | List rooms (🔒 marks private ones) |
227
- | `/room` | Current room + your buffer list |
228
- | `/topic [text\|clear]` | Show or set the room topic — shown in the status bar and synced to whoever joins later |
229
- | `/owner` | Room owner |
230
- | `/kick` `/mute` `/ban` | Owner moderation |
221
+ | Command | Description |
222
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
223
+ | `/join <room> [password]` | Open a room as a **new buffer** — you stay in your other rooms (IRC style) |
224
+ | `/leave [room]` | Leave a room; its buffer closes (the last room is protected) |
225
+ | `/create <room> <password>` | Create a **private room** 🔒 — see below |
226
+ | `/rooms` | List rooms (🔒 marks private ones) |
227
+ | `/room` | Current room + your buffer list |
228
+ | `/topic [text\|clear]` | Show or set the room topic — shown in the status bar and synced to whoever joins later |
229
+ | `/owner` | Room owner |
230
+ | `/kick` `/mute` `/ban` | Owner moderation — bound to the public key, so a rename does not undo a ban |
231
+ | `/block` `/unblock` `/blocklist` | Stop seeing someone, **just for you**. Nothing is sent, the relay never learns, and they are not told — so anyone can use it, including in `general`, which has no owner. Works in P2P too, where there is no moderation at all. |
231
232
 
232
233
  **Buffers:** be in several rooms at once — **Alt+1..9** switches, and the status
233
234
  bar shows `[1:general] [2:dev •3]` with per-room unread badges. Because the
234
235
  relay is blind (sealed sender), which room a message belongs to travels
235
- *inside* the encrypted payload — the server never learns it.
236
+ _inside_ the encrypted payload — the server never learns it.
236
237
 
237
238
  **Private rooms** are zero-knowledge: the password never leaves your machine.
238
239
  Joining derives an Ed25519 key from the password (Argon2id) and answers a
@@ -247,22 +248,22 @@ without verifying couldn't read a word. Share the password out-of-band.
247
248
  <details>
248
249
  <summary><b>Trust & security</b></summary>
249
250
 
250
- | Command | Description |
251
- |---------|-------------|
252
- | `/fingerprint [nick]` | Key fingerprint + a deterministic **randomart** picture of the key |
253
- | `/verify <nick>` | SAS code (~40-bit) + QR + key randomart for out-of-band verification |
254
- | `/verify-confirm <nick>` | Mark peer as verified |
255
- | `/trust <nick>` / `/trustlist` | Accept new key / trust status |
256
- | `/contacts [add\|remove\|all]` | Contact book — persistent aliases on trust records ("this fingerprint is João"); shows in `/users`, rides along in identity backups |
257
- | `/backup [path]` | Encrypted backup of identity + verified peers (restore at startup) |
258
- | `/deniable [on\|off]` | Plausible-deniability mode |
251
+ | Command | Description |
252
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
253
+ | `/fingerprint [nick]` | Key fingerprint + a deterministic **randomart** picture of the key |
254
+ | `/verify <nick>` | SAS code (~40-bit) + QR + key randomart for out-of-band verification |
255
+ | `/verify-confirm <nick>` | Mark peer as verified |
256
+ | `/trust <nick>` / `/trustlist` | Accept new key / trust status |
257
+ | `/contacts [add\|remove\|all]` | Contact book — persistent aliases on trust records ("this fingerprint is João"); shows in `/users`, rides along in identity backups |
258
+ | `/backup [path]` | Encrypted backup of identity + verified peers (restore at startup) |
259
+ | `/deniable [on\|off]` | Plausible-deniability mode |
259
260
  | `/lock` / `/autolock <min\|off>` | Lock the screen behind the session passphrase — manually or after idle time (`autoLock` in config). Privacy for the "stepped away" moment; `/panic` is for the worst one |
260
- | `/panic [yes]` | Duress wipe — securely erase all on-disk secrets (session, history, trust, keys) and exit |
261
- | `/cover [on\|constant\|off]` | Cover traffic — `on` = jittered decoys, `constant` = steady-rate paced channel |
262
- | `/theme [name]` | Nick colour theme: neon, matrix, mono, sunset, ocean |
263
- | `/ephemeral <30s\|5m\|1h\|off>` | Self-destructing messages |
264
- | `/receipts [on\|off]` | Send read receipts (✓✓) |
265
- | `/audit [n]` | Local audit log |
261
+ | `/panic [yes]` | Duress wipe — securely erase all on-disk secrets (session, history, trust, keys) and exit |
262
+ | `/cover [on\|constant\|off]` | Cover traffic — `on` = jittered decoys, `constant` = steady-rate paced channel |
263
+ | `/theme [name]` | Nick colour theme: neon, matrix, mono, sunset, ocean |
264
+ | `/ephemeral <30s\|5m\|1h\|off>` | Self-destructing messages |
265
+ | `/receipts [on\|off]` | Send read receipts (✓✓) |
266
+ | `/audit [n]` | Local audit log |
266
267
 
267
268
  A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a key that changed since you last saw it (possible MITM). A newly-arrived unverified peer triggers a one-time reminder to `/verify` them.
268
269
 
@@ -271,40 +272,40 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
271
272
  <details>
272
273
  <summary><b>History & files</b></summary>
273
274
 
274
- | Command | Description |
275
- |---------|-------------|
276
- | `/file <path>` | Offer a file (≤ 50MB) — the recipient must `/accept`; transfers resume |
277
- | `/voice [secs]` | Record & send an encrypted voice note (needs `sox`/`ffmpeg`; default 10s) |
278
- | `/play [path]` | Play the last received voice note (`afplay`/`sox`/`ffplay`) |
279
- | `/accept [id]` / `/reject [id]` | Accept / decline an incoming file offer |
280
- | `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
281
- | `/search <term>` | Search the encrypted local history (on disk, across sessions) |
282
- | `/find [term]` — **Ctrl+F** | Search **this room's scrollback** and press Enter to **jump to the message**, highlighted |
283
- | `/doctor [host:port]` | Diagnose why a connection fails: address, DNS, TCP port, TLS (CA vs self-signed) and protocol version — each failure with what to do about it |
284
- | `/history [n]` | Last n messages from history |
285
- | `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
286
- | `/export [path]` | Export history as .txt or .json (plaintext!) |
275
+ | Command | Description |
276
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
277
+ | `/file <path>` | Offer a file (≤ 50MB) — the recipient must `/accept`; transfers resume |
278
+ | `/voice [secs]` | Record & send an encrypted voice note (needs `sox`/`ffmpeg`; default 10s) |
279
+ | `/play [path]` | Play the last received voice note (`afplay`/`sox`/`ffplay`) |
280
+ | `/accept [id]` / `/reject [id]` | Accept / decline an incoming file offer |
281
+ | `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
282
+ | `/search <term>` | Search the encrypted local history (on disk, across sessions) |
283
+ | `/find [term]` — **Ctrl+F** | Search **this room's scrollback** and press Enter to **jump to the message**, highlighted |
284
+ | `/doctor [host:port]` | Diagnose why a connection fails: address, DNS, TCP port, TLS (CA vs self-signed) and protocol version — each failure with what to do about it |
285
+ | `/history [n]` | Last n messages from history |
286
+ | `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
287
+ | `/export [path]` | Export history as .txt or .json (plaintext!) |
287
288
 
288
289
  </details>
289
290
 
290
291
  <details>
291
292
  <summary><b>Presence & fun</b></summary>
292
293
 
293
- | Command | Description |
294
- |---------|-------------|
295
- | `/away [reason]` / `/back` | Mark yourself away — while away, unreads are counted (`[away · N new]`) and `/back` shows a summary |
296
- | `/mentions [n]` | Recent mentions of you this session (who, where, when) |
297
- | `/status <text\|off>` | Free-form status — emojis welcome (`/status :fire: coding`) |
298
- | `/react <emoji>` | React to the last message — the emoji lands **on the message**, with a count when several people react |
299
- | `/edit` `/delete` | Edit or delete your last message — the **original line is rewritten in place** (marked *(edited)*) or replaced by a tombstone, instead of a new line you have to mentally staple to it |
300
- | `/pin` `/unpin` `/pins` | Pin messages |
301
- | `/sound` `/notify` | Sound / desktop notifications |
302
- | `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Do-not-disturb, mentions-only, or quiet hours |
303
- | `/clear` | Clear the chat |
294
+ | Command | Description |
295
+ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
296
+ | `/away [reason]` / `/back` | Mark yourself away — while away, unreads are counted (`[away · N new]`) and `/back` shows a summary |
297
+ | `/mentions [n]` | Recent mentions of you this session (who, where, when) |
298
+ | `/status <text\|off>` | Free-form status — emojis welcome (`/status :fire: coding`) |
299
+ | `/react <emoji>` | React to the last message — the emoji lands **on the message**, with a count when several people react |
300
+ | `/edit` `/delete` | Edit or delete your last message — the **original line is rewritten in place** (marked _(edited)_) or replaced by a tombstone, instead of a new line you have to mentally staple to it |
301
+ | `/pin` `/unpin` `/pins` | Pin messages |
302
+ | `/sound` `/notify` | Sound / desktop notifications |
303
+ | `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Do-not-disturb, mentions-only, or quiet hours |
304
+ | `/clear` | Clear the chat |
304
305
 
305
306
  </details>
306
307
 
307
- Typing `:fire:` anywhere becomes 🔥 (Tab autocompletes shortcodes). **Ctrl+K** opens a fuzzy command palette, **Ctrl+E** an emoji picker. PageUp/PageDown scroll the history. **Alt+Enter** (or Shift+Enter where the terminal supports it, plus Ctrl+J) inserts a newline for multi-line messages; Enter sends. Pasting multi-line text (code included) keeps its line breaks — paste, check, Enter. Markdown works: \`code\`, **bold**, *italic*, links, plus fenced \`\`\` code blocks and | tables |. Received images preview inline (half-blocks) and render full-res with `/img` on kitty/iTerm2. Day separators and message grouping keep the log clean.
308
+ Typing `:fire:` anywhere becomes 🔥 (Tab autocompletes shortcodes). **Ctrl+K** opens a fuzzy command palette, **Ctrl+E** an emoji picker. PageUp/PageDown scroll the history. **Alt+Enter** (or Shift+Enter where the terminal supports it, plus Ctrl+J) inserts a newline for multi-line messages; Enter sends. Pasting multi-line text (code included) keeps its line breaks — paste, check, Enter. Markdown works: \`code\`, **bold**, _italic_, links, plus fenced \`\`\` code blocks and | tables |. Received images preview inline (half-blocks) and render full-res with `/img` on kitty/iTerm2. Day separators and message grouping keep the log clean.
308
309
 
309
310
  ### First run & config file
310
311
 
@@ -360,7 +361,7 @@ All keys are optional (unknown keys are ignored):
360
361
  **Argon2id + XSalsa20-Poly1305** — no passphrase, no persistence.
361
362
  - **Hybrid post-quantum**: each pairwise session mixes an ML-KEM-768 secret
362
363
  into the ratchet root at setup, so recorded traffic stays unreadable to a
363
- future quantum adversary. It is *added* to X25519, never replaces it —
364
+ future quantum adversary. It is _added_ to X25519, never replaces it —
364
365
  security is at least the classical one. `/trustlist` shows `[PQ]`.
365
366
  - **Private rooms** never send the password anywhere: it derives an Ed25519
366
367
  key (Argon2id) that answers a server challenge, and the room content carries
package/README.pt-BR.md CHANGED
@@ -41,28 +41,28 @@ forwarding, imune a CGNAT).
41
41
 
42
42
  ## ✨ Destaques
43
43
 
44
- | | Feature | Resumo |
45
- |-----|---------|--------|
46
- | 🔐 | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` — nunca tocam o disco |
47
- | 🔄 | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem — comprometer hoje ≠ ler ontem |
48
- | 🛡️ | **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)) |
49
- | 🕶️ | **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`) |
50
- | 🕵️ | **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 |
51
- | 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereço alcançável no banner |
52
- | 📨 | **Convites com QR** | `/invite` gera uma string `ciphermesh://` + QR — colou, caiu na sala certa |
53
- | ✓✓ | **Read receipts cifrados** | O ✓✓ viaja como ciphertext comum — o servidor não distingue de mensagem |
54
- | 🗂️ | **Histórico local cifrado** | Opt-in (só com passphrase), Argon2id + XSalsa20-Poly1305, `/search` e `/export` |
55
- | 🖼️ | **Preview de imagens** | Fotos recebidas renderizam no chat em half-blocks coloridos |
56
- | 📎 | **Transferências com resume** | Chunks perdidos são re-pedidos; reconexão retoma de onde parou |
57
- | 💬 | **Cara de app moderno** | Suas mensagens à direita, avatar de emoji por usuário, reply com citação, `:fire:` → 🔥 |
58
- | 🎞️ | **Interface animada** | Splash na abertura, spinner de reconexão, barra de transferência viva (shimmer + ETA), cadeado fechando no handshake e um selo pulsante "novas mensagens ↓" |
59
- | 👻 | **Deniable e efêmeras** | Modo de negação plausível (crypto simétrica); mensagens efêmeras *queimam* caractere a caractere ao expirar |
60
- | 🔒 | **Salas privadas** | `/create <sala> <senha>` — zero-knowledge: a senha nunca sai da sua máquina (Argon2id → challenge-response Ed25519) e o conteúdo da sala ganha uma camada simétrica extra que nem um relay malicioso atravessa |
61
- | 🗂️ | **Buffers multi-sala** | Fique em várias salas ao mesmo tempo — **Alt+1..9** alterna, com não-lidas por sala. A qual sala cada mensagem pertence viaja *dentro* do payload cifrado: o relay nunca fica sabendo |
62
- | 🩺 | **Ele se explica sozinho** | `/doctor` diagnostica uma conexão que falha camada por camada — endereço, DNS, TCP, TLS, protocolo — e diz o que fazer em cada falha |
63
- | 🔐 | **Trava de tela** | `/lock` e `/autolock` põem a sessão atrás da sua passphrase quando você sai da frente; o `/panic` continua ali para o pior momento |
64
- | 🛰️ | **Modo P2P sem servidor** | Descoberta de peers via mDNS na LAN — sem relay nenhum, e com quase o mesmo conjunto de comandos |
65
- | 🧩 | **Plugins** | Solta um arquivo JS em `~/.ciphermesh/plugins` e ganha comandos novos — exemplos `/roll` e `/poll` inclusos ([API de plugins](docs/PLUGINS.md)) |
44
+ | | Feature | Resumo |
45
+ | --- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | 🔐 | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` — nunca tocam o disco |
47
+ | 🔄 | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem — comprometer hoje ≠ ler ontem |
48
+ | 🛡️ | **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)) |
49
+ | 🕶️ | **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`) |
50
+ | 🕵️ | **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 |
51
+ | 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereço alcançável no banner |
52
+ | 📨 | **Convites com QR** | `/invite` gera uma string `ciphermesh://` + QR — colou, caiu na sala certa |
53
+ | ✓✓ | **Read receipts cifrados** | O ✓✓ viaja como ciphertext comum — o servidor não distingue de mensagem |
54
+ | 🗂️ | **Histórico local cifrado** | Opt-in (só com passphrase), Argon2id + XSalsa20-Poly1305, `/search` e `/export` |
55
+ | 🖼️ | **Preview de imagens** | Fotos recebidas renderizam no chat em half-blocks coloridos |
56
+ | 📎 | **Transferências com resume** | Chunks perdidos são re-pedidos; reconexão retoma de onde parou |
57
+ | 💬 | **Cara de app moderno** | Suas mensagens à direita, avatar de emoji por usuário, reply com citação, `:fire:` → 🔥 |
58
+ | 🎞️ | **Interface animada** | Splash na abertura, spinner de reconexão, barra de transferência viva (shimmer + ETA), cadeado fechando no handshake e um selo pulsante "novas mensagens ↓" |
59
+ | 👻 | **Deniable e efêmeras** | Modo de negação plausível (crypto simétrica); mensagens efêmeras _queimam_ caractere a caractere ao expirar |
60
+ | 🔒 | **Salas privadas** | `/create <sala> <senha>` — zero-knowledge: a senha nunca sai da sua máquina (Argon2id → challenge-response Ed25519) e o conteúdo da sala ganha uma camada simétrica extra que nem um relay malicioso atravessa |
61
+ | 🗂️ | **Buffers multi-sala** | Fique em várias salas ao mesmo tempo — **Alt+1..9** alterna, com não-lidas por sala. A qual sala cada mensagem pertence viaja _dentro_ do payload cifrado: o relay nunca fica sabendo |
62
+ | 🩺 | **Ele se explica sozinho** | `/doctor` diagnostica uma conexão que falha camada por camada — endereço, DNS, TCP, TLS, protocolo — e diz o que fazer em cada falha |
63
+ | 🔐 | **Trava de tela** | `/lock` e `/autolock` põem a sessão atrás da sua passphrase quando você sai da frente; o `/panic` continua ali para o pior momento |
64
+ | 🛰️ | **Modo P2P sem servidor** | Descoberta de peers via mDNS na LAN — sem relay nenhum, e com quase o mesmo conjunto de comandos |
65
+ | 🧩 | **Plugins** | Solta um arquivo JS em `~/.ciphermesh/plugins` e ganha comandos novos — exemplos `/roll` e `/poll` inclusos ([API de plugins](docs/PLUGINS.md)) |
66
66
 
67
67
  ## 🚀 Começando
68
68
 
@@ -132,10 +132,10 @@ do
132
132
  > nunca embutiram o addon nativo e só rodavam na máquina que os construiu, então
133
133
  > aqueles anexos foram removidos.
134
134
 
135
- | Binário | O que é |
136
- |---|---|
137
- | `ciphermesh-<plataforma>` | Tudo: cliente, relay e P2P. `ciphermesh server` e `ciphermesh p2p` funcionam igual ao npm. |
138
- | `ciphermesh-server-<plataforma>` | Só o relay, para quem hospeda e não quer mais nada na máquina. |
135
+ | Binário | O que é |
136
+ | -------------------------------- | ------------------------------------------------------------------------------------------ |
137
+ | `ciphermesh-<plataforma>` | Tudo: cliente, relay e P2P. `ciphermesh server` e `ciphermesh p2p` funcionam igual ao npm. |
138
+ | `ciphermesh-server-<plataforma>` | Só o relay, para quem hospeda e não quer mais nada na máquina. |
139
139
 
140
140
  **Todo mundo** (incluindo quem hospeda):
141
141
 
@@ -202,38 +202,39 @@ aquela para a qual este software foi feito.
202
202
  <details>
203
203
  <summary><b>Essenciais</b></summary>
204
204
 
205
- | Comando | Descrição |
206
- |---------|-----------|
207
- | `/help` | Todos os comandos |
208
- | `/tips` | Mostra uma dica rotativa de segurança/UX |
209
- | `/users` | Quem está online (com away/status) |
210
- | `/msg <nick> <texto>` | Mensagem privada (DM) |
211
- | `/reply <texto>` | Responde citando a última mensagem recebida |
212
- | `/me <ação>` | Ação em terceira pessoa — felipe está compilando»* |
205
+ | Comando | Descrição |
206
+ | ----------------------------- | ----------------------------------------------------------------------- |
207
+ | `/help` | Todos os comandos |
208
+ | `/tips` | Mostra uma dica rotativa de segurança/UX |
209
+ | `/users` | Quem está online (com away/status) |
210
+ | `/msg <nick> <texto>` | Mensagem privada (DM) |
211
+ | `/reply <texto>` | Responde citando a última mensagem recebida |
212
+ | `/me <ação>` | Ação em terceira pessoa — felipe está compilando»_ |
213
213
  | `/watch [add\|remove\|clear]` | Alerta quando uma palavra aparece em **qualquer** sala, como uma menção |
214
- | `/invite [host:porta]` | Gera convite `ciphermesh://` + QR code |
215
- | `/nick <novo>` | Troca de apelido (antes de entrar — recupera de "apelido em uso") |
216
- | `/quit` | Sair |
214
+ | `/invite [host:porta]` | Gera convite `ciphermesh://` + QR code |
215
+ | `/nick <novo>` | Troca de apelido (antes de entrar — recupera de "apelido em uso") |
216
+ | `/quit` | Sair |
217
217
 
218
218
  </details>
219
219
 
220
220
  <details>
221
221
  <summary><b>Salas</b></summary>
222
222
 
223
- | Comando | Descrição |
224
- |---------|-----------|
225
- | `/join <sala> [senha]` | Abre a sala como um **novo buffer** — você continua nas outras (estilo IRC) |
226
- | `/leave [sala]` | Sai de uma sala; o buffer fecha (a última sala é protegida) |
227
- | `/create <sala> <senha>` | Cria uma **sala privada** 🔒 — veja abaixo |
228
- | `/rooms` | Lista salas (🔒 marca as privadas) |
229
- | `/room` | Sala atual + sua lista de buffers |
230
- | `/topic [texto\|clear]` | Mostra ou define o assunto da sala — aparece na barra de status e é sincronizado para quem entra depois |
231
- | `/owner` | Dono da sala |
232
- | `/kick` `/mute` `/ban` | Moderação (dono da sala) |
223
+ | Comando | Descrição |
224
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
+ | `/join <sala> [senha]` | Abre a sala como um **novo buffer** — você continua nas outras (estilo IRC) |
226
+ | `/leave [sala]` | Sai de uma sala; o buffer fecha (a última sala é protegida) |
227
+ | `/create <sala> <senha>` | Cria uma **sala privada** 🔒 — veja abaixo |
228
+ | `/rooms` | Lista salas (🔒 marca as privadas) |
229
+ | `/room` | Sala atual + sua lista de buffers |
230
+ | `/topic [texto\|clear]` | Mostra ou define o assunto da sala — aparece na barra de status e é sincronizado para quem entra depois |
231
+ | `/owner` | Dono da sala |
232
+ | `/kick` `/mute` `/ban` | Moderação (dono da sala) — presa à chave pública, então trocar de apelido não desfaz um ban |
233
+ | `/block` `/unblock` `/blocklist` | Pare de ver alguém, **só para você**. Nada é enviado, o relay nunca fica sabendo e a pessoa não é avisada — por isso qualquer um pode usar, inclusive na `general`, que não tem dono. Funciona no P2P também, onde não há moderação nenhuma. |
233
234
 
234
235
  **Buffers:** esteja em várias salas ao mesmo tempo — **Alt+1..9** alterna, e a
235
236
  barra de status mostra `[1:general] [2:dev •3]` com não-lidas por sala. Como o
236
- relay é cego (sealed sender), a qual sala cada mensagem pertence viaja *dentro*
237
+ relay é cego (sealed sender), a qual sala cada mensagem pertence viaja _dentro_
237
238
  do payload cifrado — o servidor nunca fica sabendo.
238
239
 
239
240
  **Salas privadas** são zero-knowledge: a senha nunca sai da sua máquina. Ao
@@ -249,22 +250,22 @@ sem verificar não leria uma palavra. Combine a senha por outro canal.
249
250
  <details>
250
251
  <summary><b>Confiança & segurança</b></summary>
251
252
 
252
- | Comando | Descrição |
253
- |---------|-----------|
254
- | `/fingerprint [nick]` | Fingerprint + um **randomart** determinístico da chave |
255
- | `/verify <nick>` | Código SAS (~40 bits) + QR + randomart da chave para verificar |
256
- | `/verify-confirm <nick>` | Marca o peer como verificado |
257
- | `/backup [caminho]` | Backup cifrado da identidade + peers verificados (restaura no startup) |
258
- | `/trust <nick>` / `/trustlist` | Aceita chave nova / status de confiança |
259
- | `/contacts [add\|remove\|all]` | Agenda — apelidos persistentes nos registros de confiança ("esse fingerprint é o João"); aparece no `/users` e viaja no backup de identidade |
260
- | `/deniable [on\|off]` | Modo de negação plausível |
253
+ | Comando | Descrição |
254
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
255
+ | `/fingerprint [nick]` | Fingerprint + um **randomart** determinístico da chave |
256
+ | `/verify <nick>` | Código SAS (~40 bits) + QR + randomart da chave para verificar |
257
+ | `/verify-confirm <nick>` | Marca o peer como verificado |
258
+ | `/backup [caminho]` | Backup cifrado da identidade + peers verificados (restaura no startup) |
259
+ | `/trust <nick>` / `/trustlist` | Aceita chave nova / status de confiança |
260
+ | `/contacts [add\|remove\|all]` | Agenda — apelidos persistentes nos registros de confiança ("esse fingerprint é o João"); aparece no `/users` e viaja no backup de identidade |
261
+ | `/deniable [on\|off]` | Modo de negação plausível |
261
262
  | `/lock` / `/autolock <min\|off>` | Trava a tela atrás da passphrase da sessão — na mão ou após inatividade (`autoLock` no config). Privacidade para o "saí um minuto"; o `/panic` é para o pior minuto |
262
- | `/panic [sim]` | Wipe de coação — apaga com segurança todos os segredos do disco (sessão, histórico, confiança, chaves) e sai |
263
- | `/cover [on\|constant\|off]` | Cover traffic — `on` = iscas com jitter, `constant` = canal de taxa constante |
264
- | `/theme [nome]` | Tema de cores dos nicks: neon, matrix, mono, sunset, ocean |
265
- | `/ephemeral <30s\|5m\|1h\|off>` | Mensagens autodestrutivas |
266
- | `/receipts [on\|off]` | Envio de confirmação de leitura (✓✓) |
267
- | `/audit [n]` | Log de auditoria local |
263
+ | `/panic [sim]` | Wipe de coação — apaga com segurança todos os segredos do disco (sessão, histórico, confiança, chaves) e sai |
264
+ | `/cover [on\|constant\|off]` | Cover traffic — `on` = iscas com jitter, `constant` = canal de taxa constante |
265
+ | `/theme [nome]` | Tema de cores dos nicks: neon, matrix, mono, sunset, ocean |
266
+ | `/ephemeral <30s\|5m\|1h\|off>` | Mensagens autodestrutivas |
267
+ | `/receipts [on\|off]` | Envio de confirmação de leitura (✓✓) |
268
+ | `/audit [n]` | Log de auditoria local |
268
269
 
269
270
  Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗** vermelho sinaliza uma chave que mudou desde a última vez (possível MITM). Um peer novo não-verificado dispara um lembrete único para `/verify`.
270
271
 
@@ -273,41 +274,41 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
273
274
  <details>
274
275
  <summary><b>Histórico & arquivos</b></summary>
275
276
 
276
- | Comando | Descrição |
277
- |---------|-----------|
278
- | `/file <caminho>` | Oferece arquivo (≤ 50MB) — o destinatário precisa dar `/accept`; retoma |
279
- | `/voice [seg]` | Grava e envia nota de voz cifrada (precisa de `sox`/`ffmpeg`; default 10s) |
280
- | `/play [caminho]` | Toca a última nota de voz recebida (`afplay`/`sox`/`ffplay`) |
281
- | `/accept [id]` / `/reject [id]` | Aceita / recusa uma oferta de arquivo recebida |
282
- | `/img [caminho]` | Renderiza a última imagem recebida em **alta resolução** (kitty/iTerm2) |
283
- | `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
284
- | `/search <termo>` | Busca no histórico local cifrado (em disco, entre sessões) |
285
- | `/find [termo]` — **Ctrl+F** | Busca **no histórico da sala na tela** e, com Enter, **salta para a mensagem** destacada |
286
- | `/doctor [host:porta]` | Diagnostica por que a conexão falha: endereço, DNS, porta TCP, TLS (CA ou self-signed) e versão de protocolo — cada falha com o que fazer |
287
- | `/history [n]` | Últimas n mensagens do histórico |
288
- | `/export [caminho]` | Exporta o histórico em .txt ou .json (texto plano!) |
277
+ | Comando | Descrição |
278
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
279
+ | `/file <caminho>` | Oferece arquivo (≤ 50MB) — o destinatário precisa dar `/accept`; retoma |
280
+ | `/voice [seg]` | Grava e envia nota de voz cifrada (precisa de `sox`/`ffmpeg`; default 10s) |
281
+ | `/play [caminho]` | Toca a última nota de voz recebida (`afplay`/`sox`/`ffplay`) |
282
+ | `/accept [id]` / `/reject [id]` | Aceita / recusa uma oferta de arquivo recebida |
283
+ | `/img [caminho]` | Renderiza a última imagem recebida em **alta resolução** (kitty/iTerm2) |
284
+ | `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
285
+ | `/search <termo>` | Busca no histórico local cifrado (em disco, entre sessões) |
286
+ | `/find [termo]` — **Ctrl+F** | Busca **no histórico da sala na tela** e, com Enter, **salta para a mensagem** destacada |
287
+ | `/doctor [host:porta]` | Diagnostica por que a conexão falha: endereço, DNS, porta TCP, TLS (CA ou self-signed) e versão de protocolo — cada falha com o que fazer |
288
+ | `/history [n]` | Últimas n mensagens do histórico |
289
+ | `/export [caminho]` | Exporta o histórico em .txt ou .json (texto plano!) |
289
290
 
290
291
  </details>
291
292
 
292
293
  <details>
293
294
  <summary><b>Presença & diversão</b></summary>
294
295
 
295
- | Comando | Descrição |
296
- |---------|-----------|
297
- | `/away [motivo]` / `/back` | Marca/remove ausência — enquanto ausente, não-lidas são contadas (`[away · N new]`) e o `/back` mostra um resumo |
298
- | `/mentions [n]` | Menções recentes a você na sessão (quem, onde, quando) |
299
- | `/status <texto\|off>` | Status livre — emoji à vontade (`/status :fire: codando`) |
300
- | `/react <emoji>` | Reage à última mensagem — o emoji aparece **na própria mensagem**, com contagem quando várias pessoas reagem |
301
- | `/edit` `/delete` | Edita ou apaga sua última mensagem — a **linha original é reescrita no lugar** (marcada *(edited)*) ou vira uma lápide, em vez de uma linha nova que você precisa juntar mentalmente à original |
302
- | `/pin` `/unpin` `/pins` | Fixa mensagens |
303
- | `/sound` `/notify` | Notificações sonoras / desktop |
304
- | `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Não perturbe, só menções, ou horário silencioso |
305
- | `/clear` | Limpa o chat |
296
+ | Comando | Descrição |
297
+ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
298
+ | `/away [motivo]` / `/back` | Marca/remove ausência — enquanto ausente, não-lidas são contadas (`[away · N new]`) e o `/back` mostra um resumo |
299
+ | `/mentions [n]` | Menções recentes a você na sessão (quem, onde, quando) |
300
+ | `/status <texto\|off>` | Status livre — emoji à vontade (`/status :fire: codando`) |
301
+ | `/react <emoji>` | Reage à última mensagem — o emoji aparece **na própria mensagem**, com contagem quando várias pessoas reagem |
302
+ | `/edit` `/delete` | Edita ou apaga sua última mensagem — a **linha original é reescrita no lugar** (marcada _(edited)_) ou vira uma lápide, em vez de uma linha nova que você precisa juntar mentalmente à original |
303
+ | `/pin` `/unpin` `/pins` | Fixa mensagens |
304
+ | `/sound` `/notify` | Notificações sonoras / desktop |
305
+ | `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Não perturbe, só menções, ou horário silencioso |
306
+ | `/clear` | Limpa o chat |
306
307
 
307
308
  </details>
308
309
 
309
310
  Digitar `:fire:` em qualquer lugar vira 🔥 (Tab autocompleta shortcodes).
310
- **Ctrl+K** abre uma paleta de comandos fuzzy, **Ctrl+E** um seletor de emoji. PageUp/PageDown rolam o histórico. **Alt+Enter** (ou Shift+Enter onde o terminal suporta, além de Ctrl+J) insere uma nova linha para mensagens de várias linhas; Enter envia. Colar texto multi-linha (código incluso) preserva as quebras — cola, confere, Enter. Markdown funciona: \`código\`, **negrito**, *itálico*, links, além de blocos de código \`\`\` e | tabelas |. Imagens recebidas têm preview inline (half-blocks) e renderizam em alta resolução com `/img` no kitty/iTerm2. Separadores de dia e agrupamento de mensagens deixam o log limpo.
311
+ **Ctrl+K** abre uma paleta de comandos fuzzy, **Ctrl+E** um seletor de emoji. PageUp/PageDown rolam o histórico. **Alt+Enter** (ou Shift+Enter onde o terminal suporta, além de Ctrl+J) insere uma nova linha para mensagens de várias linhas; Enter envia. Colar texto multi-linha (código incluso) preserva as quebras — cola, confere, Enter. Markdown funciona: \`código\`, **negrito**, _itálico_, links, além de blocos de código \`\`\` e | tabelas |. Imagens recebidas têm preview inline (half-blocks) e renderizam em alta resolução com `/img` no kitty/iTerm2. Separadores de dia e agrupamento de mensagens deixam o log limpo.
311
312
 
312
313
  ### Primeira execução & arquivo de config
313
314
 
@@ -364,7 +365,7 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
364
365
  **Argon2id + XSalsa20-Poly1305** — sem passphrase, nada persiste.
365
366
  - **Pós-quântico híbrido**: cada sessão mistura um segredo ML-KEM-768 na raiz
366
367
  do ratchet na inicialização, então tráfego gravado hoje continua ilegível
367
- para um adversário quântico futuro. Ele é *somado* ao X25519, nunca o
368
+ para um adversário quântico futuro. Ele é _somado_ ao X25519, nunca o
368
369
  substitui — a segurança é no mínimo a clássica. O `/trustlist` mostra `[PQ]`.
369
370
  - **Salas privadas** nunca enviam a senha a lugar nenhum: ela deriva uma chave
370
371
  Ed25519 (Argon2id) que responde a um desafio do servidor, e o conteúdo da
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",
@@ -328,6 +328,17 @@ export class P2PChatController {
328
328
 
329
329
  // ── Handle message from peer ───────────────────────────────────
330
330
  #onPeerMessage(fromNickname, msg) {
331
+ // Blocked: drop it before anything else, so it covers direct and group
332
+ // messages alike. Nothing goes back, so the sender cannot tell.
333
+ //
334
+ // This matters more here than on a relay: P2P has no room owners at all,
335
+ // which means no /kick, no /ban and no moderation of any kind. Refusing to
336
+ // listen is the only protection there is.
337
+ const fromKey = this.#handshake.getPeerPublicKey(fromNickname);
338
+ if (fromKey && this.#trustStore.isBlocked(fromKey)) {
339
+ return;
340
+ }
341
+
331
342
  if (msg.type === 'p2p_group') {
332
343
  this.#onGroupMessage(fromNickname, msg);
333
344
  return;
@@ -1369,6 +1380,9 @@ export class P2PChatController {
1369
1380
  this.#ui.addInfoMessage(' /pins - List pinned messages');
1370
1381
  this.#ui.addInfoMessage(' /deniable [on|off] - Deniable mode (symmetric crypto)');
1371
1382
  this.#ui.addInfoMessage(' /cover [on|constant|off] - Cover traffic (masks timing/volume)');
1383
+ this.#ui.addInfoMessage(' /block <nick> - Stop seeing someone, just for you');
1384
+ this.#ui.addInfoMessage(' /unblock <nick> - Undo a block');
1385
+ this.#ui.addInfoMessage(' /blocklist - Who you have blocked');
1372
1386
  this.#ui.addInfoMessage(' /kick, /mute, /ban - (server mode only)');
1373
1387
  this.#ui.addInfoMessage(' /theme [name] - Nick color theme');
1374
1388
  this.#ui.addInfoMessage(
@@ -1987,11 +2001,60 @@ export class P2PChatController {
1987
2001
  break;
1988
2002
  }
1989
2003
 
2004
+ // Moderation needs a room owner, and P2P has none — there is nobody with
2005
+ // authority over anybody. Blocking needs no authority at all, which is
2006
+ // exactly why it works here when nothing else does.
2007
+ case '/block': {
2008
+ const blockNick = parts[1];
2009
+ if (!blockNick) {
2010
+ this.#ui.addErrorMessage('Usage: /block <nick>');
2011
+ break;
2012
+ }
2013
+ if (this.#trustStore.blockPeer(blockNick)) {
2014
+ this.#ui.addInfoMessage(
2015
+ `Blocked ${blockNick}. You will not see their messages; they are not told.`,
2016
+ );
2017
+ } else {
2018
+ this.#ui.addErrorMessage(`No record of "${blockNick}" — you can only block someone seen`);
2019
+ }
2020
+ break;
2021
+ }
2022
+
2023
+ case '/unblock': {
2024
+ const unblockNick = parts[1];
2025
+ if (!unblockNick) {
2026
+ this.#ui.addErrorMessage('Usage: /unblock <nick>');
2027
+ break;
2028
+ }
2029
+ if (this.#trustStore.unblockPeer(unblockNick)) {
2030
+ this.#ui.addInfoMessage(`Unblocked ${unblockNick}`);
2031
+ } else {
2032
+ this.#ui.addErrorMessage(`"${unblockNick}" was not blocked`);
2033
+ }
2034
+ break;
2035
+ }
2036
+
2037
+ case '/blocklist': {
2038
+ const blocked = this.#trustStore.listBlocked();
2039
+ if (blocked.length === 0) {
2040
+ this.#ui.addInfoMessage('Nobody blocked');
2041
+ break;
2042
+ }
2043
+ this.#ui.addInfoMessage(`Blocked (${blocked.length}):`);
2044
+ for (const entry of blocked) {
2045
+ const label = entry.alias ? `${entry.nickname} (${entry.alias})` : entry.nickname;
2046
+ this.#ui.addInfoMessage(` ${label} ${entry.fingerprint}`);
2047
+ }
2048
+ break;
2049
+ }
2050
+
1990
2051
  case '/kick':
1991
2052
  case '/mute':
1992
2053
  case '/ban':
1993
2054
  case '/owner':
1994
- this.#ui.addErrorMessage('Moderation not available in P2P mode');
2055
+ this.#ui.addErrorMessage(
2056
+ 'Moderation needs a room owner, and P2P has none. Use /block to stop seeing someone.',
2057
+ );
1995
2058
  break;
1996
2059
 
1997
2060
  case '/plugins': {