ciphermesh 2.7.2 → 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,44 @@
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
+
16
+ ## 2.8.0
17
+
18
+ ### Added
19
+
20
+ - **`/block`, `/unblock`, `/blocklist`.** Stop seeing someone, just for you.
21
+ Entirely local: nothing is sent, the relay never learns, and the other person
22
+ is not told. That is why everyone gets it — moderating a room acts on
23
+ everybody and so has to belong to the owner, while refusing to listen acts
24
+ only on yourself and needs no authority at all.
25
+
26
+ It is also the only protection that works in `general`, which has no owner and
27
+ therefore no moderation. Blocks live in the trust store, so they survive a
28
+ restart, are stored `0600`, and are wiped by `/panic` along with everything
29
+ else.
30
+
31
+ ### Fixed
32
+
33
+ - **A room ban was undone by `/nick`.** Bans were stored against the nickname,
34
+ and anyone can pick a new one whenever they like: get banned, rename, walk
35
+ back in. Room owners are the only moderation in the system — the operator
36
+ cannot read content and deliberately holds no in-chat authority — so their one
37
+ tool was defeated by a single word. Bans are now bound to the public key
38
+ (#438).
39
+
40
+ The correct pattern was already in the codebase: the offline queue looks up by
41
+ nickname but verifies the public key before delivering. The ban list was the
42
+ one place a nickname was treated as an identity.
43
+
6
44
  ## 2.7.2
7
45
 
8
46
  ### Fixed
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
 
@@ -122,15 +122,19 @@ the server against a real CA (no trust-on-first-use window), and a host that
122
122
  once served a valid certificate can never be silently downgraded to a
123
123
  self-signed one.
124
124
 
125
- **No Node at all?** Every release ships standalone binaries for macOS and Linux
126
- (arm64/x64) — download from the
127
- [releases page](https://github.com/FelipeKreulich/secret-chat-lan/releases),
125
+ **No Node at all?** Standalone binaries for macOS and Linux (arm64/x64) —
126
+ download from the
127
+ [latest release](https://github.com/FelipeKreulich/secret-chat-lan/releases/latest),
128
128
  `chmod +x`, run. Nothing to install, not even Node.
129
129
 
130
- | Binary | What it is |
131
- |---|---|
132
- | `ciphermesh-<platform>` | Everything: client, relay and P2P. `ciphermesh server` and `ciphermesh p2p` work exactly as they do on npm. |
133
- | `ciphermesh-server-<platform>` | Relay only, for self-hosters who want nothing else on the machine. |
130
+ > Take the binaries from the **latest** release. Anything published before
131
+ > v2.7.2 never embedded its native addon and only ran on the machine that built
132
+ > it, so those attachments have been removed.
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
138
 
135
139
  **Everyone** (including the host):
136
140
 
@@ -180,44 +184,56 @@ sleep. Use is governed by the **[terms](TERMS.md)**.
180
184
  Bring people in: `/invite` prints a joinable string and a QR code, and
181
185
  `/rooms` shows what is live.
182
186
 
187
+ **What the hub is for.** It exists so anyone can try CipherMesh and find other
188
+ people using it without running a server first. It is a meeting point, not a
189
+ general-purpose communications service — rooms are nobody's home, there are no
190
+ accounts, and nothing is kept between sessions.
191
+
192
+ **If you need a relay you control, run one.** It answers to you, depends on
193
+ nobody else's uptime, and does not put your conversations through a machine a
194
+ stranger administers. [`deploy/`](deploy/README.md) has the Docker setup ready to
195
+ go. For anything that matters that is the better answer, and it is the one this
196
+ software was built for.
197
+
183
198
  ## 💬 Commands
184
199
 
185
200
  <details>
186
201
  <summary><b>Essentials</b></summary>
187
202
 
188
- | Command | Description |
189
- |---------|-------------|
190
- | `/help` | All commands |
191
- | `/tips` | Show a rotating security/UX tip |
192
- | `/users` | Who's online (with away/status) |
193
- | `/msg <nick> <text>` | Private message (DM) |
194
- | `/reply <text>` | Reply quoting the last received message |
195
- | `/me <action>` | Third-person action — felipe is compiling»* |
196
- | `/watch [add\|remove\|clear]` | Alert on a keyword in **any** room, like a mention |
197
- | `/invite [host:port]` | Generate a `ciphermesh://` invite + QR code |
198
- | `/nick <new>` | Change nickname (before joining — recovers from "nickname taken") |
199
- | `/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 |
200
215
 
201
216
  </details>
202
217
 
203
218
  <details>
204
219
  <summary><b>Rooms</b></summary>
205
220
 
206
- | Command | Description |
207
- |---------|-------------|
208
- | `/join <room> [password]` | Open a room as a **new buffer** — you stay in your other rooms (IRC style) |
209
- | `/leave [room]` | Leave a room; its buffer closes (the last room is protected) |
210
- | `/create <room> <password>` | Create a **private room** 🔒 — see below |
211
- | `/rooms` | List rooms (🔒 marks private ones) |
212
- | `/room` | Current room + your buffer list |
213
- | `/topic [text\|clear]` | Show or set the room topic — shown in the status bar and synced to whoever joins later |
214
- | `/owner` | Room owner |
215
- | `/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. |
216
232
 
217
233
  **Buffers:** be in several rooms at once — **Alt+1..9** switches, and the status
218
234
  bar shows `[1:general] [2:dev •3]` with per-room unread badges. Because the
219
235
  relay is blind (sealed sender), which room a message belongs to travels
220
- *inside* the encrypted payload — the server never learns it.
236
+ _inside_ the encrypted payload — the server never learns it.
221
237
 
222
238
  **Private rooms** are zero-knowledge: the password never leaves your machine.
223
239
  Joining derives an Ed25519 key from the password (Argon2id) and answers a
@@ -232,22 +248,22 @@ without verifying couldn't read a word. Share the password out-of-band.
232
248
  <details>
233
249
  <summary><b>Trust & security</b></summary>
234
250
 
235
- | Command | Description |
236
- |---------|-------------|
237
- | `/fingerprint [nick]` | Key fingerprint + a deterministic **randomart** picture of the key |
238
- | `/verify <nick>` | SAS code (~40-bit) + QR + key randomart for out-of-band verification |
239
- | `/verify-confirm <nick>` | Mark peer as verified |
240
- | `/trust <nick>` / `/trustlist` | Accept new key / trust status |
241
- | `/contacts [add\|remove\|all]` | Contact book — persistent aliases on trust records ("this fingerprint is João"); shows in `/users`, rides along in identity backups |
242
- | `/backup [path]` | Encrypted backup of identity + verified peers (restore at startup) |
243
- | `/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 |
244
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 |
245
- | `/panic [yes]` | Duress wipe — securely erase all on-disk secrets (session, history, trust, keys) and exit |
246
- | `/cover [on\|constant\|off]` | Cover traffic — `on` = jittered decoys, `constant` = steady-rate paced channel |
247
- | `/theme [name]` | Nick colour theme: neon, matrix, mono, sunset, ocean |
248
- | `/ephemeral <30s\|5m\|1h\|off>` | Self-destructing messages |
249
- | `/receipts [on\|off]` | Send read receipts (✓✓) |
250
- | `/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 |
251
267
 
252
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.
253
269
 
@@ -256,40 +272,40 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
256
272
  <details>
257
273
  <summary><b>History & files</b></summary>
258
274
 
259
- | Command | Description |
260
- |---------|-------------|
261
- | `/file <path>` | Offer a file (≤ 50MB) — the recipient must `/accept`; transfers resume |
262
- | `/voice [secs]` | Record & send an encrypted voice note (needs `sox`/`ffmpeg`; default 10s) |
263
- | `/play [path]` | Play the last received voice note (`afplay`/`sox`/`ffplay`) |
264
- | `/accept [id]` / `/reject [id]` | Accept / decline an incoming file offer |
265
- | `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
266
- | `/search <term>` | Search the encrypted local history (on disk, across sessions) |
267
- | `/find [term]` — **Ctrl+F** | Search **this room's scrollback** and press Enter to **jump to the message**, highlighted |
268
- | `/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 |
269
- | `/history [n]` | Last n messages from history |
270
- | `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
271
- | `/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!) |
272
288
 
273
289
  </details>
274
290
 
275
291
  <details>
276
292
  <summary><b>Presence & fun</b></summary>
277
293
 
278
- | Command | Description |
279
- |---------|-------------|
280
- | `/away [reason]` / `/back` | Mark yourself away — while away, unreads are counted (`[away · N new]`) and `/back` shows a summary |
281
- | `/mentions [n]` | Recent mentions of you this session (who, where, when) |
282
- | `/status <text\|off>` | Free-form status — emojis welcome (`/status :fire: coding`) |
283
- | `/react <emoji>` | React to the last message — the emoji lands **on the message**, with a count when several people react |
284
- | `/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 |
285
- | `/pin` `/unpin` `/pins` | Pin messages |
286
- | `/sound` `/notify` | Sound / desktop notifications |
287
- | `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Do-not-disturb, mentions-only, or quiet hours |
288
- | `/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 |
289
305
 
290
306
  </details>
291
307
 
292
- 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.
293
309
 
294
310
  ### First run & config file
295
311
 
@@ -345,7 +361,7 @@ All keys are optional (unknown keys are ignored):
345
361
  **Argon2id + XSalsa20-Poly1305** — no passphrase, no persistence.
346
362
  - **Hybrid post-quantum**: each pairwise session mixes an ML-KEM-768 secret
347
363
  into the ratchet root at setup, so recorded traffic stays unreadable to a
348
- future quantum adversary. It is *added* to X25519, never replaces it —
364
+ future quantum adversary. It is _added_ to X25519, never replaces it —
349
365
  security is at least the classical one. `/trustlist` shows `[PQ]`.
350
366
  - **Private rooms** never send the password anywhere: it derives an Ed25519
351
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
 
@@ -123,15 +123,19 @@ clientes verificam o servidor contra uma CA de verdade (sem janela de
123
123
  trust-on-first-use), e um host que já apresentou certificado válido nunca pode
124
124
  ser rebaixado silenciosamente para um self-signed.
125
125
 
126
- **Sem Node nenhum?** Todo release traz binários standalone para macOS e Linux
127
- (arm64/x64) — baixe da
128
- [página de releases](https://github.com/FelipeKreulich/secret-chat-lan/releases),
126
+ **Sem Node nenhum?** Binários standalone para macOS e Linux (arm64/x64) — baixe
127
+ do
128
+ [último release](https://github.com/FelipeKreulich/secret-chat-lan/releases/latest),
129
129
  `chmod +x`, rode. Nada para instalar, nem Node.
130
130
 
131
- | Binário | O que é |
132
- |---|---|
133
- | `ciphermesh-<plataforma>` | Tudo: cliente, relay e P2P. `ciphermesh server` e `ciphermesh p2p` funcionam igual ao npm. |
134
- | `ciphermesh-server-<plataforma>` | Só o relay, para quem hospeda e não quer mais nada na máquina. |
131
+ > Pegue os binários do release **mais recente**. Os publicados antes da v2.7.2
132
+ > nunca embutiram o addon nativo e só rodavam na máquina que os construiu, então
133
+ > aqueles anexos foram removidos.
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
139
 
136
140
  **Todo mundo** (incluindo quem hospeda):
137
141
 
@@ -182,43 +186,55 @@ uso é regido pelos **[termos](TERMS.md)**.
182
186
  Para chamar gente: `/invite` imprime uma string de entrada e um QR code, e
183
187
  `/rooms` mostra o que está no ar.
184
188
 
189
+ **Para que o hub existe.** Ele existe para qualquer pessoa poder experimentar o
190
+ CipherMesh e encontrar outras pessoas que o usam sem precisar subir um servidor
191
+ antes. É um ponto de encontro, não um serviço de comunicação de uso geral — as
192
+ salas não são casa de ninguém, não há contas, e nada fica entre sessões.
193
+
194
+ **Se você precisa de um relay que seja seu, suba um.** Ele responde a você, não
195
+ depende da disponibilidade de mais ninguém, e não faz suas conversas passarem por
196
+ uma máquina administrada por um estranho. A pasta [`deploy/`](deploy/README.md)
197
+ tem o setup Docker pronto. Para o que é importante essa é a melhor resposta — e é
198
+ aquela para a qual este software foi feito.
199
+
185
200
  ## 💬 Comandos
186
201
 
187
202
  <details>
188
203
  <summary><b>Essenciais</b></summary>
189
204
 
190
- | Comando | Descrição |
191
- |---------|-----------|
192
- | `/help` | Todos os comandos |
193
- | `/tips` | Mostra uma dica rotativa de segurança/UX |
194
- | `/users` | Quem está online (com away/status) |
195
- | `/msg <nick> <texto>` | Mensagem privada (DM) |
196
- | `/reply <texto>` | Responde citando a última mensagem recebida |
197
- | `/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»_ |
198
213
  | `/watch [add\|remove\|clear]` | Alerta quando uma palavra aparece em **qualquer** sala, como uma menção |
199
- | `/invite [host:porta]` | Gera convite `ciphermesh://` + QR code |
200
- | `/nick <novo>` | Troca de apelido (antes de entrar — recupera de "apelido em uso") |
201
- | `/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 |
202
217
 
203
218
  </details>
204
219
 
205
220
  <details>
206
221
  <summary><b>Salas</b></summary>
207
222
 
208
- | Comando | Descrição |
209
- |---------|-----------|
210
- | `/join <sala> [senha]` | Abre a sala como um **novo buffer** — você continua nas outras (estilo IRC) |
211
- | `/leave [sala]` | Sai de uma sala; o buffer fecha (a última sala é protegida) |
212
- | `/create <sala> <senha>` | Cria uma **sala privada** 🔒 — veja abaixo |
213
- | `/rooms` | Lista salas (🔒 marca as privadas) |
214
- | `/room` | Sala atual + sua lista de buffers |
215
- | `/topic [texto\|clear]` | Mostra ou define o assunto da sala — aparece na barra de status e é sincronizado para quem entra depois |
216
- | `/owner` | Dono da sala |
217
- | `/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. |
218
234
 
219
235
  **Buffers:** esteja em várias salas ao mesmo tempo — **Alt+1..9** alterna, e a
220
236
  barra de status mostra `[1:general] [2:dev •3]` com não-lidas por sala. Como o
221
- 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_
222
238
  do payload cifrado — o servidor nunca fica sabendo.
223
239
 
224
240
  **Salas privadas** são zero-knowledge: a senha nunca sai da sua máquina. Ao
@@ -234,22 +250,22 @@ sem verificar não leria uma palavra. Combine a senha por outro canal.
234
250
  <details>
235
251
  <summary><b>Confiança & segurança</b></summary>
236
252
 
237
- | Comando | Descrição |
238
- |---------|-----------|
239
- | `/fingerprint [nick]` | Fingerprint + um **randomart** determinístico da chave |
240
- | `/verify <nick>` | Código SAS (~40 bits) + QR + randomart da chave para verificar |
241
- | `/verify-confirm <nick>` | Marca o peer como verificado |
242
- | `/backup [caminho]` | Backup cifrado da identidade + peers verificados (restaura no startup) |
243
- | `/trust <nick>` / `/trustlist` | Aceita chave nova / status de confiança |
244
- | `/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 |
245
- | `/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 |
246
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 |
247
- | `/panic [sim]` | Wipe de coação — apaga com segurança todos os segredos do disco (sessão, histórico, confiança, chaves) e sai |
248
- | `/cover [on\|constant\|off]` | Cover traffic — `on` = iscas com jitter, `constant` = canal de taxa constante |
249
- | `/theme [nome]` | Tema de cores dos nicks: neon, matrix, mono, sunset, ocean |
250
- | `/ephemeral <30s\|5m\|1h\|off>` | Mensagens autodestrutivas |
251
- | `/receipts [on\|off]` | Envio de confirmação de leitura (✓✓) |
252
- | `/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 |
253
269
 
254
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`.
255
271
 
@@ -258,41 +274,41 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
258
274
  <details>
259
275
  <summary><b>Histórico & arquivos</b></summary>
260
276
 
261
- | Comando | Descrição |
262
- |---------|-----------|
263
- | `/file <caminho>` | Oferece arquivo (≤ 50MB) — o destinatário precisa dar `/accept`; retoma |
264
- | `/voice [seg]` | Grava e envia nota de voz cifrada (precisa de `sox`/`ffmpeg`; default 10s) |
265
- | `/play [caminho]` | Toca a última nota de voz recebida (`afplay`/`sox`/`ffplay`) |
266
- | `/accept [id]` / `/reject [id]` | Aceita / recusa uma oferta de arquivo recebida |
267
- | `/img [caminho]` | Renderiza a última imagem recebida em **alta resolução** (kitty/iTerm2) |
268
- | `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
269
- | `/search <termo>` | Busca no histórico local cifrado (em disco, entre sessões) |
270
- | `/find [termo]` — **Ctrl+F** | Busca **no histórico da sala na tela** e, com Enter, **salta para a mensagem** destacada |
271
- | `/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 |
272
- | `/history [n]` | Últimas n mensagens do histórico |
273
- | `/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!) |
274
290
 
275
291
  </details>
276
292
 
277
293
  <details>
278
294
  <summary><b>Presença & diversão</b></summary>
279
295
 
280
- | Comando | Descrição |
281
- |---------|-----------|
282
- | `/away [motivo]` / `/back` | Marca/remove ausência — enquanto ausente, não-lidas são contadas (`[away · N new]`) e o `/back` mostra um resumo |
283
- | `/mentions [n]` | Menções recentes a você na sessão (quem, onde, quando) |
284
- | `/status <texto\|off>` | Status livre — emoji à vontade (`/status :fire: codando`) |
285
- | `/react <emoji>` | Reage à última mensagem — o emoji aparece **na própria mensagem**, com contagem quando várias pessoas reagem |
286
- | `/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 |
287
- | `/pin` `/unpin` `/pins` | Fixa mensagens |
288
- | `/sound` `/notify` | Notificações sonoras / desktop |
289
- | `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Não perturbe, só menções, ou horário silencioso |
290
- | `/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 |
291
307
 
292
308
  </details>
293
309
 
294
310
  Digitar `:fire:` em qualquer lugar vira 🔥 (Tab autocompleta shortcodes).
295
- **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.
296
312
 
297
313
  ### Primeira execução & arquivo de config
298
314
 
@@ -349,7 +365,7 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
349
365
  **Argon2id + XSalsa20-Poly1305** — sem passphrase, nada persiste.
350
366
  - **Pós-quântico híbrido**: cada sessão mistura um segredo ML-KEM-768 na raiz
351
367
  do ratchet na inicialização, então tráfego gravado hoje continua ilegível
352
- 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
353
369
  substitui — a segurança é no mínimo a clássica. O `/trustlist` mostra `[PQ]`.
354
370
  - **Salas privadas** nunca enviam a senha a lugar nenhum: ela deriva uma chave
355
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.7.2",
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",
@@ -896,6 +896,13 @@ export class ChatController {
896
896
  return;
897
897
  }
898
898
 
899
+ // Blocked: drop it here, before spending the decryption. Nothing goes back,
900
+ // so the sender cannot tell — refusing to listen is not a message. This is
901
+ // also the only protection that works in `general`, which has no owner.
902
+ if (this.#trustStore.isBlocked(senderPublicKey)) {
903
+ return;
904
+ }
905
+
899
906
  const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
900
907
  const nonce = Buffer.from(msg.payload.nonce, 'base64');
901
908
 
@@ -1502,6 +1509,9 @@ export class ChatController {
1502
1509
  this.#ui.addInfoMessage(' /cover [on|constant|off] - Cover traffic (masks timing/volume)');
1503
1510
  this.#ui.addInfoMessage(' /kick <nick> [reason] - Kick a user from the room (owner)');
1504
1511
  this.#ui.addInfoMessage(' /mute <nick> [time] - Mute a user (owner, default 5m)');
1512
+ this.#ui.addInfoMessage(' /block <nick> - Stop seeing someone, just for you');
1513
+ this.#ui.addInfoMessage(' /unblock <nick> - Undo a block');
1514
+ this.#ui.addInfoMessage(' /blocklist - Who you have blocked');
1505
1515
  this.#ui.addInfoMessage(' /ban <nick> [reason] - Ban a user from the room (owner)');
1506
1516
  this.#ui.addInfoMessage(' /owner - Show the current room owner');
1507
1517
  this.#ui.addInfoMessage(' /theme [name] - Nick color theme');
@@ -2501,6 +2511,55 @@ export class ChatController {
2501
2511
  break;
2502
2512
  }
2503
2513
 
2514
+ // /block is nothing like /kick, /ban or /mute: those need to be the room
2515
+ // owner because they act on everyone, and this acts only on me. Nothing
2516
+ // is sent, the relay never learns, and the other person cannot tell.
2517
+ // That is why everybody gets it — and why it is the only protection that
2518
+ // works in `general`, which has no owner at all.
2519
+ case '/block': {
2520
+ const blockNick = parts[1];
2521
+ if (!blockNick) {
2522
+ this.#ui.addErrorMessage('Usage: /block <nick>');
2523
+ break;
2524
+ }
2525
+ if (this.#trustStore.blockPeer(blockNick)) {
2526
+ this.#ui.addInfoMessage(
2527
+ `Blocked ${blockNick}. You will not see their messages; they are not told.`,
2528
+ );
2529
+ } else {
2530
+ this.#ui.addErrorMessage(`No record of "${blockNick}" — you can only block someone seen`);
2531
+ }
2532
+ break;
2533
+ }
2534
+
2535
+ case '/unblock': {
2536
+ const unblockNick = parts[1];
2537
+ if (!unblockNick) {
2538
+ this.#ui.addErrorMessage('Usage: /unblock <nick>');
2539
+ break;
2540
+ }
2541
+ if (this.#trustStore.unblockPeer(unblockNick)) {
2542
+ this.#ui.addInfoMessage(`Unblocked ${unblockNick}`);
2543
+ } else {
2544
+ this.#ui.addErrorMessage(`"${unblockNick}" was not blocked`);
2545
+ }
2546
+ break;
2547
+ }
2548
+
2549
+ case '/blocklist': {
2550
+ const blocked = this.#trustStore.listBlocked();
2551
+ if (blocked.length === 0) {
2552
+ this.#ui.addInfoMessage('Nobody blocked');
2553
+ break;
2554
+ }
2555
+ this.#ui.addInfoMessage(`Blocked (${blocked.length}):`);
2556
+ for (const entry of blocked) {
2557
+ const label = entry.alias ? `${entry.nickname} (${entry.alias})` : entry.nickname;
2558
+ this.#ui.addInfoMessage(` ${label} ${entry.fingerprint}`);
2559
+ }
2560
+ break;
2561
+ }
2562
+
2504
2563
  case '/mute': {
2505
2564
  const muteNick = parts[1];
2506
2565
  if (!muteNick) {
@@ -241,6 +241,73 @@ export class TrustStore {
241
241
  .sort((a, b) => b.lastSeen - a.lastSeen);
242
242
  }
243
243
 
244
+ // ── Blocking ─────────────────────────────────────────────────
245
+ // Entirely local: nothing is sent, the relay never learns, and blocking
246
+ // someone affects only the person who did it. That is what makes it safe to
247
+ // give to everyone — moderation needs authority and so has to be limited to
248
+ // room owners, but refusing to listen needs none.
249
+ //
250
+ // It is also the only protection available in `general`, which has no owner
251
+ // and therefore no moderation at all.
252
+ //
253
+ // Matched on the public key, never the nickname: /nick would otherwise undo a
254
+ // block the same way it used to undo a ban.
255
+
256
+ /** Block an already-seen peer. False if we have never seen them. */
257
+ blockPeer(nickname) {
258
+ const record = this.#store.get(nickname.toLowerCase());
259
+ if (!record) {
260
+ return false;
261
+ }
262
+ record.blocked = true;
263
+ this.#save();
264
+ return true;
265
+ }
266
+
267
+ /** Unblock a peer. False if they were not blocked. */
268
+ unblockPeer(nickname) {
269
+ const record = this.#store.get(nickname.toLowerCase());
270
+ if (!record?.blocked) {
271
+ return false;
272
+ }
273
+ delete record.blocked;
274
+ this.#save();
275
+ return true;
276
+ }
277
+
278
+ /**
279
+ * Is this key blocked?
280
+ *
281
+ * Records are filed under the nickname they were first seen with, so a
282
+ * rename leaves the record where it was — the key is what has to match.
283
+ *
284
+ * @param {string} publicKeyB64
285
+ */
286
+ isBlocked(publicKeyB64) {
287
+ if (!publicKeyB64) {
288
+ return false;
289
+ }
290
+ for (const record of this.#store.values()) {
291
+ if (record.blocked && record.publicKey === publicKeyB64) {
292
+ return true;
293
+ }
294
+ }
295
+ return false;
296
+ }
297
+
298
+ /** Everyone currently blocked, most recently seen first. */
299
+ listBlocked() {
300
+ return [...this.#store.entries()]
301
+ .filter(([, r]) => r.blocked)
302
+ .map(([nickname, r]) => ({
303
+ nickname,
304
+ alias: r.alias || null,
305
+ fingerprint: r.fingerprint,
306
+ lastSeen: r.lastSeen || 0,
307
+ }))
308
+ .sort((a, b) => b.lastSeen - a.lastSeen);
309
+ }
310
+
244
311
  /** Export all trust records as a plain object (for identity backup). */
245
312
  exportData() {
246
313
  return Object.fromEntries(this.#store);
@@ -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': {
@@ -394,16 +394,38 @@ export class SessionManager {
394
394
  this.#muteState.set(sessionId, { until: Date.now() + durationMs });
395
395
  }
396
396
 
397
- banPeer(room, nickname) {
397
+ /**
398
+ * Ban someone from a room.
399
+ *
400
+ * Keyed on the public key, never the nickname: /nick lets anyone pick a new
401
+ * name whenever they like, so a nickname ban was undone by typing one word.
402
+ * The public key is what an identity actually is here — the offline queue
403
+ * already binds delivery to it, and this was the one place that did not.
404
+ *
405
+ * A new keypair still gets you back in, but it costs the verified status you
406
+ * had with every contact and shows up as unverified under TOFU. That is a
407
+ * real price; a new nickname is not.
408
+ *
409
+ * @param {string} room
410
+ * @param {string} publicKey - base64, as the session carries it
411
+ */
412
+ banPeer(room, publicKey) {
413
+ if (!publicKey) {
414
+ return;
415
+ }
398
416
  if (!this.#banList.has(room)) {
399
417
  this.#banList.set(room, new Set());
400
418
  }
401
- this.#banList.get(room).add(nickname.toLowerCase());
419
+ this.#banList.get(room).add(publicKey);
402
420
  }
403
421
 
404
- isBanned(room, nickname) {
422
+ /**
423
+ * @param {string} room
424
+ * @param {string} publicKey - base64
425
+ */
426
+ isBanned(room, publicKey) {
405
427
  const banned = this.#banList.get(room);
406
- return banned ? banned.has(nickname.toLowerCase()) : false;
428
+ return !!publicKey && !!banned && banned.has(publicKey);
407
429
  }
408
430
 
409
431
  findSessionByNickname(nickname) {
@@ -381,7 +381,7 @@ export class SecureWSServer {
381
381
  const session = this.#sessionManager.getSession(ws.sessionId);
382
382
 
383
383
  // Check if user is banned from target room
384
- if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
384
+ if (this.#sessionManager.isBanned(validation.room, session.publicKey)) {
385
385
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
386
386
  return;
387
387
  }
@@ -474,7 +474,7 @@ export class SecureWSServer {
474
474
  }
475
475
 
476
476
  const session = this.#sessionManager.getSession(ws.sessionId);
477
- if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
477
+ if (this.#sessionManager.isBanned(validation.room, session.publicKey)) {
478
478
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
479
479
  return;
480
480
  }
@@ -655,7 +655,7 @@ export class SecureWSServer {
655
655
  }
656
656
 
657
657
  const session = this.#sessionManager.getSession(ws.sessionId);
658
- if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
658
+ if (this.#sessionManager.isBanned(validation.room, session.publicKey)) {
659
659
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
660
660
  return;
661
661
  }
@@ -914,13 +914,12 @@ export class SecureWSServer {
914
914
  return;
915
915
  }
916
916
 
917
- // Ban + kick to general
918
- this.#sessionManager.banPeer(room, validation.targetNickname);
917
+ // Ban the key, not the name: /nick would otherwise undo this in one word.
918
+ const targetSession = this.#sessionManager.getSession(targetSessionId);
919
+ this.#sessionManager.banPeer(room, targetSession?.publicKey);
919
920
 
920
921
  const result = this.#sessionManager.switchRoom(targetSessionId, 'general');
921
922
  if (result) {
922
- const targetSession = this.#sessionManager.getSession(targetSessionId);
923
-
924
923
  this.#sessionManager.broadcastToRoom(
925
924
  room,
926
925
  createPeerKicked(validation.targetNickname, validation.reason || 'banned'),