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.
@@ -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; // Map<name, module>
14
- #commands; // Map<cmdName, handler>
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
- constructor() {
17
- this.#plugins = new Map();
18
- this.#commands = new Map();
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 jsFiles = files.filter((f) => f.endsWith('.js'));
35
-
36
- for (const file of jsFiles) {
37
- try {
38
- const filePath = join(dir, file);
39
- const fileUrl = pathToFileURL(filePath).href;
40
- const mod = await import(fileUrl);
41
- const plugin = mod.default || mod;
42
-
43
- if (!plugin.name || !plugin.commands) {
44
- continue;
45
- }
46
-
47
- this.#plugins.set(plugin.name, plugin);
48
-
49
- for (const [cmdName, handler] of Object.entries(plugin.commands)) {
50
- const normalized = cmdName.startsWith('/')
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
+ }
@@ -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
 
@@ -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
+ }