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
|
@@ -9,16 +9,39 @@ export function pluginDir() {
|
|
|
9
9
|
return join(homedir(), '.ciphermesh', 'plugins');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Loads plugins, but only the ones the user has said yes to.
|
|
14
|
+
*
|
|
15
|
+
* A plugin is arbitrary JavaScript running inside the chat process, with the
|
|
16
|
+
* same reach as the client itself. That has always been documented, but until
|
|
17
|
+
* now any `.js` file appearing in the directory simply ran — which meant the
|
|
18
|
+
* warning protected nobody who had not already read it, and anything that could
|
|
19
|
+
* write one file to `~/.ciphermesh/plugins` had code execution.
|
|
20
|
+
*
|
|
21
|
+
* Approval is recorded **per file name**, not per plugin name, and that is
|
|
22
|
+
* forced rather than chosen: a plugin's own `name` is inside the module, and
|
|
23
|
+
* reading it means importing the module, and importing it is already running
|
|
24
|
+
* it. The check has to happen on something knowable from the directory listing.
|
|
25
|
+
*
|
|
26
|
+
* This is not a sandbox and is not presented as one. An approved plugin can do
|
|
27
|
+
* everything the client can do. What it buys is that the decision is now made
|
|
28
|
+
* by a person, once, per file — instead of by whatever put the file there.
|
|
29
|
+
*/
|
|
12
30
|
export class PluginManager {
|
|
13
|
-
#plugins; //
|
|
14
|
-
#commands; //
|
|
31
|
+
#plugins = new Map(); // name -> module
|
|
32
|
+
#commands = new Map(); // '/cmd' -> { handler, pluginName }
|
|
33
|
+
#pending = []; // files found but never approved
|
|
34
|
+
#failed = []; // files approved that would not load
|
|
15
35
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} dir
|
|
38
|
+
* @param {string[]} allowed file names the user has approved, with or
|
|
39
|
+
* without the `.js` — `['roll']` and `['roll.js']` both mean the same file.
|
|
40
|
+
*/
|
|
41
|
+
async loadAll(dir = pluginDir(), allowed = []) {
|
|
42
|
+
this.#pending = [];
|
|
43
|
+
this.#failed = [];
|
|
20
44
|
|
|
21
|
-
async loadAll(dir = pluginDir()) {
|
|
22
45
|
if (!existsSync(dir)) {
|
|
23
46
|
mkdirSync(dir, { recursive: true });
|
|
24
47
|
return;
|
|
@@ -31,33 +54,69 @@ export class PluginManager {
|
|
|
31
54
|
return;
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
? cmdName.toLowerCase()
|
|
52
|
-
: `/${cmdName.toLowerCase()}`;
|
|
53
|
-
this.#commands.set(normalized, { handler, pluginName: plugin.name });
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
// Skip broken plugins silently
|
|
57
|
+
const approved = new Set(
|
|
58
|
+
(Array.isArray(allowed) ? allowed : [])
|
|
59
|
+
.filter((entry) => typeof entry === 'string')
|
|
60
|
+
.map((entry) => normalizeFileName(entry)),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
for (const file of files.filter((f) => f.endsWith('.js'))) {
|
|
64
|
+
if (!approved.has(normalizeFileName(file))) {
|
|
65
|
+
this.#pending.push(file);
|
|
66
|
+
continue; // NOT imported: importing is running.
|
|
67
|
+
}
|
|
68
|
+
// Sequential on purpose: plugins load once at startup, and a predictable
|
|
69
|
+
// order means a command defined by two of them resolves the same way
|
|
70
|
+
// every run.
|
|
71
|
+
const loaded = await this.#load(dir, file);
|
|
72
|
+
if (!loaded) {
|
|
73
|
+
this.#failed.push(file);
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
|
|
78
|
+
/** Load one already-approved file. @returns {boolean} */
|
|
79
|
+
async #load(dir, file) {
|
|
80
|
+
try {
|
|
81
|
+
const fileUrl = pathToFileURL(join(dir, file)).href;
|
|
82
|
+
const mod = await import(fileUrl);
|
|
83
|
+
const plugin = mod.default || mod;
|
|
84
|
+
|
|
85
|
+
if (!plugin.name || !plugin.commands) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.#plugins.set(plugin.name, plugin);
|
|
90
|
+
|
|
91
|
+
for (const [cmdName, handler] of Object.entries(plugin.commands)) {
|
|
92
|
+
const normalized = cmdName.startsWith('/')
|
|
93
|
+
? cmdName.toLowerCase()
|
|
94
|
+
: `/${cmdName.toLowerCase()}`;
|
|
95
|
+
this.#commands.set(normalized, { handler, pluginName: plugin.name });
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false; // a broken plugin must never stop the client starting
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Approve and load a file that was sitting in the directory unapproved.
|
|
105
|
+
* The caller persists the name; this only affects the running session.
|
|
106
|
+
*/
|
|
107
|
+
async approve(file, dir = pluginDir()) {
|
|
108
|
+
const target = this.#pending.find((f) => normalizeFileName(f) === normalizeFileName(file));
|
|
109
|
+
if (!target) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const loaded = await this.#load(dir, target);
|
|
113
|
+
this.#pending = this.#pending.filter((f) => f !== target);
|
|
114
|
+
if (!loaded) {
|
|
115
|
+
this.#failed.push(target);
|
|
116
|
+
}
|
|
117
|
+
return loaded;
|
|
118
|
+
}
|
|
119
|
+
|
|
61
120
|
getCommandNames() {
|
|
62
121
|
return [...this.#commands.keys()];
|
|
63
122
|
}
|
|
@@ -66,6 +125,16 @@ export class PluginManager {
|
|
|
66
125
|
return [...this.#plugins.keys()];
|
|
67
126
|
}
|
|
68
127
|
|
|
128
|
+
/** Files present but never approved, so the client can offer them. */
|
|
129
|
+
getPendingFiles() {
|
|
130
|
+
return [...this.#pending];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Approved files that would not load — otherwise this fails silently. */
|
|
134
|
+
getFailedFiles() {
|
|
135
|
+
return [...this.#failed];
|
|
136
|
+
}
|
|
137
|
+
|
|
69
138
|
get pluginCount() {
|
|
70
139
|
return this.#plugins.size;
|
|
71
140
|
}
|
|
@@ -84,3 +153,7 @@ export class PluginManager {
|
|
|
84
153
|
}
|
|
85
154
|
}
|
|
86
155
|
}
|
|
156
|
+
|
|
157
|
+
function normalizeFileName(name) {
|
|
158
|
+
return name.toLowerCase().replace(/\.js$/, '').trim();
|
|
159
|
+
}
|
package/src/shared/config.js
CHANGED
|
@@ -17,6 +17,10 @@ const ALLOWED = [
|
|
|
17
17
|
'autoAway',
|
|
18
18
|
'autoLock',
|
|
19
19
|
'dnd',
|
|
20
|
+
// File names under ~/.ciphermesh/plugins the user has approved. A plugin is
|
|
21
|
+
// arbitrary code in the chat process, so nothing there runs until it appears
|
|
22
|
+
// here — see PluginManager.
|
|
23
|
+
'pluginsAllowed',
|
|
20
24
|
];
|
|
21
25
|
|
|
22
26
|
export function configPath() {
|
|
@@ -40,6 +44,14 @@ export function parseConfig(raw) {
|
|
|
40
44
|
out[k] = obj[k];
|
|
41
45
|
}
|
|
42
46
|
}
|
|
47
|
+
// The only key whose *shape* matters: a hand-edited string here would be
|
|
48
|
+
// iterated character by character, and "roll.js" would approve nothing while
|
|
49
|
+
// looking like it approved something.
|
|
50
|
+
if (out.pluginsAllowed !== undefined) {
|
|
51
|
+
out.pluginsAllowed = Array.isArray(out.pluginsAllowed)
|
|
52
|
+
? out.pluginsAllowed.filter((entry) => typeof entry === 'string')
|
|
53
|
+
: [];
|
|
54
|
+
}
|
|
43
55
|
return out;
|
|
44
56
|
}
|
|
45
57
|
|
package/src/shared/constants.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
export const PROTOCOL_VERSION = 2; // v2: sealed sender (encrypted_message carries `sealed`, no `from`)
|
|
2
2
|
|
|
3
|
+
// ── Capability negotiation ─────────────────────────────────────
|
|
4
|
+
// PROTOCOL_VERSION is an exact-equality gate, so it cannot express "newer, but
|
|
5
|
+
// still able to talk to you". Capabilities fill that gap: a client lists what it
|
|
6
|
+
// can do in JOIN, the relay hands the list on verbatim with the peer list, and a
|
|
7
|
+
// feature turns on only when every member of the room advertises it. Absent or
|
|
8
|
+
// empty means an older peer, which is the safe default rather than an error.
|
|
9
|
+
export const CAP = {
|
|
10
|
+
// Group encryption on the relay path — one ciphertext for the whole room
|
|
11
|
+
// instead of one sealed envelope per peer. See
|
|
12
|
+
// docs/design/sender-keys-on-relay.md.
|
|
13
|
+
//
|
|
14
|
+
// From a client it means "I can *receive* a group message": I accept a sender
|
|
15
|
+
// key over the pairwise channel and can decrypt what that chain produces.
|
|
16
|
+
// From the relay it means "I can fan a room-addressed message out". Receive
|
|
17
|
+
// and fan-out land a release before anyone sends, so that by the time a sender
|
|
18
|
+
// exists, every advertised room can already read it.
|
|
19
|
+
SENDER_KEYS: 'sk1',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// What this client advertises. SENDER_KEYS is honest here: the receive path
|
|
23
|
+
// exists. Nothing sends group messages yet.
|
|
24
|
+
export const OWN_CAPABILITIES = [CAP.SENDER_KEYS];
|
|
25
|
+
|
|
26
|
+
// What the relay advertises, in join_ack. A client cannot promise this on the
|
|
27
|
+
// relay's behalf — the fan-out is the relay's job — so a sender has to check the
|
|
28
|
+
// room *and* the hub it is sitting on before switching paths.
|
|
29
|
+
export const SERVER_CAPABILITIES = [CAP.SENDER_KEYS];
|
|
30
|
+
|
|
31
|
+
// Bounds. This list arrives from a public hub, so it is attacker-controlled.
|
|
32
|
+
export const MAX_CAPABILITIES = 16;
|
|
33
|
+
export const MAX_CAPABILITY_LENGTH = 24;
|
|
34
|
+
|
|
35
|
+
// Ed25519 detached signature on a group message (src/crypto/SenderKey.js).
|
|
36
|
+
export const SIGNATURE_SIZE = 64;
|
|
37
|
+
|
|
3
38
|
// Network
|
|
4
39
|
export const SERVER_PORT = 3600;
|
|
5
40
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
@@ -18,6 +53,21 @@ export const MAX_CONNECTIONS_PER_IP = 20; // per-source-IP socket cap
|
|
|
18
53
|
export const JOIN_TIMEOUT_MS = 15_000; // drop sockets that never JOIN
|
|
19
54
|
export const MESSAGE_RATE_LIMIT_PER_SECOND = 60; // per connection, ALL message types
|
|
20
55
|
|
|
56
|
+
// How fast one source may *open* connections, as opposed to how many it may
|
|
57
|
+
// hold. The concurrency cap never trips against churn — connect, handshake,
|
|
58
|
+
// disconnect, repeat — and every one of those attempts costs the relay an
|
|
59
|
+
// X25519 and an ML-KEM-768 operation while costing the client almost nothing.
|
|
60
|
+
export const CONNECTION_RATE_PER_MINUTE = 60;
|
|
61
|
+
|
|
62
|
+
// Bytes per second per connection, sustained. The message limit counts
|
|
63
|
+
// messages, and messages are padded into buckets, so a session at 60/s is a
|
|
64
|
+
// multi-megabit stream: bytes are the resource that actually runs out.
|
|
65
|
+
export const MAX_BYTES_PER_SECOND = 1_048_576;
|
|
66
|
+
// Burst allowance, so a file transfer is not mistaken for an attack. Must stay
|
|
67
|
+
// comfortably above MAX_PAYLOAD_SIZE — a burst smaller than one frame could
|
|
68
|
+
// never be paid for, and the connection would wedge instead of throttling.
|
|
69
|
+
export const MAX_BYTES_BURST = 4_194_304;
|
|
70
|
+
|
|
21
71
|
// Crypto sizes (libsodium Curve25519 + XSalsa20-Poly1305)
|
|
22
72
|
export const NONCE_SIZE = 24;
|
|
23
73
|
export const PUBLIC_KEY_SIZE = 32;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `/plugins` for both controllers.
|
|
5
|
+
*
|
|
6
|
+
* Written once and shared because the relay client and the P2P client had
|
|
7
|
+
* separate copies of it, and separate copies of a command drift — one gains a
|
|
8
|
+
* subcommand the other never hears about, and the two `/help` lists stop
|
|
9
|
+
* describing the same program.
|
|
10
|
+
*
|
|
11
|
+
* Returns lines rather than printing, so each controller renders them with its
|
|
12
|
+
* own UI and the logic stays testable without a terminal.
|
|
13
|
+
*
|
|
14
|
+
* @returns {Promise<Array<{kind: 'info'|'error'|'system', text: string}>>}
|
|
15
|
+
*/
|
|
16
|
+
export async function pluginsCommand(manager, args = []) {
|
|
17
|
+
if (!manager) {
|
|
18
|
+
// Same words as an empty directory. From where the user sits the situation
|
|
19
|
+
// is identical — there is nothing to run — and "not available" would send
|
|
20
|
+
// them looking for a fault that is not theirs.
|
|
21
|
+
return [{ kind: 'info', text: 'No plugins. Put .js files in ~/.ciphermesh/plugins/' }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [sub, target] = args;
|
|
25
|
+
|
|
26
|
+
if (sub && sub.toLowerCase() === 'allow') {
|
|
27
|
+
return approve(manager, target);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (sub) {
|
|
31
|
+
return [{ kind: 'error', text: 'Usage: /plugins or /plugins allow <file>' }];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return status(manager);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function status(manager) {
|
|
38
|
+
const lines = [];
|
|
39
|
+
const loaded = manager.getPluginNames();
|
|
40
|
+
const pending = manager.getPendingFiles();
|
|
41
|
+
const failed = manager.getFailedFiles();
|
|
42
|
+
|
|
43
|
+
if (loaded.length > 0) {
|
|
44
|
+
lines.push({ kind: 'info', text: `Plugins loaded (${loaded.length}): ${loaded.join(', ')}` });
|
|
45
|
+
const cmds = manager.getCommandNames();
|
|
46
|
+
if (cmds.length > 0) {
|
|
47
|
+
lines.push({ kind: 'info', text: `Commands: ${cmds.join(', ')}` });
|
|
48
|
+
}
|
|
49
|
+
} else if (pending.length === 0) {
|
|
50
|
+
lines.push({ kind: 'info', text: 'No plugins. Put .js files in ~/.ciphermesh/plugins/' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (pending.length > 0) {
|
|
54
|
+
// Deliberately blunt. A plugin is not an extension in a browser sandbox: it
|
|
55
|
+
// is code in this process, next to the keys, and the person approving it
|
|
56
|
+
// should be told that in the same breath as being told how.
|
|
57
|
+
lines.push({
|
|
58
|
+
kind: 'error',
|
|
59
|
+
text: `Not running (${pending.length}): ${pending.join(', ')}`,
|
|
60
|
+
});
|
|
61
|
+
lines.push({
|
|
62
|
+
kind: 'info',
|
|
63
|
+
text: 'A plugin runs inside this process and can do anything the client can — including reading your keys in memory. Approve one only if you wrote it or read it.',
|
|
64
|
+
});
|
|
65
|
+
lines.push({
|
|
66
|
+
kind: 'info',
|
|
67
|
+
text: `Approve with /plugins allow ${pending[0].replace(/\.js$/, '')}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (failed.length > 0) {
|
|
72
|
+
lines.push({
|
|
73
|
+
kind: 'error',
|
|
74
|
+
text: `Approved but would not load: ${failed.join(', ')}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function approve(manager, file) {
|
|
82
|
+
if (!file) {
|
|
83
|
+
return [{ kind: 'error', text: 'Usage: /plugins allow <file>' }];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const loaded = await manager.approve(file);
|
|
87
|
+
if (!loaded) {
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
kind: 'error',
|
|
91
|
+
text: `No unapproved plugin called "${file}". Run /plugins to see what is there.`,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Persisted only after it actually loaded, so a broken file does not end up
|
|
97
|
+
// permanently on the approved list.
|
|
98
|
+
const config = loadConfig();
|
|
99
|
+
const allowed = new Set(config.pluginsAllowed || []);
|
|
100
|
+
allowed.add(file.replace(/\.js$/, ''));
|
|
101
|
+
saveConfig({ pluginsAllowed: [...allowed] });
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
{ kind: 'system', text: `${file} approved and loaded. It will load on its own from now on.` },
|
|
105
|
+
];
|
|
106
|
+
}
|