ciphermesh 2.7.2 → 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 +28 -0
- package/README.md +18 -3
- package/README.pt-BR.md +18 -3
- package/package.json +1 -1
- package/src/client/ChatController.js +59 -0
- package/src/crypto/TrustStore.js +67 -0
- package/src/server/SessionManager.js +26 -4
- package/src/server/WebSocketServer.js +6 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
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
|
+
|
|
6
34
|
## 2.7.2
|
|
7
35
|
|
|
8
36
|
### 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?**
|
|
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
|
+
> 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?**
|
|
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
|
+
> 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
|
@@ -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);
|
|
@@ -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'),
|