ciphermesh 2.9.0 → 2.11.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 +126 -0
- package/README.md +5 -3
- package/README.pt-BR.md +2 -2
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PLUGINS.md +53 -14
- package/docs/PROTOCOL.md +486 -0
- package/docs/commands.json +640 -0
- package/docs/demo.svg +2 -2
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +3 -2
- package/src/client/ChatController.js +245 -68
- package/src/client/UI.js +4 -1
- package/src/client/index.js +6 -1
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +16 -10
- package/src/p2p/index.js +6 -1
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- package/src/protocol/validators.js +73 -1
- package/src/server/ConnectionGuard.js +158 -0
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +101 -1
- package/src/server/config.js +15 -0
- package/src/server/index.js +18 -0
- package/src/server/preflight.js +96 -0
- package/src/shared/PluginManager.js +103 -30
- package/src/shared/config.js +12 -0
- package/src/shared/constants.js +50 -0
- package/src/shared/pluginCommand.js +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,132 @@
|
|
|
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.11.0
|
|
7
|
+
|
|
8
|
+
Groundwork for sender keys on the relay. **Nothing sends a group message yet** —
|
|
9
|
+
this release is the half that has to be in the field first, and the reason is in
|
|
10
|
+
"Added" below.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Capability negotiation in `JOIN`.** `PROTOCOL_VERSION` is checked for exact
|
|
15
|
+
equality, so it can only say "same" or "refuse to talk". It cannot express
|
|
16
|
+
_newer, but still willing to speak the old way_, which is the only thing that
|
|
17
|
+
makes a protocol change survivable on a public hub where people upgrade
|
|
18
|
+
whenever they upgrade.
|
|
19
|
+
|
|
20
|
+
A client now lists what it can do in its `JOIN`; the relay validates the list,
|
|
21
|
+
stores it, and hands it on verbatim with the peer list, without ever acting on
|
|
22
|
+
it. A feature turns on only when **every member of the room** advertises it, so
|
|
23
|
+
one peer on an older build holds the whole room on the old path — which is the
|
|
24
|
+
outcome that keeps a half-upgraded room readable.
|
|
25
|
+
|
|
26
|
+
The relay advertises its own abilities separately, in `join_ack.serverCaps`. No
|
|
27
|
+
client can promise those on the relay's behalf, and some features need the
|
|
28
|
+
relay to play along, so a capable room sitting on an older hub is still not a
|
|
29
|
+
room that can switch paths.
|
|
30
|
+
|
|
31
|
+
- **Receiving group messages, and the relay fan-out that carries them.** The
|
|
32
|
+
relay path seals one envelope per peer, so a line in a room of fifty costs
|
|
33
|
+
fifty encryptions and fifty envelopes — against a 1 MiB/s byte budget, a padded
|
|
34
|
+
message to fifty people throttles the sender for saying one thing. Sender keys
|
|
35
|
+
make it one of each.
|
|
36
|
+
|
|
37
|
+
Reading ships a release ahead of writing on purpose. The switch is _every
|
|
38
|
+
member agrees_: if both arrived together it would only ever be true in rooms
|
|
39
|
+
where everybody upgraded in the same moment.
|
|
40
|
+
|
|
41
|
+
A room-addressed message carries no `to`, because there is no single recipient,
|
|
42
|
+
and no `from`, because the relay must not become the one place on this wire
|
|
43
|
+
that asserts who is speaking. A packet names its _chain_ instead — an opaque
|
|
44
|
+
label members resolve through the sender key they were handed over the pairwise
|
|
45
|
+
channel, and which the relay cannot invert.
|
|
46
|
+
|
|
47
|
+
- **Per-sender signatures on group messages.** A sender chain is symmetric: every
|
|
48
|
+
member holds the key that decrypts a given sender, and can therefore also
|
|
49
|
+
produce ciphertext on it. Without something asymmetric on top, "Alice said
|
|
50
|
+
this" only ever meant "somebody in this room said this" — tolerable in a small
|
|
51
|
+
P2P mesh, not on a public hub where `general` has no owner and no admission
|
|
52
|
+
control.
|
|
53
|
+
|
|
54
|
+
Each sender now holds an Ed25519 keypair for the life of its chain, rotated
|
|
55
|
+
with it. Recipients verify before touching the ratchet, so an unauthenticated
|
|
56
|
+
packet carrying a large counter cannot make them derive and cache a thousand
|
|
57
|
+
message keys.
|
|
58
|
+
|
|
59
|
+
- **`docs/PROTOCOL.md`.** The wire format field by field, with the limits, the
|
|
60
|
+
failure modes, and the reason attached wherever the relay is forbidden to do
|
|
61
|
+
something. It also states what the relay _does_ learn, because a specification
|
|
62
|
+
that lists only the protections is misleading.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **Room-addressed messages are never queued for absent members**, unlike
|
|
67
|
+
unicast. Not policy — arithmetic: a sender key handed over on someone's return
|
|
68
|
+
serialises the chain at its current counter, so the backlog is unreadable to
|
|
69
|
+
them whatever the relay does with it. Queueing would store ciphertext on the
|
|
70
|
+
relay that provably nobody can open.
|
|
71
|
+
|
|
72
|
+
### Internal
|
|
73
|
+
|
|
74
|
+
- **Frozen test vectors for sender keys.** The previous tests round-tripped a
|
|
75
|
+
sender against a receiver built from that same sender — a mirror, not a
|
|
76
|
+
reference, which agrees with itself however the KDF is defined. Swapping the
|
|
77
|
+
two domain tags left the old suite fully green while making two clients unable
|
|
78
|
+
to read each other. The vectors pin the absolute values, and moving them is a
|
|
79
|
+
protocol version bump rather than a regeneration.
|
|
80
|
+
|
|
81
|
+
- **A guarantees test that lives apart from the features it checks.** In August a
|
|
82
|
+
squash reverted the entire plugin-approval control and nothing failed, because
|
|
83
|
+
that pull request's tests were reverted in the same commit. `test/guarantees.test.js`
|
|
84
|
+
asserts what the project promises — through the surfaces a user goes through,
|
|
85
|
+
and deliberately not next to any feature — so deleting a feature now leaves its
|
|
86
|
+
proof behind, failing. Verified by mutation rather than assumed.
|
|
87
|
+
|
|
88
|
+
- **Branch protection and CODEOWNERS.** `dev` and `master` now require CI, and
|
|
89
|
+
refuse force pushes and deletions.
|
|
90
|
+
|
|
91
|
+
## 2.10.0
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
|
|
95
|
+
- **\`--check\` for operators.** The deploy guide already explained every
|
|
96
|
+
footgun here, which is the problem: a document is read once, by whoever set
|
|
97
|
+
the machine up, while the misconfiguration lasts as long as the machine does.
|
|
98
|
+
The worst of them — \`TRUST_PROXY\` left off behind a reverse proxy — is
|
|
99
|
+
invisible from outside, because everything works and the per-IP cap, the rate
|
|
100
|
+
limit and the banlist simply apply to the proxy and protect nobody.
|
|
101
|
+
|
|
102
|
+
\`ciphermesh-server --check\` validates and exits without opening a socket, so
|
|
103
|
+
it is safe against a live host, and exits non-zero on an error so a deploy
|
|
104
|
+
script can gate on it. The same findings print at every startup, because a
|
|
105
|
+
warning you have to ask for is a warning nobody sees.
|
|
106
|
+
|
|
107
|
+
- **Connection-rate limiting.** The relay capped how many sockets one address
|
|
108
|
+
could hold, and how many messages a session could send, but nothing capped how
|
|
109
|
+
fast an address could _open_ connections. Connect, run the hybrid handshake,
|
|
110
|
+
disconnect, repeat: the concurrency cap never trips because the sockets are
|
|
111
|
+
never held, and every attempt costs the relay an X25519 and an ML-KEM-768
|
|
112
|
+
operation while costing the client almost nothing. That asymmetry was the one
|
|
113
|
+
real denial-of-service route into a public hub.
|
|
114
|
+
|
|
115
|
+
An address that exceeds `CONNECTION_RATE_PER_MINUTE` (60 by default, still
|
|
116
|
+
LAN-friendly) is refused for a minute, then five, then thirty. An hour of
|
|
117
|
+
behaving clears the record, so a shared NAT gateway cannot accumulate strikes
|
|
118
|
+
forever, and the refusal says how long to wait so a well-behaved client backs
|
|
119
|
+
off instead of extending its own ban.
|
|
120
|
+
|
|
121
|
+
- **A byte budget per connection** (`MAX_BYTES_PER_SECOND`,
|
|
122
|
+
`MAX_BYTES_BURST`). The message limit counts messages, and messages are padded
|
|
123
|
+
into buckets of up to 32 KiB, so a session sitting at the limit is a
|
|
124
|
+
multi-megabit stream. Bytes are the resource that runs out. The burst
|
|
125
|
+
allowance keeps a legitimate file transfer from looking like an attack.
|
|
126
|
+
|
|
127
|
+
Both are continuously refilling token buckets rather than fixed windows: a
|
|
128
|
+
fixed window lets a caller spend its whole allowance at the end of one window
|
|
129
|
+
and again at the start of the next, which is twice the intended rate at
|
|
130
|
+
exactly the moment an attacker aims for.
|
|
131
|
+
|
|
6
132
|
## 2.9.0
|
|
7
133
|
|
|
8
134
|
### Added
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
**[ciphermesh.de](https://ciphermesh.de)** — the website, and the public hub anyone can join.
|
|
21
21
|
|
|
22
|
-
**[🇧🇷 Leia em Português](README.pt-BR.md)** · [Setup Guide](docs/SETUP.md) · [Architecture](docs/ARCHITECTURE.md) · [Security Policy](SECURITY.md) · [Hub Terms](TERMS.md) · [Contributing](CONTRIBUTING.md)
|
|
22
|
+
**[🇧🇷 Leia em Português](README.pt-BR.md)** · [Setup Guide](docs/SETUP.md) · [Architecture](docs/ARCHITECTURE.md) · [Wire Protocol](docs/PROTOCOL.md) · [Security Policy](SECURITY.md) · [Hub Terms](TERMS.md) · [Contributing](CONTRIBUTING.md)
|
|
23
23
|
|
|
24
24
|
<img src="docs/demo.svg" alt="CipherMesh terminal demo" width="720">
|
|
25
25
|
|
|
@@ -204,10 +204,11 @@ software was built for.
|
|
|
204
204
|
| ----------------------------- | ----------------------------------------------------------------- |
|
|
205
205
|
| `/help` | All commands |
|
|
206
206
|
| `/tips` | Show a rotating security/UX tip |
|
|
207
|
+
| `/plugins` | List loaded plugins and the commands they add |
|
|
207
208
|
| `/users` | Who's online (with away/status) |
|
|
208
209
|
| `/msg <nick> <text>` | Private message (DM) |
|
|
209
210
|
| `/reply <text>` | Reply quoting the last received message |
|
|
210
|
-
| `/me <action>` | Third-person action — _«
|
|
211
|
+
| `/me <action>` | Third-person action — _«ana is compiling»_ |
|
|
211
212
|
| `/watch [add\|remove\|clear]` | Alert on a keyword in **any** room, like a mention |
|
|
212
213
|
| `/invite [host:port]` | Generate a `ciphermesh://` invite + QR code |
|
|
213
214
|
| `/nick <new>` | Change nickname (before joining — recovers from "nickname taken") |
|
|
@@ -294,6 +295,7 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
|
|
|
294
295
|
| Command | Description |
|
|
295
296
|
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
296
297
|
| `/away [reason]` / `/back` | Mark yourself away — while away, unreads are counted (`[away · N new]`) and `/back` shows a summary |
|
|
298
|
+
| `/autoaway <minutes\|off>` | Go away by itself after that long without typing (1–240 minutes) |
|
|
297
299
|
| `/mentions [n]` | Recent mentions of you this session (who, where, when) |
|
|
298
300
|
| `/status <text\|off>` | Free-form status — emojis welcome (`/status :fire: coding`) |
|
|
299
301
|
| `/react <emoji>` | React to the last message — the emoji lands **on the message**, with a count when several people react |
|
|
@@ -325,7 +327,7 @@ All keys are optional (unknown keys are ignored):
|
|
|
325
327
|
|
|
326
328
|
```json
|
|
327
329
|
{
|
|
328
|
-
"nickname": "
|
|
330
|
+
"nickname": "ana",
|
|
329
331
|
"server": "wss://100.x.y.z:3600",
|
|
330
332
|
"sound": false,
|
|
331
333
|
"notify": true,
|
package/README.pt-BR.md
CHANGED
|
@@ -209,7 +209,7 @@ aquela para a qual este software foi feito.
|
|
|
209
209
|
| `/users` | Quem está online (com away/status) |
|
|
210
210
|
| `/msg <nick> <texto>` | Mensagem privada (DM) |
|
|
211
211
|
| `/reply <texto>` | Responde citando a última mensagem recebida |
|
|
212
|
-
| `/me <ação>` | Ação em terceira pessoa — _«
|
|
212
|
+
| `/me <ação>` | Ação em terceira pessoa — _«ana está compilando»_ |
|
|
213
213
|
| `/watch [add\|remove\|clear]` | Alerta quando uma palavra aparece em **qualquer** sala, como uma menção |
|
|
214
214
|
| `/invite [host:porta]` | Gera convite `ciphermesh://` + QR code |
|
|
215
215
|
| `/nick <novo>` | Troca de apelido (antes de entrar — recupera de "apelido em uso") |
|
|
@@ -328,7 +328,7 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
|
|
|
328
328
|
|
|
329
329
|
```json
|
|
330
330
|
{
|
|
331
|
-
"nickname": "
|
|
331
|
+
"nickname": "ana",
|
|
332
332
|
"server": "wss://100.x.y.z:3600",
|
|
333
333
|
"sound": false,
|
|
334
334
|
"notify": true,
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -439,15 +439,21 @@ Defines the protocol's message types. All messages have:
|
|
|
439
439
|
}
|
|
440
440
|
```
|
|
441
441
|
|
|
442
|
+
> The normative description of the wire format lives in
|
|
443
|
+
> **[docs/PROTOCOL.md](PROTOCOL.md)** — field by field, with the limits, the
|
|
444
|
+
> failure modes and what the relay is forbidden to do. This chapter is the tour;
|
|
445
|
+
> that document is the specification.
|
|
446
|
+
|
|
442
447
|
Types:
|
|
443
448
|
| Type | Direction | Description |
|
|
444
449
|
|------|---------|-----------|
|
|
445
|
-
| `join` | Client -> Server | The client wants to join (nickname + publicKey) |
|
|
450
|
+
| `join` | Client -> Server | The client wants to join (nickname + publicKey + optional `caps`, see 6.10) |
|
|
446
451
|
| `join_ack` | Server -> Client | The server confirms the join (sessionId + peer list) |
|
|
447
452
|
| `peer_joined` | Server -> Clients | A new peer joined (nickname + publicKey) |
|
|
448
453
|
| `peer_left` | Server -> Clients | A peer left |
|
|
449
454
|
| `key_exchange` | Client -> Server -> Client | Public key exchange between peers |
|
|
450
455
|
| `encrypted_message` | Client -> Server -> Client | Encrypted message |
|
|
456
|
+
| `group_message` | Client -> Server -> Room | One ciphertext fanned out to a room's members (sender keys, see 6.11). No `to`, no `from` |
|
|
451
457
|
| `change_room` | Client -> Server | Legacy single-room switch: leave every room, enter one (+ optional `roomAuthPk` when creating a private room) |
|
|
452
458
|
| `room_changed` | Server -> Client | Room switch confirmed (+ `private` flag) |
|
|
453
459
|
| `join_room` | Client -> Server | Multi-room: join an ADDITIONAL room, keeping current ones (same `roomAuthPk` option) |
|
|
@@ -514,10 +520,15 @@ export const FILE_CHUNK_SIZE = 49152; // 48KB
|
|
|
514
520
|
"version": 1,
|
|
515
521
|
"timestamp": 1739800000000,
|
|
516
522
|
"nickname": "Alice",
|
|
517
|
-
"publicKey": "base64(32 bytes da chave publica Curve25519)"
|
|
523
|
+
"publicKey": "base64(32 bytes da chave publica Curve25519)",
|
|
524
|
+
"caps": ["sk1"]
|
|
518
525
|
}
|
|
519
526
|
```
|
|
520
527
|
|
|
528
|
+
`caps` is optional (see 6.10). It is omitted entirely when the client has
|
|
529
|
+
nothing to advertise, so the message is byte-identical to a pre-capability
|
|
530
|
+
client's.
|
|
531
|
+
|
|
521
532
|
### 5.2 Join ACK (server -> client)
|
|
522
533
|
|
|
523
534
|
```json
|
|
@@ -530,12 +541,17 @@ export const FILE_CHUNK_SIZE = 49152; // 48KB
|
|
|
530
541
|
{
|
|
531
542
|
"sessionId": "660e8400-e29b-41d4-a716-446655440001",
|
|
532
543
|
"nickname": "Bob",
|
|
533
|
-
"publicKey": "base64(chave publica do Bob)"
|
|
544
|
+
"publicKey": "base64(chave publica do Bob)",
|
|
545
|
+
"caps": ["sk1"]
|
|
534
546
|
}
|
|
535
|
-
]
|
|
547
|
+
],
|
|
548
|
+
"serverCaps": ["sk1"]
|
|
536
549
|
}
|
|
537
550
|
```
|
|
538
551
|
|
|
552
|
+
`caps` is what each peer can do; `serverCaps` is what the relay itself can do.
|
|
553
|
+
Both are optional and both matter — see 6.11.
|
|
554
|
+
|
|
539
555
|
### 5.3 Encrypted Message (client -> server -> client)
|
|
540
556
|
|
|
541
557
|
```json
|
|
@@ -550,6 +566,63 @@ export const FILE_CHUNK_SIZE = 49152; // 48KB
|
|
|
550
566
|
|
|
551
567
|
**Note (sealed sender, v2)**: The relay sees only `to` (for routing) and an opaque `sealed` blob. The sender (`from`) *and* the whole already-E2E-encrypted `payload` are sealed to the recipient's public key with libsodium `crypto_box_seal` — an anonymous box only the recipient can open. The relay therefore learns **neither who sent the message nor what's inside**, and it never stamps, stores, or logs a sender. The recipient opens the seal to recover `{ from, payload }`, then decrypts the payload as before. (See §10 for the exact — honest — guarantee and its limits.)
|
|
552
568
|
|
|
569
|
+
### 5.3b Group Message (client -> server -> room)
|
|
570
|
+
|
|
571
|
+
```json
|
|
572
|
+
{
|
|
573
|
+
"type": "group_message",
|
|
574
|
+
"version": 2,
|
|
575
|
+
"timestamp": 1739800001000,
|
|
576
|
+
"room": "general",
|
|
577
|
+
"keyId": "base64(16 random bytes labelling the sender's chain)",
|
|
578
|
+
"counter": 7,
|
|
579
|
+
"ciphertext": "base64(...)",
|
|
580
|
+
"nonce": "base64(24 bytes)",
|
|
581
|
+
"signature": "base64(64 bytes, Ed25519 over keyId|counter|ciphertext|nonce)"
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
One ciphertext for the whole room, fanned out by the relay to every member
|
|
586
|
+
except the sender. Note what is **not** here: no `to`, because there is no single
|
|
587
|
+
recipient, and no `from`, because the relay must not be the thing that says who
|
|
588
|
+
is speaking — on this wire, identity only ever travels sealed.
|
|
589
|
+
|
|
590
|
+
Recipients pick the right sender chain with `keyId`, which they learned from the
|
|
591
|
+
`sk_dist` payload that member sent them over the pairwise channel. That envelope
|
|
592
|
+
authenticates the sender; the fan-out does not, and is not asked to. To the relay
|
|
593
|
+
`keyId` is a random string, and it already knows which socket sent the frame, so
|
|
594
|
+
it learns nothing from it.
|
|
595
|
+
|
|
596
|
+
**Why the signature is not optional.** A sender chain is symmetric: every member
|
|
597
|
+
holds the key that decrypts a given sender, which means every member can also
|
|
598
|
+
*produce* ciphertext on it. Without something asymmetric on top, "Alice said
|
|
599
|
+
this" only ever means "somebody in this room said this" — and `general` on a
|
|
600
|
+
public hub has no owner and no admission control, so that is a very wide set of
|
|
601
|
+
somebodies. Each sender therefore also holds an Ed25519 keypair for the life of
|
|
602
|
+
its chain; the public half travels in the distribution, over the pairwise channel
|
|
603
|
+
that already authenticates who sent it. Recipients verify **before** touching the
|
|
604
|
+
ratchet, so an unauthenticated packet carrying a large counter cannot make them
|
|
605
|
+
derive and cache a thousand message keys.
|
|
606
|
+
|
|
607
|
+
The relay checks only that the sender is a member of `room` — without that, one
|
|
608
|
+
connection could inject into every room on the hub at once, which the unicast
|
|
609
|
+
path cannot do because it needs a `sessionId` it could only have been told. It
|
|
610
|
+
cannot verify the signature and does not try: it holds no signing keys, and that
|
|
611
|
+
is the recipient's job.
|
|
612
|
+
|
|
613
|
+
**Not queued for absent members**, unlike the unicast path. A member who was away
|
|
614
|
+
could not read it anyway — a sender key handed over on their return serialises
|
|
615
|
+
the chain at its *current* counter, so anything sent while they were gone stays
|
|
616
|
+
shut. Queueing would store ciphertext on the relay that provably nobody can open.
|
|
617
|
+
The unicast queue survives because an envelope addressed to a peer *is* still
|
|
618
|
+
openable when they come back with the same key; that difference is what decides
|
|
619
|
+
it.
|
|
620
|
+
|
|
621
|
+
**Not yet sent by anything.** As of this release clients can *receive* a group
|
|
622
|
+
message; `#broadcastPayload` still seals one envelope per peer. Receive and
|
|
623
|
+
fan-out ship first so that when the send path lands, the rooms it negotiates
|
|
624
|
+
with can already read it.
|
|
625
|
+
|
|
553
626
|
### 5.4 Decrypted content (never travels in cleartext)
|
|
554
627
|
|
|
555
628
|
After decrypting `payload.ciphertext`, the result is:
|
|
@@ -814,6 +887,56 @@ wrapper adds no metadata leak.
|
|
|
814
887
|
|
|
815
888
|
---
|
|
816
889
|
|
|
890
|
+
### 6.11 Capability Negotiation
|
|
891
|
+
|
|
892
|
+
`PROTOCOL_VERSION` is checked for exact equality, so it can only say "same" or
|
|
893
|
+
"refuse to talk". It cannot express a client that is newer but still willing to
|
|
894
|
+
speak the old way — which is what rolling a protocol change through a public hub
|
|
895
|
+
needs, because people upgrade whenever they upgrade.
|
|
896
|
+
|
|
897
|
+
Capabilities fill that gap:
|
|
898
|
+
|
|
899
|
+
1. A client lists what it can do in its `JOIN` (`caps: ["sk1"]`).
|
|
900
|
+
2. The relay validates the list, stores it, and hands it on **verbatim** with
|
|
901
|
+
the peer list (`join_ack`) and with `peer_joined`. The relay never acts on a
|
|
902
|
+
capability itself.
|
|
903
|
+
3. The relay advertises **its own** abilities separately, in `join_ack.serverCaps`.
|
|
904
|
+
No client can promise these on the relay's behalf, and some features need the
|
|
905
|
+
relay to play along — the group fan-out is its job, not a peer's. A capable
|
|
906
|
+
room sitting on an older hub is still not a room that can switch paths.
|
|
907
|
+
4. A feature turns on only when **every** member of the room advertises it
|
|
908
|
+
(`roomSupports()` in `src/protocol/capabilities.js`) **and** the relay does
|
|
909
|
+
(`relaySupportsCapability()`). One peer on an older build, or one older hub,
|
|
910
|
+
holds the whole room on the old path.
|
|
911
|
+
|
|
912
|
+
**Compatibility:** the field is optional and omitted when empty, so a
|
|
913
|
+
pre-capability client's `JOIN` is unchanged, and an older relay that does not
|
|
914
|
+
know the field simply drops it — peers then see no capabilities and fall back,
|
|
915
|
+
which is the safe direction.
|
|
916
|
+
|
|
917
|
+
**Bounds:** the list arrives from a public hub, so it is capped at 16 entries of
|
|
918
|
+
up to 24 lowercase characters each. A malformed list gets the whole `JOIN`
|
|
919
|
+
rejected rather than filtered: the relay hands this to other clients, and
|
|
920
|
+
forwarding the good half of a bad list would make a peer look capable of
|
|
921
|
+
something it never claimed.
|
|
922
|
+
|
|
923
|
+
**What a hostile relay can do with it:** stripping capabilities forces the room
|
|
924
|
+
back onto the per-peer path, which is the status quo and reveals nothing new.
|
|
925
|
+
Adding one a peer never claimed makes senders encrypt in a form that peer cannot
|
|
926
|
+
read — denial of service, immediately visible, never a way to read plaintext.
|
|
927
|
+
The all-members rule is what keeps the damage on that side of the line.
|
|
928
|
+
|
|
929
|
+
| Capability | Advertised by | Meaning |
|
|
930
|
+
|---|---|---|
|
|
931
|
+
| `sk1` | client | I can **receive** a group message: I accept a sender key over the pairwise channel and can decrypt what that chain produces. |
|
|
932
|
+
| `sk1` | relay | I can fan a room-addressed `group_message` out to a room's members. |
|
|
933
|
+
|
|
934
|
+
Neither means "I send group messages" — nothing does yet. Receive and fan-out
|
|
935
|
+
are deliberately a release ahead of send, because the switch is *every member
|
|
936
|
+
agrees*: if the ability to read arrived with the ability to write, the switch
|
|
937
|
+
would only ever be true in rooms where everybody upgraded at the same moment,
|
|
938
|
+
which on a public hub is close to never. See `docs/design/sender-keys-on-relay.md`.
|
|
939
|
+
|
|
817
940
|
## 7. Handshake Protocol
|
|
818
941
|
|
|
819
942
|
### 7.1 Full Diagram
|
package/docs/PLUGINS.md
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# CipherMesh Plugin API
|
|
2
2
|
|
|
3
|
-
CipherMesh loads user plugins
|
|
4
|
-
|
|
3
|
+
CipherMesh loads user plugins from `~/.ciphermesh/plugins/*.js` and routes
|
|
4
|
+
unknown slash-commands to them. Plugins work in both relay and P2P mode.
|
|
5
|
+
|
|
6
|
+
**Nothing there runs until you say so.** A file in that directory is found, not
|
|
7
|
+
loaded. Run `/plugins` to see what is waiting and `/plugins allow <file>` to
|
|
8
|
+
approve it — the approval is remembered in `~/.ciphermesh/config.json` under
|
|
9
|
+
`pluginsAllowed`, so you are asked once per file.
|
|
5
10
|
|
|
6
11
|
## Quick start
|
|
7
12
|
|
|
@@ -10,8 +15,15 @@ mkdir -p ~/.ciphermesh/plugins
|
|
|
10
15
|
cp examples/plugins/roll.js examples/plugins/poll.js ~/.ciphermesh/plugins/
|
|
11
16
|
```
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
Then in the client:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
/plugins → shows roll.js and poll.js waiting
|
|
22
|
+
/plugins allow roll → approved, loaded, and remembered
|
|
23
|
+
/plugins allow poll
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Now `/roll 2d20+3` and `/poll Pizza tonight? | yes | obviously` work.
|
|
15
27
|
|
|
16
28
|
## Plugin format
|
|
17
29
|
|
|
@@ -19,8 +31,8 @@ A plugin is an ES module whose **default export** is:
|
|
|
19
31
|
|
|
20
32
|
```js
|
|
21
33
|
export default {
|
|
22
|
-
name: 'roll',
|
|
23
|
-
description: 'Roll dice',
|
|
34
|
+
name: 'roll', // required, unique
|
|
35
|
+
description: 'Roll dice', // optional, shown by /plugins
|
|
24
36
|
commands: {
|
|
25
37
|
// key = command name (with or without the leading slash)
|
|
26
38
|
roll(args) {
|
|
@@ -31,16 +43,18 @@ export default {
|
|
|
31
43
|
};
|
|
32
44
|
```
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
A file that fails to import, or lacks `name`/`commands`, is skipped — but if you
|
|
47
|
+
approved it, `/plugins` says so. A plugin you asked for and did not get should
|
|
48
|
+
not disappear without a word.
|
|
35
49
|
|
|
36
50
|
## Handler return values
|
|
37
51
|
|
|
38
|
-
| Return
|
|
39
|
-
|
|
40
|
-
| `{ send: '<text>' }`
|
|
41
|
-
| `{ info: '<text>' }`
|
|
42
|
-
| `'<text>'` (plain string)
|
|
43
|
-
| `null` / `undefined` / throw | Treated as "not handled": the user sees
|
|
52
|
+
| Return | Effect |
|
|
53
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
54
|
+
| `{ send: '<text>' }` | The text is **sent to the current room** as a normal end-to-end-encrypted message (and echoed locally). Markdown and multi-line text work. |
|
|
55
|
+
| `{ info: '<text>' }` | Shown **only locally** as an info line. |
|
|
56
|
+
| `'<text>'` (plain string) | Same as `{ info }` — the original API, still supported. |
|
|
57
|
+
| `null` / `undefined` / throw | Treated as "not handled": the user sees _Unknown command_. |
|
|
44
58
|
|
|
45
59
|
Handlers are synchronous — return the final value directly.
|
|
46
60
|
|
|
@@ -54,13 +68,38 @@ directory listing).
|
|
|
54
68
|
## Security model — read this
|
|
55
69
|
|
|
56
70
|
A plugin is **arbitrary JavaScript running inside your chat process**, with
|
|
57
|
-
your privileges and full access to your keys in memory. There is no sandbox
|
|
71
|
+
your privileges and full access to your keys in memory. There is no sandbox,
|
|
72
|
+
and approving one does not create one.
|
|
58
73
|
|
|
59
74
|
- Only install plugins you wrote or read line-by-line.
|
|
60
75
|
- Treat a plugin file like you treat `curl | sh`.
|
|
61
76
|
- Plugins are never synced, auto-updated or downloaded by CipherMesh — the
|
|
62
77
|
only way code gets into `~/.ciphermesh/plugins/` is you putting it there.
|
|
63
78
|
|
|
79
|
+
### What approval actually buys
|
|
80
|
+
|
|
81
|
+
Before, any `.js` file appearing in that directory ran at the next start. The
|
|
82
|
+
warning above protected only the people who had already read it, and anything
|
|
83
|
+
able to write one file into a known path had code execution.
|
|
84
|
+
|
|
85
|
+
Now the file is listed and left alone until you approve it. That is the whole
|
|
86
|
+
guarantee, and it is worth being precise about its edges:
|
|
87
|
+
|
|
88
|
+
- **It is a consent step, not a sandbox.** An approved plugin can do everything
|
|
89
|
+
the client can do. Approve for the same reasons you would run a script.
|
|
90
|
+
- **Approval is per _file name_, not per plugin name.** That is forced, not
|
|
91
|
+
chosen: a plugin's own `name` lives inside the module, and reading it means
|
|
92
|
+
importing the module, and importing it is already running it. The check has
|
|
93
|
+
to work from the directory listing alone.
|
|
94
|
+
- **Replacing an approved file is not a new decision.** `roll.js` stays
|
|
95
|
+
approved even if its contents change completely. If you did not put the new
|
|
96
|
+
contents there, you have a bigger problem than plugins — but do not read the
|
|
97
|
+
approval as a promise about what the file contains.
|
|
98
|
+
|
|
99
|
+
There is no capability system, deliberately. Declaring what a plugin may do
|
|
100
|
+
without being able to enforce it would make the risk look bounded when it is
|
|
101
|
+
not, which is worse than the plain warning above.
|
|
102
|
+
|
|
64
103
|
## Included examples
|
|
65
104
|
|
|
66
105
|
- [`examples/plugins/roll.js`](../examples/plugins/roll.js) — dice roller,
|