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 +38 -0
- package/README.md +109 -93
- package/README.pt-BR.md +108 -92
- package/package.json +1 -1
- package/src/client/ChatController.js +59 -0
- package/src/crypto/TrustStore.js +67 -0
- package/src/p2p/P2PChatController.js +64 -1
- package/src/server/SessionManager.js +26 -4
- package/src/server/WebSocketServer.js +6 -7
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
|
|
45
|
-
|
|
46
|
-
| 🔐
|
|
47
|
-
| 🔄
|
|
48
|
-
| 🛡️
|
|
49
|
-
| 🕶️
|
|
50
|
-
| 🕵️
|
|
51
|
-
| 🌐
|
|
52
|
-
| 📨
|
|
53
|
-
| ✓✓
|
|
54
|
-
| 🗂️
|
|
55
|
-
| 🖼️
|
|
56
|
-
| 📎
|
|
57
|
-
| 💬
|
|
58
|
-
| 🎞️
|
|
59
|
-
| 👻
|
|
60
|
-
| 🔒
|
|
61
|
-
| 🗂️
|
|
62
|
-
| 🩺
|
|
63
|
-
| 🔐
|
|
64
|
-
| 🛰️
|
|
65
|
-
| 🧩
|
|
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?**
|
|
126
|
-
|
|
127
|
-
[
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
| `/help`
|
|
191
|
-
| `/tips`
|
|
192
|
-
| `/users`
|
|
193
|
-
| `/msg <nick> <text>`
|
|
194
|
-
| `/reply <text>`
|
|
195
|
-
| `/me <action>`
|
|
196
|
-
| `/watch [add\|remove\|clear]` | Alert on a keyword in **any** room, like a mention
|
|
197
|
-
| `/invite [host:port]`
|
|
198
|
-
| `/nick <new>`
|
|
199
|
-
| `/quit`
|
|
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
|
|
207
|
-
|
|
208
|
-
| `/join <room> [password]`
|
|
209
|
-
| `/leave [room]`
|
|
210
|
-
| `/create <room> <password>`
|
|
211
|
-
| `/rooms`
|
|
212
|
-
| `/room`
|
|
213
|
-
| `/topic [text\|clear]`
|
|
214
|
-
| `/owner`
|
|
215
|
-
| `/kick` `/mute` `/ban`
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
| `/fingerprint [nick]`
|
|
238
|
-
| `/verify <nick>`
|
|
239
|
-
| `/verify-confirm <nick>`
|
|
240
|
-
| `/trust <nick>` / `/trustlist`
|
|
241
|
-
| `/contacts [add\|remove\|all]`
|
|
242
|
-
| `/backup [path]`
|
|
243
|
-
| `/deniable [on\|off]`
|
|
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]`
|
|
246
|
-
| `/cover [on\|constant\|off]`
|
|
247
|
-
| `/theme [name]`
|
|
248
|
-
| `/ephemeral <30s\|5m\|1h\|off>`
|
|
249
|
-
| `/receipts [on\|off]`
|
|
250
|
-
| `/audit [n]`
|
|
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
|
|
260
|
-
|
|
261
|
-
| `/file <path>`
|
|
262
|
-
| `/voice [secs]`
|
|
263
|
-
| `/play [path]`
|
|
264
|
-
| `/accept [id]` / `/reject [id]` | Accept / decline an incoming file offer
|
|
265
|
-
| `/img [path]`
|
|
266
|
-
| `/search <term>`
|
|
267
|
-
| `/find [term]` — **Ctrl+F**
|
|
268
|
-
| `/doctor [host:port]`
|
|
269
|
-
| `/history [n]`
|
|
270
|
-
| `/retention <7d\|24h\|30m>`
|
|
271
|
-
| `/export [path]`
|
|
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
|
|
279
|
-
|
|
280
|
-
| `/away [reason]` / `/back`
|
|
281
|
-
| `/mentions [n]`
|
|
282
|
-
| `/status <text\|off>`
|
|
283
|
-
| `/react <emoji>`
|
|
284
|
-
| `/edit` `/delete`
|
|
285
|
-
| `/pin` `/unpin` `/pins`
|
|
286
|
-
| `/sound` `/notify`
|
|
287
|
-
| `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Do-not-disturb, mentions-only, or quiet hours
|
|
288
|
-
| `/clear`
|
|
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**,
|
|
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
|
|
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
|
|
45
|
-
|
|
46
|
-
| 🔐
|
|
47
|
-
| 🔄
|
|
48
|
-
| 🛡️
|
|
49
|
-
| 🕶️
|
|
50
|
-
| 🕵️
|
|
51
|
-
| 🌐
|
|
52
|
-
| 📨
|
|
53
|
-
| ✓✓
|
|
54
|
-
| 🗂️
|
|
55
|
-
| 🖼️
|
|
56
|
-
| 📎
|
|
57
|
-
| 💬
|
|
58
|
-
| 🎞️
|
|
59
|
-
| 👻
|
|
60
|
-
| 🔒
|
|
61
|
-
| 🗂️
|
|
62
|
-
| 🩺
|
|
63
|
-
| 🔐
|
|
64
|
-
| 🛰️
|
|
65
|
-
| 🧩
|
|
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?**
|
|
127
|
-
|
|
128
|
-
[
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
| `/help`
|
|
193
|
-
| `/tips`
|
|
194
|
-
| `/users`
|
|
195
|
-
| `/msg <nick> <texto>`
|
|
196
|
-
| `/reply <texto>`
|
|
197
|
-
| `/me <ação>`
|
|
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]`
|
|
200
|
-
| `/nick <novo>`
|
|
201
|
-
| `/quit`
|
|
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
|
|
209
|
-
|
|
210
|
-
| `/join <sala> [senha]`
|
|
211
|
-
| `/leave [sala]`
|
|
212
|
-
| `/create <sala> <senha>`
|
|
213
|
-
| `/rooms`
|
|
214
|
-
| `/room`
|
|
215
|
-
| `/topic [texto\|clear]`
|
|
216
|
-
| `/owner`
|
|
217
|
-
| `/kick` `/mute` `/ban`
|
|
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
|
|
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
|
|
238
|
-
|
|
239
|
-
| `/fingerprint [nick]`
|
|
240
|
-
| `/verify <nick>`
|
|
241
|
-
| `/verify-confirm <nick>`
|
|
242
|
-
| `/backup [caminho]`
|
|
243
|
-
| `/trust <nick>` / `/trustlist`
|
|
244
|
-
| `/contacts [add\|remove\|all]`
|
|
245
|
-
| `/deniable [on\|off]`
|
|
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]`
|
|
248
|
-
| `/cover [on\|constant\|off]`
|
|
249
|
-
| `/theme [nome]`
|
|
250
|
-
| `/ephemeral <30s\|5m\|1h\|off>`
|
|
251
|
-
| `/receipts [on\|off]`
|
|
252
|
-
| `/audit [n]`
|
|
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
|
|
262
|
-
|
|
263
|
-
| `/file <caminho>`
|
|
264
|
-
| `/voice [seg]`
|
|
265
|
-
| `/play [caminho]`
|
|
266
|
-
| `/accept [id]` / `/reject [id]` | Aceita / recusa uma oferta de arquivo recebida
|
|
267
|
-
| `/img [caminho]`
|
|
268
|
-
| `/retention <7d\|24h\|30m>`
|
|
269
|
-
| `/search <termo>`
|
|
270
|
-
| `/find [termo]` — **Ctrl+F**
|
|
271
|
-
| `/doctor [host:porta]`
|
|
272
|
-
| `/history [n]`
|
|
273
|
-
| `/export [caminho]`
|
|
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
|
|
281
|
-
|
|
282
|
-
| `/away [motivo]` / `/back`
|
|
283
|
-
| `/mentions [n]`
|
|
284
|
-
| `/status <texto\|off>`
|
|
285
|
-
| `/react <emoji>`
|
|
286
|
-
| `/edit` `/delete`
|
|
287
|
-
| `/pin` `/unpin` `/pins`
|
|
288
|
-
| `/sound` `/notify`
|
|
289
|
-
| `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Não perturbe, só menções, ou horário silencioso
|
|
290
|
-
| `/clear`
|
|
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**,
|
|
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 é
|
|
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
|
@@ -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) {
|
package/src/crypto/TrustStore.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
419
|
+
this.#banList.get(room).add(publicKey);
|
|
402
420
|
}
|
|
403
421
|
|
|
404
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
918
|
-
this.#sessionManager.
|
|
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'),
|