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,181 @@
1
+ import sodium from 'sodium-native';
2
+ import { MESSAGE_PAD_BUCKETS } from '../shared/constants.js';
3
+
4
+ // ── Padding helpers ──────────────────────────────────────────
5
+
6
+ /**
7
+ * Pad plaintext to a fixed-size bucket to hide message length.
8
+ * Format: [2 bytes length BE] + [plaintext] + [random padding]
9
+ */
10
+ export function padMessage(message) {
11
+ // The length prefix is 2 bytes, so a single plaintext can't exceed 65535.
12
+ // Callers must keep payloads (e.g. file chunks) under this — fail loudly with
13
+ // a clear message rather than a cryptic writeUInt16BE RangeError.
14
+ if (message.length > 0xffff) {
15
+ throw new Error(`Payload too large to pad: ${message.length} bytes (max 65535)`);
16
+ }
17
+ const needed = 2 + message.length;
18
+
19
+ // Find smallest bucket that fits
20
+ let bucketSize = MESSAGE_PAD_BUCKETS[MESSAGE_PAD_BUCKETS.length - 1];
21
+ for (const size of MESSAGE_PAD_BUCKETS) {
22
+ if (size >= needed) {
23
+ bucketSize = size;
24
+ break;
25
+ }
26
+ }
27
+
28
+ // If message is larger than biggest bucket, no padding (e.g. file chunks)
29
+ if (needed > bucketSize) {
30
+ bucketSize = needed;
31
+ }
32
+
33
+ const padded = Buffer.alloc(bucketSize);
34
+ padded.writeUInt16BE(message.length, 0);
35
+ message.copy(padded, 2);
36
+
37
+ // Fill remaining bytes with random data
38
+ if (bucketSize > needed) {
39
+ const randomPart = padded.subarray(needed);
40
+ sodium.randombytes_buf(randomPart);
41
+ }
42
+
43
+ return padded;
44
+ }
45
+
46
+ /**
47
+ * Remove padding and extract original plaintext.
48
+ */
49
+ export function unpadMessage(padded) {
50
+ if (padded.length < 2) {
51
+ return null;
52
+ }
53
+
54
+ const length = padded.readUInt16BE(0);
55
+ if (length + 2 > padded.length) {
56
+ return null;
57
+ }
58
+
59
+ return padded.subarray(2, 2 + length);
60
+ }
61
+
62
+ /**
63
+ * Unpad and copy plaintext into secure memory, then wipe the padded source.
64
+ * @param {Buffer} padded - Decrypted padded buffer
65
+ * @returns {Buffer|null} Plaintext in sodium_malloc, or null
66
+ */
67
+ export function unpadSecure(padded) {
68
+ const unpadded = unpadMessage(padded);
69
+ if (!unpadded) {
70
+ sodium.sodium_memzero(padded);
71
+ return null;
72
+ }
73
+ const secure = sodium.sodium_malloc(unpadded.length);
74
+ unpadded.copy(secure);
75
+ sodium.sodium_memzero(padded);
76
+ return secure;
77
+ }
78
+
79
+ // ── Encrypt / Decrypt ────────────────────────────────────────
80
+
81
+ /**
82
+ * Encrypt plaintext using crypto_box_easy (X25519 + XSalsa20-Poly1305).
83
+ * Applies padding before encryption to hide message length.
84
+ * @param {string|Buffer} plaintext
85
+ * @param {Buffer} nonce - 24 bytes
86
+ * @param {Buffer} recipientPublicKey - 32 bytes
87
+ * @param {Buffer} senderSecretKey - 32 bytes
88
+ * @returns {Buffer} ciphertext (padded length + 16 bytes MAC)
89
+ */
90
+ export function encrypt(plaintext, nonce, recipientPublicKey, senderSecretKey) {
91
+ const message = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf-8');
92
+ const padded = padMessage(message);
93
+ const ciphertext = Buffer.alloc(padded.length + sodium.crypto_box_MACBYTES);
94
+
95
+ sodium.crypto_box_easy(ciphertext, padded, nonce, recipientPublicKey, senderSecretKey);
96
+ sodium.sodium_memzero(padded);
97
+
98
+ return ciphertext;
99
+ }
100
+
101
+ /**
102
+ * Decrypt ciphertext using crypto_box_open_easy.
103
+ * Removes padding after decryption to recover original plaintext.
104
+ * @param {Buffer} ciphertext
105
+ * @param {Buffer} nonce - 24 bytes
106
+ * @param {Buffer} senderPublicKey - 32 bytes
107
+ * @param {Buffer} recipientSecretKey - 32 bytes
108
+ * @returns {Buffer|null} plaintext, or null if MAC verification failed
109
+ */
110
+ export function decrypt(ciphertext, nonce, senderPublicKey, recipientSecretKey) {
111
+ if (ciphertext.length < sodium.crypto_box_MACBYTES) {
112
+ return null;
113
+ }
114
+
115
+ const padded = Buffer.alloc(ciphertext.length - sodium.crypto_box_MACBYTES);
116
+ const valid = sodium.crypto_box_open_easy(
117
+ padded,
118
+ ciphertext,
119
+ nonce,
120
+ senderPublicKey,
121
+ recipientSecretKey,
122
+ );
123
+
124
+ if (!valid) {
125
+ sodium.sodium_memzero(padded);
126
+ return null;
127
+ }
128
+
129
+ return unpadSecure(padded);
130
+ }
131
+
132
+ /**
133
+ * Try to decrypt with current keys, falling back to previous keys (grace period).
134
+ * @param {Buffer} ciphertext
135
+ * @param {Buffer} nonce
136
+ * @param {Buffer} senderPublicKey - current
137
+ * @param {Buffer} recipientSecretKey - current
138
+ * @param {Buffer|null} prevSenderPublicKey - previous sender key (if rotated)
139
+ * @param {Buffer|null} prevRecipientSecretKey - previous recipient key (if rotated)
140
+ * @returns {Buffer|null}
141
+ */
142
+ export function decryptWithFallback(
143
+ ciphertext,
144
+ nonce,
145
+ senderPublicKey,
146
+ recipientSecretKey,
147
+ prevSenderPublicKey,
148
+ prevRecipientSecretKey,
149
+ ) {
150
+ // Try current keys first
151
+ const result = decrypt(ciphertext, nonce, senderPublicKey, recipientSecretKey);
152
+ if (result) {
153
+ return result;
154
+ }
155
+
156
+ // Try with sender's previous public key + our current secret key
157
+ if (prevSenderPublicKey) {
158
+ const r = decrypt(ciphertext, nonce, prevSenderPublicKey, recipientSecretKey);
159
+ if (r) {
160
+ return r;
161
+ }
162
+ }
163
+
164
+ // Try with our previous secret key + sender's current public key
165
+ if (prevRecipientSecretKey) {
166
+ const r = decrypt(ciphertext, nonce, senderPublicKey, prevRecipientSecretKey);
167
+ if (r) {
168
+ return r;
169
+ }
170
+ }
171
+
172
+ // Try with both previous keys
173
+ if (prevSenderPublicKey && prevRecipientSecretKey) {
174
+ const r = decrypt(ciphertext, nonce, prevSenderPublicKey, prevRecipientSecretKey);
175
+ if (r) {
176
+ return r;
177
+ }
178
+ }
179
+
180
+ return null;
181
+ }
@@ -0,0 +1,72 @@
1
+ import sodium from 'sodium-native';
2
+ import {
3
+ NONCE_SIZE,
4
+ NONCE_TIMESTAMP_OFFSET,
5
+ NONCE_COUNTER_OFFSET,
6
+ NONCE_RANDOM_OFFSET,
7
+ NONCE_RANDOM_SIZE,
8
+ NONCE_MAX_AGE_MS,
9
+ } from '../shared/constants.js';
10
+
11
+ export class NonceManager {
12
+ #counter;
13
+ #peerCounters; // Map<peerId, lastCounter>
14
+
15
+ constructor() {
16
+ this.#counter = 0;
17
+ this.#peerCounters = new Map();
18
+ }
19
+
20
+ /**
21
+ * Generate a new 24-byte nonce:
22
+ * [8B timestamp][4B counter][12B random]
23
+ */
24
+ generate() {
25
+ const nonce = Buffer.alloc(NONCE_SIZE);
26
+
27
+ // 8 bytes: millisecond timestamp
28
+ const now = BigInt(Date.now());
29
+ nonce.writeBigUInt64BE(now, NONCE_TIMESTAMP_OFFSET);
30
+
31
+ // 4 bytes: monotonic counter
32
+ this.#counter = (this.#counter + 1) & 0xffffffff;
33
+ nonce.writeUInt32BE(this.#counter, NONCE_COUNTER_OFFSET);
34
+
35
+ // 12 bytes: random
36
+ const randomPart = nonce.subarray(NONCE_RANDOM_OFFSET, NONCE_RANDOM_OFFSET + NONCE_RANDOM_SIZE);
37
+ sodium.randombytes_buf(randomPart);
38
+
39
+ return nonce;
40
+ }
41
+
42
+ /**
43
+ * Validate an incoming nonce (anti-replay).
44
+ * Returns true if valid, false if replayed or too old.
45
+ */
46
+ validate(peerId, nonce) {
47
+ if (!Buffer.isBuffer(nonce) || nonce.length !== NONCE_SIZE) {
48
+ return false;
49
+ }
50
+
51
+ // Check timestamp freshness
52
+ const nonceTimestamp = Number(nonce.readBigUInt64BE(NONCE_TIMESTAMP_OFFSET));
53
+ const now = Date.now();
54
+ if (Math.abs(now - nonceTimestamp) > NONCE_MAX_AGE_MS) {
55
+ return false;
56
+ }
57
+
58
+ // Check counter is strictly increasing per peer
59
+ const counter = nonce.readUInt32BE(NONCE_COUNTER_OFFSET);
60
+ const lastCounter = this.#peerCounters.get(peerId) ?? -1;
61
+ if (counter <= lastCounter) {
62
+ return false;
63
+ }
64
+
65
+ this.#peerCounters.set(peerId, counter);
66
+ return true;
67
+ }
68
+
69
+ removePeer(peerId) {
70
+ this.#peerCounters.delete(peerId);
71
+ }
72
+ }
@@ -0,0 +1,58 @@
1
+ import sodium from 'sodium-native';
2
+
3
+ // ── Sealed sender: anonymise the SENDER to the relay ────────────
4
+ // The zero-knowledge relay routes by `to` and never needs `from`. Sealed sender
5
+ // removes `from` from the wire envelope and instead carries the sender's
6
+ // identity inside a libsodium *sealed box* (crypto_box_seal): the sender uses a
7
+ // throwaway ephemeral keypair to encrypt to the recipient's static public key,
8
+ // so the ciphertext is anonymous — only the recipient's secret key opens it, and
9
+ // nothing in it identifies the sender to the relay. The recipient opens it,
10
+ // learns `from`, then decrypts the inner (already E2E-encrypted) payload as usual.
11
+ // (Recipient identity is still visible to the relay — that's inherent to routing.)
12
+
13
+ // Anonymous seal of arbitrary bytes to a recipient public key.
14
+ export function seal(inner, recipientPublicKey) {
15
+ const message = Buffer.isBuffer(inner) ? inner : Buffer.from(inner, 'utf-8');
16
+ const ciphertext = Buffer.alloc(message.length + sodium.crypto_box_SEALBYTES);
17
+ sodium.crypto_box_seal(ciphertext, message, recipientPublicKey);
18
+ return ciphertext;
19
+ }
20
+
21
+ // Open a sealed box; returns the plaintext Buffer, or null on failure/tamper.
22
+ export function unseal(sealedCiphertext, recipientPublicKey, recipientSecretKey) {
23
+ if (sealedCiphertext.length < sodium.crypto_box_SEALBYTES) {
24
+ return null;
25
+ }
26
+ const out = Buffer.alloc(sealedCiphertext.length - sodium.crypto_box_SEALBYTES);
27
+ const ok = sodium.crypto_box_seal_open(
28
+ out,
29
+ sealedCiphertext,
30
+ recipientPublicKey,
31
+ recipientSecretKey,
32
+ );
33
+ if (!ok) {
34
+ sodium.sodium_memzero(out);
35
+ return null;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Wrap an already-encrypted payload plus the sender's identity so the relay
41
+ // can't see who sent it. Returns base64 for the wire `payload.sealed` field.
42
+ export function sealEnvelope(from, payload, recipientPublicKey) {
43
+ const inner = Buffer.from(JSON.stringify({ from, payload }), 'utf-8');
44
+ return seal(inner, recipientPublicKey).toString('base64');
45
+ }
46
+
47
+ // Open a sealed envelope → { from, payload }, or null if it isn't for us.
48
+ export function openEnvelope(sealedB64, recipientPublicKey, recipientSecretKey) {
49
+ const opened = unseal(Buffer.from(sealedB64, 'base64'), recipientPublicKey, recipientSecretKey);
50
+ if (!opened) {
51
+ return null;
52
+ }
53
+ try {
54
+ return JSON.parse(opened.toString('utf-8'));
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,204 @@
1
+ import sodium from 'sodium-native';
2
+ import { NONCE_SIZE } from '../shared/constants.js';
3
+ import { padMessage, unpadSecure } from './MessageCrypto.js';
4
+
5
+ // ── Sender Keys: real group cryptography ────────────────────────
6
+ // Each sender owns a symmetric ratchet chain per room. A message is encrypted
7
+ // ONCE with the next message key and the same ciphertext is broadcast to every
8
+ // member (O(1) instead of one pairwise encryption per peer). Members decrypt
9
+ // with the sender's chain, which was distributed once over the pairwise channel.
10
+ // Forward secrecy comes from ratcheting the chain forward every message and
11
+ // rotating the whole chain on membership changes.
12
+
13
+ const KEY_SIZE = 32;
14
+ const MSG_KEY_TAG = Buffer.from([0x01]);
15
+ const CHAIN_KEY_TAG = Buffer.from([0x02]);
16
+ const DEFAULT_MAX_SKIP = 1000; // bound out-of-order / skipped message keys
17
+
18
+ // A single sender's ratchet chain. Used to *send* (deriveNext) when it's your
19
+ // own chain, or to *receive* (messageKeyFor) when it's a peer's distributed one.
20
+ export class SenderChain {
21
+ #chainKey;
22
+ #counter;
23
+ #skipped; // Map<counter, messageKey> for out-of-order receipt
24
+ #maxSkip;
25
+
26
+ constructor(chainKey = null, counter = 0, maxSkip = DEFAULT_MAX_SKIP) {
27
+ this.#chainKey = sodium.sodium_malloc(KEY_SIZE);
28
+ if (chainKey) {
29
+ chainKey.copy(this.#chainKey);
30
+ } else {
31
+ sodium.randombytes_buf(this.#chainKey);
32
+ }
33
+ this.#counter = counter;
34
+ this.#skipped = new Map();
35
+ this.#maxSkip = maxSkip;
36
+ }
37
+
38
+ // Derive the current message key and ratchet the chain forward one step.
39
+ #step() {
40
+ const messageKey = sodium.sodium_malloc(KEY_SIZE);
41
+ sodium.crypto_generichash(messageKey, MSG_KEY_TAG, this.#chainKey);
42
+ const nextChainKey = sodium.sodium_malloc(KEY_SIZE);
43
+ sodium.crypto_generichash(nextChainKey, CHAIN_KEY_TAG, this.#chainKey);
44
+ sodium.sodium_memzero(this.#chainKey);
45
+ this.#chainKey = nextChainKey;
46
+ return messageKey;
47
+ }
48
+
49
+ // Sending: next message key + its counter.
50
+ deriveNext() {
51
+ const counter = this.#counter;
52
+ const messageKey = this.#step();
53
+ this.#counter++;
54
+ return { messageKey, counter };
55
+ }
56
+
57
+ // Receiving: the message key at `targetCounter`, caching skipped keys for
58
+ // out-of-order delivery. Returns null on replay (already consumed) or if the
59
+ // gap exceeds maxSkip. The caller must sodium_memzero the returned key.
60
+ messageKeyFor(targetCounter) {
61
+ if (this.#skipped.has(targetCounter)) {
62
+ const key = this.#skipped.get(targetCounter);
63
+ this.#skipped.delete(targetCounter);
64
+ return key;
65
+ }
66
+ if (targetCounter < this.#counter) {
67
+ return null; // already consumed → replay
68
+ }
69
+ if (targetCounter - this.#counter > this.#maxSkip) {
70
+ return null; // too far ahead
71
+ }
72
+ while (this.#counter < targetCounter) {
73
+ this.#skipped.set(this.#counter, this.#step());
74
+ this.#counter++;
75
+ }
76
+ const messageKey = this.#step();
77
+ this.#counter++;
78
+ return messageKey;
79
+ }
80
+
81
+ // Serialise the chain state so it can be handed to a new member (over the
82
+ // encrypted pairwise channel). Never send this in the clear.
83
+ serialize() {
84
+ return { chainKey: Buffer.from(this.#chainKey).toString('base64'), counter: this.#counter };
85
+ }
86
+
87
+ static deserialize({ chainKey, counter }) {
88
+ return new SenderChain(Buffer.from(chainKey, 'base64'), counter);
89
+ }
90
+
91
+ destroy() {
92
+ sodium.sodium_memzero(this.#chainKey);
93
+ for (const key of this.#skipped.values()) {
94
+ sodium.sodium_memzero(key);
95
+ }
96
+ this.#skipped.clear();
97
+ }
98
+ }
99
+
100
+ // Encrypt with a one-shot message key (length-padded, like the pairwise paths).
101
+ export function groupEncrypt(messageKey, plaintext) {
102
+ const message = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf-8');
103
+ const padded = padMessage(message);
104
+ const nonce = Buffer.alloc(NONCE_SIZE);
105
+ sodium.randombytes_buf(nonce);
106
+ const ciphertext = Buffer.alloc(padded.length + sodium.crypto_secretbox_MACBYTES);
107
+ sodium.crypto_secretbox_easy(ciphertext, padded, nonce, messageKey);
108
+ sodium.sodium_memzero(padded);
109
+ sodium.sodium_memzero(messageKey);
110
+ return { ciphertext, nonce };
111
+ }
112
+
113
+ export function groupDecrypt(messageKey, ciphertext, nonce) {
114
+ if (ciphertext.length < sodium.crypto_secretbox_MACBYTES) {
115
+ sodium.sodium_memzero(messageKey);
116
+ return null;
117
+ }
118
+ const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
119
+ const ok = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, messageKey);
120
+ sodium.sodium_memzero(messageKey);
121
+ if (!ok) {
122
+ sodium.sodium_memzero(padded);
123
+ return null;
124
+ }
125
+ return unpadSecure(padded);
126
+ }
127
+
128
+ // A per-room group session: your own sending chain + one receiving chain per
129
+ // member. encrypt() runs once; every member decrypt()s the same ciphertext.
130
+ export class GroupSession {
131
+ #own;
132
+ #members; // Map<memberId, SenderChain>
133
+
134
+ constructor() {
135
+ this.#own = new SenderChain();
136
+ this.#members = new Map();
137
+ }
138
+
139
+ encrypt(plaintext) {
140
+ const { messageKey, counter } = this.#own.deriveNext();
141
+ const { ciphertext, nonce } = groupEncrypt(messageKey, plaintext);
142
+ return {
143
+ counter,
144
+ ciphertext: ciphertext.toString('base64'),
145
+ nonce: nonce.toString('base64'),
146
+ };
147
+ }
148
+
149
+ decrypt(memberId, { counter, ciphertext, nonce }) {
150
+ const chain = this.#members.get(memberId);
151
+ if (!chain) {
152
+ return null;
153
+ }
154
+ const messageKey = chain.messageKeyFor(counter);
155
+ if (!messageKey) {
156
+ return null;
157
+ }
158
+ return groupDecrypt(
159
+ messageKey,
160
+ Buffer.from(ciphertext, 'base64'),
161
+ Buffer.from(nonce, 'base64'),
162
+ );
163
+ }
164
+
165
+ // The distribution message to hand a (new) member so they can decrypt you.
166
+ distribution() {
167
+ return this.#own.serialize();
168
+ }
169
+
170
+ addMember(memberId, distribution) {
171
+ const existing = this.#members.get(memberId);
172
+ if (existing) {
173
+ existing.destroy();
174
+ }
175
+ this.#members.set(memberId, SenderChain.deserialize(distribution));
176
+ }
177
+
178
+ removeMember(memberId) {
179
+ const chain = this.#members.get(memberId);
180
+ if (chain) {
181
+ chain.destroy();
182
+ this.#members.delete(memberId);
183
+ }
184
+ }
185
+
186
+ hasMember(memberId) {
187
+ return this.#members.has(memberId);
188
+ }
189
+
190
+ // Rotate your own chain (forward secrecy on membership change). Callers must
191
+ // redistribute the new distribution() to every member afterwards.
192
+ rotate() {
193
+ this.#own.destroy();
194
+ this.#own = new SenderChain();
195
+ }
196
+
197
+ destroy() {
198
+ this.#own.destroy();
199
+ for (const chain of this.#members.values()) {
200
+ chain.destroy();
201
+ }
202
+ this.#members.clear();
203
+ }
204
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import sodium from 'sodium-native';
4
+
5
+ const STATE_DIR = 'state';
6
+ const STATE_FILE = 'session-state.enc.json';
7
+
8
+ // Argon2id parameters. MODERATE is the current default for long-term key
9
+ // material at rest; INTERACTIVE is the legacy default used to open files
10
+ // written before the upgrade (their params are read from the envelope).
11
+ export const KDF_DEFAULT = {
12
+ opslimit: sodium.crypto_pwhash_OPSLIMIT_MODERATE,
13
+ memlimit: sodium.crypto_pwhash_MEMLIMIT_MODERATE,
14
+ };
15
+ export const KDF_LEGACY = {
16
+ opslimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
17
+ memlimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
18
+ };
19
+
20
+ /**
21
+ * Derive a 32-byte KEK from a passphrase using Argon2id.
22
+ * @param {string} passphrase
23
+ * @param {Buffer} [salt] - 16 bytes. If omitted, generates a new one.
24
+ * @param {number} [opslimit] - Argon2id ops limit (defaults to MODERATE).
25
+ * @param {number} [memlimit] - Argon2id mem limit (defaults to MODERATE).
26
+ * @returns {{ kek: Buffer, salt: Buffer, opslimit: number, memlimit: number }}
27
+ */
28
+ export function deriveKEK(
29
+ passphrase,
30
+ salt,
31
+ opslimit = KDF_DEFAULT.opslimit,
32
+ memlimit = KDF_DEFAULT.memlimit,
33
+ ) {
34
+ if (!salt) {
35
+ salt = Buffer.alloc(sodium.crypto_pwhash_SALTBYTES);
36
+ sodium.randombytes_buf(salt);
37
+ }
38
+ const kek = sodium.sodium_malloc(32);
39
+ sodium.crypto_pwhash(
40
+ kek,
41
+ Buffer.from(passphrase, 'utf-8'),
42
+ salt,
43
+ opslimit,
44
+ memlimit,
45
+ sodium.crypto_pwhash_ALG_ARGON2ID13,
46
+ );
47
+ return { kek, salt, opslimit, memlimit };
48
+ }
49
+
50
+ export class StateManager {
51
+ #stateDir;
52
+ #statePath;
53
+
54
+ constructor(baseDir = '.ciphermesh') {
55
+ this.#stateDir = join(baseDir, STATE_DIR);
56
+ if (!existsSync(this.#stateDir)) {
57
+ mkdirSync(this.#stateDir, { recursive: true });
58
+ }
59
+ this.#statePath = join(this.#stateDir, STATE_FILE);
60
+ }
61
+
62
+ deriveKEK(passphrase, salt) {
63
+ return deriveKEK(passphrase, salt);
64
+ }
65
+
66
+ /**
67
+ * Encrypt and save state to disk.
68
+ * @param {object} data - Plain object to serialize
69
+ * @param {Buffer} kek - 32-byte key encryption key
70
+ * @param {Buffer} salt - Salt used to derive KEK (stored alongside)
71
+ */
72
+ saveState(data, kek, salt, opslimit = KDF_DEFAULT.opslimit, memlimit = KDF_DEFAULT.memlimit) {
73
+ const plaintext = Buffer.from(JSON.stringify(data), 'utf-8');
74
+ const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
75
+ sodium.randombytes_buf(nonce);
76
+
77
+ const ciphertext = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES);
78
+ sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, kek);
79
+ sodium.sodium_memzero(plaintext);
80
+
81
+ const envelope = {
82
+ salt: salt.toString('base64'),
83
+ nonce: nonce.toString('base64'),
84
+ ciphertext: ciphertext.toString('base64'),
85
+ opslimit,
86
+ memlimit,
87
+ };
88
+ writeFileSync(this.#statePath, JSON.stringify(envelope), { encoding: 'utf-8', mode: 0o600 });
89
+ }
90
+
91
+ /**
92
+ * Load and decrypt state from disk.
93
+ * @param {string} passphrase - Used to re-derive the KEK
94
+ * @returns {object|null} Parsed state or null if file missing/corrupt/wrong passphrase
95
+ */
96
+ loadState(passphrase) {
97
+ if (!existsSync(this.#statePath)) {
98
+ return null;
99
+ }
100
+
101
+ try {
102
+ const envelope = JSON.parse(readFileSync(this.#statePath, 'utf-8'));
103
+ const salt = Buffer.from(envelope.salt, 'base64');
104
+ const nonce = Buffer.from(envelope.nonce, 'base64');
105
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
106
+
107
+ // Legacy files have no stored params → they were written with INTERACTIVE.
108
+ const opslimit = envelope.opslimit ?? KDF_LEGACY.opslimit;
109
+ const memlimit = envelope.memlimit ?? KDF_LEGACY.memlimit;
110
+ const { kek } = this.deriveKEK(passphrase, salt, opslimit, memlimit);
111
+ const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
112
+ const valid = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, kek);
113
+ sodium.sodium_memzero(kek);
114
+
115
+ if (!valid) {
116
+ sodium.sodium_memzero(plaintext);
117
+ return null;
118
+ }
119
+
120
+ const data = JSON.parse(plaintext.toString('utf-8'));
121
+ sodium.sodium_memzero(plaintext);
122
+ return data;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ hasState() {
129
+ return existsSync(this.#statePath);
130
+ }
131
+
132
+ clearState() {
133
+ if (existsSync(this.#statePath)) {
134
+ writeFileSync(this.#statePath, Buffer.alloc(256));
135
+ unlinkSync(this.#statePath);
136
+ }
137
+ }
138
+ }