ciphermesh 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,231 @@
1
+ import * as readline from 'node:readline/promises';
2
+ import { stdin, stdout } from 'node:process';
3
+ import { readFileSync } from 'node:fs';
4
+ import sodium from 'sodium-native';
5
+ import { SERVER_PORT } from '../shared/constants.js';
6
+ import {
7
+ animatedBanner,
8
+ bootSequence,
9
+ clientConnectingBox,
10
+ promptLabel,
11
+ promptDim,
12
+ promptError,
13
+ } from '../shared/banner.js';
14
+ import { KeyManager } from '../crypto/KeyManager.js';
15
+ import { StateManager } from '../crypto/StateManager.js';
16
+ import { HistoryStore } from '../crypto/HistoryStore.js';
17
+ import { parseInvite } from '../shared/invite.js';
18
+ import { importBackup } from '../crypto/IdentityBackup.js';
19
+ import { questionHidden } from '../shared/prompt.js';
20
+ import { loadConfig, startupCommands } from '../shared/config.js';
21
+ import { setTheme } from '../shared/themes.js';
22
+ import { Connection } from './Connection.js';
23
+ import { UI } from './UI.js';
24
+ import { ChatController } from './ChatController.js';
25
+ import { PluginManager } from '../shared/PluginManager.js';
26
+
27
+ // ── Banner ──────────────────────────────────────────────────────
28
+ await animatedBanner();
29
+
30
+ // ── Config (optional defaults from ~/.ciphermesh/config.json) ────
31
+ const config = loadConfig();
32
+ if (config.theme) {
33
+ setTheme(config.theme);
34
+ }
35
+
36
+ // ── Prompt setup ────────────────────────────────────────────────
37
+ const rl = readline.createInterface({ input: stdin, output: stdout });
38
+
39
+ let nickname = '';
40
+ while (!nickname) {
41
+ const hint = config.nickname ? `(${config.nickname})` : '(a-z, 0-9, _, -)';
42
+ const raw = await rl.question(promptLabel(`Nickname ${promptDim(hint)}: `));
43
+ const clean = (raw.trim() || config.nickname || '').replace(/[^a-zA-Z0-9_-]/g, '');
44
+ if (clean.length >= 1 && clean.length <= 20) {
45
+ nickname = clean;
46
+ } else {
47
+ console.log(promptError('Invalid nickname. Use 1-20 alphanumeric characters.'));
48
+ }
49
+ }
50
+
51
+ // ── State restoration ────────────────────────────────────────────
52
+ const stateManager = new StateManager();
53
+ let restoredState = null;
54
+
55
+ if (stateManager.hasState()) {
56
+ const passphrase = await questionHidden(
57
+ rl,
58
+ promptLabel(`Passphrase to restore session ${promptDim('(Enter to skip)')}: `),
59
+ );
60
+ if (passphrase.trim()) {
61
+ restoredState = stateManager.loadState(passphrase.trim());
62
+ if (restoredState) {
63
+ restoredState.passphrase = passphrase.trim();
64
+ console.log(promptLabel('Previous session restored successfully!'));
65
+ } else {
66
+ console.log(promptError('Incorrect passphrase or corrupted state. Starting a new session.'));
67
+ }
68
+ }
69
+ } else {
70
+ const passphrase = await questionHidden(
71
+ rl,
72
+ promptLabel(`Passphrase to protect session ${promptDim('(Enter to skip)')}: `),
73
+ );
74
+ if (passphrase.trim()) {
75
+ const confirm = await questionHidden(rl, promptLabel('Confirm the passphrase: '));
76
+ if (confirm.trim() === passphrase.trim()) {
77
+ restoredState = { passphrase: passphrase.trim() };
78
+ } else {
79
+ console.log(
80
+ promptError('Passphrases do not match — session will not be protected this time.'),
81
+ );
82
+ }
83
+ }
84
+ }
85
+
86
+ // Offer to restore identity + trust from an encrypted backup (only when there
87
+ // is no session to restore).
88
+ if (!restoredState?.keyManager) {
89
+ const backupPath = await rl.question(
90
+ promptLabel(`Restore identity from a backup? ${promptDim('(path or Enter)')}: `),
91
+ );
92
+ if (backupPath.trim()) {
93
+ try {
94
+ const raw = readFileSync(backupPath.trim(), 'utf-8');
95
+ const pass = await questionHidden(rl, promptLabel('Backup passphrase: '));
96
+ const data = importBackup(raw, pass.trim());
97
+ if (data?.identity) {
98
+ restoredState = {
99
+ ...(restoredState || {}),
100
+ keyManager: data.identity,
101
+ trust: data.trust,
102
+ passphrase: pass.trim(),
103
+ };
104
+ console.log(promptLabel('Identity + trust restored from backup!'));
105
+ } else {
106
+ console.log(promptError('Invalid backup or incorrect passphrase.'));
107
+ }
108
+ } catch (e) {
109
+ console.log(promptError(`Could not read the backup: ${e.message}`));
110
+ }
111
+ }
112
+ }
113
+
114
+ const defaultServer = config.server || `localhost:${SERVER_PORT}`;
115
+ const serverInput = await rl.question(
116
+ promptLabel(`Server ${promptDim(`(${defaultServer} or ciphermesh:// invite)`)}: `),
117
+ );
118
+ const serverAddr = serverInput.trim() || defaultServer;
119
+
120
+ let wsUrl;
121
+ let inviteRoom = null;
122
+ const invite = parseInvite(serverAddr);
123
+ if (invite) {
124
+ wsUrl = invite.wsUrl;
125
+ inviteRoom = invite.room !== 'general' ? invite.room : null;
126
+ } else {
127
+ wsUrl =
128
+ serverAddr.startsWith('ws://') || serverAddr.startsWith('wss://')
129
+ ? serverAddr
130
+ : `wss://${serverAddr}`;
131
+ }
132
+
133
+ rl.close();
134
+
135
+ // ── Encrypted local history (opt-in, needs passphrase) ─────────
136
+ let historyStore = null;
137
+ if (restoredState?.passphrase) {
138
+ historyStore = new HistoryStore();
139
+ if (!historyStore.open(restoredState.passphrase)) {
140
+ console.log(promptError('History: passphrase mismatch — history disabled for this session'));
141
+ historyStore = null;
142
+ }
143
+ }
144
+
145
+ // ── Pre-connect info ────────────────────────────────────────────
146
+ // Create a temporary KeyManager to show fingerprint before blessed takes over
147
+ const tempKeys = restoredState?.keyManager
148
+ ? KeyManager.deserialize(restoredState.keyManager)
149
+ : new KeyManager();
150
+ const fingerprint = tempKeys.fingerprint;
151
+ tempKeys.destroy();
152
+
153
+ console.log();
154
+ clientConnectingBox(wsUrl, fingerprint);
155
+
156
+ // ── Real boot sequence ──────────────────────────────────────────
157
+ // The spinner gates on genuine startup work: plugins actually load, then the
158
+ // relay connection is actually established. If the connection is slow it
159
+ // extends; if it times out we fall through to the TUI, which keeps retrying
160
+ // and shows the live connection status.
161
+ const pluginManager = new PluginManager();
162
+ const connection = new Connection(wsUrl);
163
+
164
+ await bootSequence([
165
+ 'Curve25519 key exchange',
166
+ 'XSalsa20-Poly1305 cipher',
167
+ 'Double Ratchet — forward secrecy',
168
+ 'TOFU trust store',
169
+ { label: 'Loading plugins', task: () => pluginManager.loadAll() },
170
+ {
171
+ label: 'Connecting to relay',
172
+ timeoutMs: 3500,
173
+ task: () =>
174
+ new Promise((resolve) => {
175
+ if (connection.connected) {
176
+ resolve();
177
+ return;
178
+ }
179
+ connection.once('connected', resolve);
180
+ connection.connect();
181
+ }),
182
+ },
183
+ ]);
184
+
185
+ // ── Initialize ──────────────────────────────────────────────────
186
+
187
+ const ui = new UI(nickname);
188
+ const controller = new ChatController(
189
+ nickname,
190
+ connection,
191
+ ui,
192
+ restoredState,
193
+ pluginManager,
194
+ inviteRoom,
195
+ historyStore,
196
+ );
197
+
198
+ ui.setFingerprint(controller.fingerprint);
199
+ ui.addInfoMessage(`Your fingerprint: ${controller.fingerprint}`);
200
+ ui.addInfoMessage('Use /help to see available commands');
201
+
202
+ if (restoredState?.handshake) {
203
+ ui.addSystemMessage('Previous session restored — ratchets preserved');
204
+ }
205
+
206
+ // The connection was already initiated during the boot sequence.
207
+
208
+ // Apply config toggles by replaying their slash-commands through the controller.
209
+ for (const cmd of startupCommands(config)) {
210
+ ui.emit('input', cmd);
211
+ }
212
+
213
+ // ── Graceful shutdown ───────────────────────────────────────────
214
+ function shutdown() {
215
+ const passphrase = controller.passphrase;
216
+ if (passphrase) {
217
+ try {
218
+ const state = controller.serializeState();
219
+ const { kek, salt, opslimit, memlimit } = stateManager.deriveKEK(passphrase);
220
+ stateManager.saveState(state, kek, salt, opslimit, memlimit);
221
+ sodium.sodium_memzero(kek);
222
+ } catch {
223
+ // Best effort — don't block shutdown
224
+ }
225
+ }
226
+ controller.destroy();
227
+ process.exit(0);
228
+ }
229
+
230
+ process.on('SIGINT', shutdown);
231
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,79 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const PIN_DIR = '.ciphermesh';
5
+ const PIN_FILE = 'pinned-certs.json';
6
+
7
+ export const PinResult = {
8
+ PINNED: 'pinned', // first time — fingerprint stored (trust on first use)
9
+ MATCH: 'match', // fingerprint matches the pin
10
+ MISMATCH: 'mismatch', // fingerprint changed — possible MITM
11
+ };
12
+
13
+ /**
14
+ * Trust-on-first-use pin store for server TLS certificate fingerprints.
15
+ * Complements the E2EE key TOFU (TrustStore) with transport-layer detection.
16
+ */
17
+ export class CertPinStore {
18
+ #path;
19
+ #store; // Map<host, sha256Fingerprint>
20
+
21
+ constructor(baseDir = process.cwd()) {
22
+ const dir = join(baseDir, PIN_DIR);
23
+ if (!existsSync(dir)) {
24
+ mkdirSync(dir, { recursive: true });
25
+ }
26
+ this.#path = join(dir, PIN_FILE);
27
+ this.#store = new Map();
28
+ this.#load();
29
+ }
30
+
31
+ #load() {
32
+ try {
33
+ if (existsSync(this.#path)) {
34
+ const data = JSON.parse(readFileSync(this.#path, 'utf-8'));
35
+ for (const [host, fp] of Object.entries(data)) {
36
+ this.#store.set(host, fp);
37
+ }
38
+ }
39
+ } catch {
40
+ this.#store = new Map();
41
+ }
42
+ }
43
+
44
+ #save() {
45
+ writeFileSync(this.#path, JSON.stringify(Object.fromEntries(this.#store), null, 2), {
46
+ encoding: 'utf-8',
47
+ mode: 0o600,
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Check a server's cert fingerprint against the pinned one (pinning on first use).
53
+ * @param {string} host - e.g. "100.73.206.23:3600"
54
+ * @param {string|null} fingerprint - SHA-256 fingerprint, or null for non-TLS
55
+ * @returns {string} one of PinResult
56
+ */
57
+ check(host, fingerprint) {
58
+ if (!fingerprint) {
59
+ return PinResult.MATCH; // nothing to pin (plain ws://)
60
+ }
61
+ const pinned = this.#store.get(host);
62
+ if (!pinned) {
63
+ this.#store.set(host, fingerprint);
64
+ this.#save();
65
+ return PinResult.PINNED;
66
+ }
67
+ return pinned === fingerprint ? PinResult.MATCH : PinResult.MISMATCH;
68
+ }
69
+
70
+ getPinned(host) {
71
+ return this.#store.get(host) || null;
72
+ }
73
+
74
+ /** Explicitly accept a new fingerprint (e.g. after a legitimate cert rotation). */
75
+ repin(host, fingerprint) {
76
+ this.#store.set(host, fingerprint);
77
+ this.#save();
78
+ }
79
+ }
@@ -0,0 +1,53 @@
1
+ import sodium from 'sodium-native';
2
+ import { padMessage, unpadSecure } from './MessageCrypto.js';
3
+
4
+ /**
5
+ * Derive a shared symmetric key from a DH key exchange.
6
+ * X25519(mySecret, peerPublic) → BLAKE2b → 32-byte secretbox key.
7
+ * Both parties derive the same key (DH symmetry), so either could have created
8
+ * any message — that is what gives the deniable construction its deniability.
9
+ *
10
+ * NOTE: this used to call crypto_box_beforenm, which sodium-native removed
11
+ * (>=4.3.3), leaving deniable mode broken at runtime. We now derive the key
12
+ * ourselves from the raw X25519 shared secret.
13
+ */
14
+ export function deriveSharedKey(mySecretKey, peerPublicKey) {
15
+ const dh = sodium.sodium_malloc(sodium.crypto_scalarmult_BYTES);
16
+ sodium.crypto_scalarmult(dh, mySecretKey, peerPublicKey);
17
+
18
+ const sharedKey = sodium.sodium_malloc(sodium.crypto_secretbox_KEYBYTES);
19
+ sodium.crypto_generichash(sharedKey, dh);
20
+
21
+ sodium.sodium_memzero(dh);
22
+ return sharedKey;
23
+ }
24
+
25
+ /**
26
+ * Encrypt with crypto_secretbox_easy (symmetric, deniable).
27
+ * Uses XSalsa20-Poly1305 with a shared key — no sender authentication.
28
+ */
29
+ export function encryptDeniable(plaintext, nonce, sharedKey) {
30
+ const message = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf-8');
31
+ const padded = padMessage(message);
32
+ const ciphertext = Buffer.alloc(padded.length + sodium.crypto_secretbox_MACBYTES);
33
+
34
+ sodium.crypto_secretbox_easy(ciphertext, padded, nonce, sharedKey);
35
+ sodium.sodium_memzero(padded);
36
+
37
+ return ciphertext;
38
+ }
39
+
40
+ /**
41
+ * Decrypt with crypto_secretbox_open_easy (symmetric, deniable).
42
+ */
43
+ export function decryptDeniable(ciphertext, nonce, sharedKey) {
44
+ const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
45
+ const ok = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, sharedKey);
46
+
47
+ if (!ok) {
48
+ sodium.sodium_memzero(padded);
49
+ return null;
50
+ }
51
+
52
+ return unpadSecure(padded);
53
+ }