ciphermesh 2.8.0 → 2.10.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 +51 -0
- package/README.md +94 -91
- package/README.pt-BR.md +91 -90
- package/docs/PLUGINS.md +53 -14
- package/docs/commands.json +640 -0
- package/docs/demo.svg +2 -2
- package/package.json +3 -2
- package/src/client/ChatController.js +14 -10
- package/src/client/UI.js +4 -1
- package/src/client/index.js +6 -1
- package/src/p2p/P2PChatController.js +78 -11
- package/src/p2p/index.js +6 -1
- package/src/server/ConnectionGuard.js +158 -0
- package/src/server/WebSocketServer.js +35 -0
- 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 +15 -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
|
@@ -18,6 +18,21 @@ export const MAX_CONNECTIONS_PER_IP = 20; // per-source-IP socket cap
|
|
|
18
18
|
export const JOIN_TIMEOUT_MS = 15_000; // drop sockets that never JOIN
|
|
19
19
|
export const MESSAGE_RATE_LIMIT_PER_SECOND = 60; // per connection, ALL message types
|
|
20
20
|
|
|
21
|
+
// How fast one source may *open* connections, as opposed to how many it may
|
|
22
|
+
// hold. The concurrency cap never trips against churn — connect, handshake,
|
|
23
|
+
// disconnect, repeat — and every one of those attempts costs the relay an
|
|
24
|
+
// X25519 and an ML-KEM-768 operation while costing the client almost nothing.
|
|
25
|
+
export const CONNECTION_RATE_PER_MINUTE = 60;
|
|
26
|
+
|
|
27
|
+
// Bytes per second per connection, sustained. The message limit counts
|
|
28
|
+
// messages, and messages are padded into buckets, so a session at 60/s is a
|
|
29
|
+
// multi-megabit stream: bytes are the resource that actually runs out.
|
|
30
|
+
export const MAX_BYTES_PER_SECOND = 1_048_576;
|
|
31
|
+
// Burst allowance, so a file transfer is not mistaken for an attack. Must stay
|
|
32
|
+
// comfortably above MAX_PAYLOAD_SIZE — a burst smaller than one frame could
|
|
33
|
+
// never be paid for, and the connection would wedge instead of throttling.
|
|
34
|
+
export const MAX_BYTES_BURST = 4_194_304;
|
|
35
|
+
|
|
21
36
|
// Crypto sizes (libsodium Curve25519 + XSalsa20-Poly1305)
|
|
22
37
|
export const NONCE_SIZE = 24;
|
|
23
38
|
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
|
+
}
|