ciphermesh 2.7.1 → 2.8.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,53 @@
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.8.0
7
+
8
+ ### Added
9
+
10
+ - **`/block`, `/unblock`, `/blocklist`.** Stop seeing someone, just for you.
11
+ Entirely local: nothing is sent, the relay never learns, and the other person
12
+ is not told. That is why everyone gets it — moderating a room acts on
13
+ everybody and so has to belong to the owner, while refusing to listen acts
14
+ only on yourself and needs no authority at all.
15
+
16
+ It is also the only protection that works in `general`, which has no owner and
17
+ therefore no moderation. Blocks live in the trust store, so they survive a
18
+ restart, are stored `0600`, and are wiped by `/panic` along with everything
19
+ else.
20
+
21
+ ### Fixed
22
+
23
+ - **A room ban was undone by `/nick`.** Bans were stored against the nickname,
24
+ and anyone can pick a new one whenever they like: get banned, rename, walk
25
+ back in. Room owners are the only moderation in the system — the operator
26
+ cannot read content and deliberately holds no in-chat authority — so their one
27
+ tool was defeated by a single word. Bans are now bound to the public key
28
+ (#438).
29
+
30
+ The correct pattern was already in the codebase: the offline queue looks up by
31
+ nickname but verifies the public key before delivering. The ban list was the
32
+ one place a nickname was treated as an identity.
33
+
34
+ ## 2.7.2
35
+
36
+ ### Fixed
37
+
38
+ - **The standalone binaries now run on machines other than the one that built
39
+ them.** `sodium-native` resolves its prebuilt addon at runtime, which bun
40
+ cannot follow, so the addon was never embedded — the binary kept an absolute
41
+ path back to the build checkout and died with `Cannot find addon` anywhere
42
+ else. Every binary published before this, including the relay binaries dating
43
+ back to 2.3.0, was affected (#427).
44
+
45
+ The verification was the deeper problem: it ran the binary _on the runner that
46
+ built it_, where the addon resolved through `node_modules`, so every check
47
+ passed while every download was broken. CI now hides the build tree before
48
+ running anything, and the cross-compiled binary is executed under Rosetta
49
+ rather than merely asserting its architecture.
50
+
51
+ **If you downloaded a binary from an earlier release, replace it.**
52
+
6
53
  ## 2.7.1
7
54
 
8
55
  ### Fixed
package/README.md CHANGED
@@ -122,11 +122,15 @@ 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
+ > 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
+
130
134
  | Binary | What it is |
131
135
  |---|---|
132
136
  | `ciphermesh-<platform>` | Everything: client, relay and P2P. `ciphermesh server` and `ciphermesh p2p` work exactly as they do on npm. |
@@ -180,6 +184,17 @@ 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>
package/README.pt-BR.md CHANGED
@@ -123,11 +123,15 @@ 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
+ > 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
+
131
135
  | Binário | O que é |
132
136
  |---|---|
133
137
  | `ciphermesh-<plataforma>` | Tudo: cliente, relay e P2P. `ciphermesh server` e `ciphermesh p2p` funcionam igual ao npm. |
@@ -182,6 +186,17 @@ 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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.7.1",
3
+ "version": "2.8.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);
@@ -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'),