ciphermesh 2.3.0 → 2.5.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/src/p2p/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from '../shared/banner.js';
15
15
  import { KeyManager } from '../crypto/KeyManager.js';
16
16
  import { StateManager } from '../crypto/StateManager.js';
17
+ import { HistoryStore } from '../crypto/HistoryStore.js';
17
18
  import { questionHidden } from '../shared/prompt.js';
18
19
  import { loadConfig, startupCommands } from '../shared/config.js';
19
20
  import { randomTip } from '../shared/tips.js';
@@ -171,6 +172,16 @@ await bootSequence([
171
172
  const connManager = new PeerConnectionManager(nickname, () => keyManager.publicKeyB64);
172
173
  const discovery = new Discovery();
173
174
  const ui = new UI(nickname);
175
+ // Encrypted local history (opt-in — same passphrase that protects the session)
176
+ let historyStore = null;
177
+ if (restoredState?.passphrase) {
178
+ historyStore = new HistoryStore();
179
+ if (!historyStore.open(restoredState.passphrase)) {
180
+ console.log(promptError('History: passphrase mismatch — history disabled for this session'));
181
+ historyStore = null;
182
+ }
183
+ }
184
+
174
185
  const controller = new P2PChatController(
175
186
  nickname,
176
187
  peerServer,
@@ -180,6 +191,7 @@ const controller = new P2PChatController(
180
191
  keyManager,
181
192
  restoredState,
182
193
  pluginManager,
194
+ historyStore,
183
195
  );
184
196
 
185
197
  ui.setFingerprint(controller.fingerprint);
@@ -66,7 +66,16 @@ export function parseMessage(raw) {
66
66
  return { valid: false, error: 'Message must be an object' };
67
67
  }
68
68
  if (msg.version !== PROTOCOL_VERSION) {
69
- return { valid: false, error: `Unsupported protocol version: ${msg.version}` };
69
+ // Spell out what to do: this reaches a human staring at a chat that just
70
+ // refuses to work, and "unsupported protocol version" alone tells them
71
+ // nothing about which side is behind or how to fix it.
72
+ const side = msg.version < PROTOCOL_VERSION ? 'client is older' : 'server is older';
73
+ return {
74
+ valid: false,
75
+ error:
76
+ `Protocol mismatch: this ${side} (got v${msg.version}, expected v${PROTOCOL_VERSION}). ` +
77
+ 'Update both sides — `npx ciphermesh@latest`, or `git pull && npm install` if running from source.',
78
+ };
70
79
  }
71
80
  if (!isString(msg.type)) {
72
81
  return { valid: false, error: 'Missing message type' };
package/src/shared/dnd.js CHANGED
@@ -58,3 +58,16 @@ export function mentionsMe(text, nickname) {
58
58
  }
59
59
  return new RegExp(`(^|[^a-z0-9_-])${nick}([^a-z0-9_-]|$)`).test(t);
60
60
  }
61
+
62
+ /**
63
+ * True if `text` contains `word` as a whole word (case-insensitive) — the
64
+ * matcher behind /watch. Whole-word so "dev" doesn't fire on "development",
65
+ * and the keyword is escaped so punctuation can never build a stray regex.
66
+ */
67
+ export function matchesKeyword(text, word) {
68
+ if (typeof text !== 'string' || typeof word !== 'string' || !word) {
69
+ return false;
70
+ }
71
+ const escaped = word.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ return new RegExp(`(^|[^a-z0-9_-])${escaped}([^a-z0-9_-]|$)`).test(text.toLowerCase());
73
+ }
@@ -0,0 +1,225 @@
1
+ // Connection doctor: answers "why can't I connect?" in the order the failures
2
+ // actually happen, so a user without a terminal debugger can fix it alone.
3
+ //
4
+ // Every step reports what was checked, whether it passed, and — when it fails
5
+ // — what to do about it. A failing step stops the run: there is no point
6
+ // testing TLS when the TCP port never opened.
7
+ import { connect as netConnect } from 'node:net';
8
+ import { connect as tlsConnect } from 'node:tls';
9
+ import { lookup as dnsLookup } from 'node:dns';
10
+ import { PROTOCOL_VERSION } from './constants.js';
11
+
12
+ const DEFAULT_TIMEOUT_MS = 6000;
13
+
14
+ /** Split "wss://host:3600" (or "host:3600") into its parts. */
15
+ export function parseTarget(raw) {
16
+ const value = String(raw || '').trim();
17
+ if (!value) {
18
+ return null;
19
+ }
20
+ const withScheme = /^wss?:\/\//.test(value) ? value : `wss://${value}`;
21
+ try {
22
+ const url = new URL(withScheme);
23
+ const port = Number(url.port) || 3600;
24
+ if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
25
+ return null;
26
+ }
27
+ return { host: url.hostname, port, tls: url.protocol === 'wss:', url: withScheme };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ const isIpLiteral = (host) => /^[\d.]+$/.test(host) || host.includes(':');
34
+
35
+ function step(name, ok, detail, hint = null) {
36
+ return hint ? { name, ok, detail, hint } : { name, ok, detail };
37
+ }
38
+
39
+ function resolveHost(host, deps) {
40
+ return new Promise((resolve) => {
41
+ deps.lookup(host, (err, address) => resolve(err ? null : address));
42
+ });
43
+ }
44
+
45
+ function probeTcp(host, port, timeoutMs, deps) {
46
+ return new Promise((resolve) => {
47
+ const socket = deps.netConnect({ host, port });
48
+ const done = (result) => {
49
+ socket.removeAllListeners();
50
+ socket.destroy();
51
+ resolve(result);
52
+ };
53
+ socket.setTimeout(timeoutMs);
54
+ socket.on('connect', () => done({ ok: true }));
55
+ socket.on('timeout', () => done({ ok: false, reason: 'timeout' }));
56
+ socket.on('error', (err) => done({ ok: false, reason: err.code || err.message }));
57
+ });
58
+ }
59
+
60
+ function probeTls(host, port, timeoutMs, deps) {
61
+ return new Promise((resolve) => {
62
+ // SNI is a hostname field: Node throws outright if given an IP, and on a
63
+ // LAN the target is almost always an IP. Send it only when we have a name.
64
+ const sni = isIpLiteral(host) ? {} : { servername: host };
65
+ // Diagnostic socket only: it completes the handshake, reads the
66
+ // certificate, reports it and closes — no data is ever sent over it.
67
+ // rejectUnauthorized is off precisely so a self-signed LAN certificate
68
+ // still reaches this point and can be DESCRIBED to the user instead of
69
+ // failing opaquely. The real chat connection does not run this way: it
70
+ // enforces strict verification for hosts that ever presented a CA-valid
71
+ // certificate (see crypto/CertPinStore.js).
72
+ const socket = deps.tlsConnect({ host, port, rejectUnauthorized: false, ...sni });
73
+ const done = (result) => {
74
+ socket.removeAllListeners();
75
+ socket.destroy();
76
+ resolve(result);
77
+ };
78
+ socket.setTimeout(timeoutMs);
79
+ socket.on('secureConnect', () => {
80
+ const cert = socket.getPeerCertificate?.() || {};
81
+ done({
82
+ ok: true,
83
+ authorized: socket.authorized === true,
84
+ issuer: cert.issuer?.O || cert.issuer?.CN || 'unknown',
85
+ fingerprint: cert.fingerprint256 || null,
86
+ });
87
+ });
88
+ socket.on('timeout', () => done({ ok: false, reason: 'timeout' }));
89
+ socket.on('error', (err) => done({ ok: false, reason: err.code || err.message }));
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Run the connection checks against `target`.
95
+ *
96
+ * @param {string} target - what the user typed at the Server prompt
97
+ * @param {object} [opts]
98
+ * @param {number} [opts.timeoutMs]
99
+ * @param {object} [opts.deps] - injectable node primitives (tests)
100
+ * @returns {Promise<Array<{name, ok, detail, hint?}>>}
101
+ */
102
+ export async function diagnose(target, opts = {}) {
103
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
104
+ const deps = {
105
+ lookup: dnsLookup,
106
+ netConnect,
107
+ tlsConnect,
108
+ ...(opts.deps || {}),
109
+ };
110
+ const steps = [];
111
+
112
+ const parsed = parseTarget(target);
113
+ if (!parsed) {
114
+ steps.push(
115
+ step(
116
+ 'Address',
117
+ false,
118
+ `cannot parse "${target}"`,
119
+ 'Use host:port — for example 192.168.1.10:3600 — or a ciphermesh:// invite.',
120
+ ),
121
+ );
122
+ return steps;
123
+ }
124
+ steps.push(
125
+ step('Address', true, `${parsed.host}:${parsed.port} (${parsed.tls ? 'TLS' : 'plain'})`),
126
+ );
127
+
128
+ if (/^(localhost|127\.|::1)/.test(parsed.host)) {
129
+ steps.push(
130
+ step(
131
+ 'Target',
132
+ true,
133
+ 'localhost — this points at YOUR machine',
134
+ 'To reach someone else, use THEIR address. localhost never leaves this computer.',
135
+ ),
136
+ );
137
+ }
138
+
139
+ if (!isIpLiteral(parsed.host)) {
140
+ const address = await resolveHost(parsed.host, deps);
141
+ steps.push(
142
+ address
143
+ ? step('DNS', true, `${parsed.host} → ${address}`)
144
+ : step(
145
+ 'DNS',
146
+ false,
147
+ `cannot resolve ${parsed.host}`,
148
+ 'Check the name, or use the IP directly.',
149
+ ),
150
+ );
151
+ if (!address) {
152
+ return steps;
153
+ }
154
+ }
155
+
156
+ const tcp = await probeTcp(parsed.host, parsed.port, timeoutMs, deps);
157
+ if (!tcp.ok) {
158
+ steps.push(
159
+ step(
160
+ 'TCP port',
161
+ false,
162
+ `port ${parsed.port} unreachable (${tcp.reason})`,
163
+ tcp.reason === 'timeout'
164
+ ? 'Something is dropping the packets: a firewall on either side, or client isolation on the Wi-Fi router. If you can ping the host but not open the port, that is the usual cause.'
165
+ : 'Nothing is listening there. Is the server running, and is the port right?',
166
+ ),
167
+ );
168
+ return steps;
169
+ }
170
+ steps.push(step('TCP port', true, `${parsed.host}:${parsed.port} is open`));
171
+
172
+ if (parsed.tls) {
173
+ const tls = await probeTls(parsed.host, parsed.port, timeoutMs, deps);
174
+ if (!tls.ok) {
175
+ steps.push(
176
+ step(
177
+ 'TLS',
178
+ false,
179
+ `handshake failed (${tls.reason})`,
180
+ 'The port answered but did not negotiate TLS. Is that really a CipherMesh relay?',
181
+ ),
182
+ );
183
+ return steps;
184
+ }
185
+ steps.push(
186
+ tls.authorized
187
+ ? step('TLS', true, `verified against a public CA (${tls.issuer})`)
188
+ : step(
189
+ 'TLS',
190
+ true,
191
+ 'self-signed certificate (normal on a LAN)',
192
+ 'Trust is pinned on first use — compare fingerprints out-of-band if you want certainty.',
193
+ ),
194
+ );
195
+ }
196
+
197
+ steps.push(
198
+ step(
199
+ 'Protocol',
200
+ true,
201
+ `this client speaks v${PROTOCOL_VERSION}`,
202
+ 'If the server refuses with a protocol mismatch, update BOTH sides: npx ciphermesh@latest, or git pull && npm install from source.',
203
+ ),
204
+ );
205
+
206
+ return steps;
207
+ }
208
+
209
+ /** Render diagnose() output as lines ready for the chat log. */
210
+ export function formatDiagnosis(steps) {
211
+ const lines = [];
212
+ for (const s of steps) {
213
+ lines.push(`${s.ok ? '✓' : '✗'} ${s.name}: ${s.detail}`);
214
+ if (s.hint) {
215
+ lines.push(` ↳ ${s.hint}`);
216
+ }
217
+ }
218
+ const failed = steps.find((s) => !s.ok);
219
+ lines.push(
220
+ failed
221
+ ? `Blocked at "${failed.name}". Fix that first — the checks after it were skipped.`
222
+ : 'All checks passed. If the chat still fails, the problem is above the network layer.',
223
+ );
224
+ return lines;
225
+ }