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,219 @@
1
+ import sodium from 'sodium-native';
2
+ import { KEY_ROTATION_GRACE_MS } from '../shared/constants.js';
3
+ import { DoubleRatchet } from './DoubleRatchet.js';
4
+
5
+ export class Handshake {
6
+ #keyManager;
7
+ #peerKeys; // Map<sessionId, Buffer(publicKey)>
8
+ #previousPeerKeys; // Map<sessionId, { publicKey, timer }>
9
+ #ratchets; // Map<sessionId, DoubleRatchet>
10
+ #mySessionId;
11
+
12
+ constructor(keyManager) {
13
+ this.#keyManager = keyManager;
14
+ this.#peerKeys = new Map();
15
+ this.#previousPeerKeys = new Map();
16
+ this.#ratchets = new Map();
17
+ this.#mySessionId = null;
18
+ }
19
+
20
+ /**
21
+ * Set our session ID (called after JOIN_ACK).
22
+ * Also initializes ratchets for any already-registered peers.
23
+ */
24
+ setMySessionId(sessionId) {
25
+ this.#mySessionId = sessionId;
26
+
27
+ // Create ratchets for peers already registered
28
+ for (const [peerId, pubKey] of this.#peerKeys) {
29
+ if (!this.#ratchets.has(peerId)) {
30
+ this.#ratchets.set(
31
+ peerId,
32
+ new DoubleRatchet(this.#mySessionId, peerId, this.#keyManager.secretKey, pubKey),
33
+ );
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Register a peer's public key for future encryption/decryption.
40
+ */
41
+ registerPeer(peerId, peerPublicKey) {
42
+ const pubBuf = Buffer.isBuffer(peerPublicKey)
43
+ ? peerPublicKey
44
+ : Buffer.from(peerPublicKey, 'base64');
45
+
46
+ if (pubBuf.length !== sodium.crypto_box_PUBLICKEYBYTES) {
47
+ throw new Error(`Invalid public key size: ${pubBuf.length}`);
48
+ }
49
+
50
+ this.#peerKeys.set(peerId, Buffer.from(pubBuf));
51
+
52
+ // Create ratchet if we already have our session ID
53
+ if (this.#mySessionId && !this.#ratchets.has(peerId)) {
54
+ this.#ratchets.set(
55
+ peerId,
56
+ new DoubleRatchet(this.#mySessionId, peerId, this.#keyManager.secretKey, pubBuf),
57
+ );
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get the ratchet for a peer.
63
+ */
64
+ getRatchet(peerId) {
65
+ return this.#ratchets.get(peerId) || null;
66
+ }
67
+
68
+ /**
69
+ * Update a peer's public key (key rotation).
70
+ * Keeps the old key for a grace period.
71
+ */
72
+ updatePeerKey(peerId, newPublicKey) {
73
+ const newBuf = Buffer.isBuffer(newPublicKey)
74
+ ? newPublicKey
75
+ : Buffer.from(newPublicKey, 'base64');
76
+
77
+ if (newBuf.length !== sodium.crypto_box_PUBLICKEYBYTES) {
78
+ throw new Error(`Invalid public key size: ${newBuf.length}`);
79
+ }
80
+
81
+ const oldKey = this.#peerKeys.get(peerId);
82
+ if (oldKey) {
83
+ // Clear any existing grace timer for this peer
84
+ const existing = this.#previousPeerKeys.get(peerId);
85
+ if (existing) {
86
+ clearTimeout(existing.timer);
87
+ }
88
+
89
+ const timer = setTimeout(() => {
90
+ this.#previousPeerKeys.delete(peerId);
91
+ }, KEY_ROTATION_GRACE_MS);
92
+
93
+ this.#previousPeerKeys.set(peerId, { publicKey: oldKey, timer });
94
+ }
95
+
96
+ this.#peerKeys.set(peerId, Buffer.from(newBuf));
97
+ }
98
+
99
+ /**
100
+ * Get the peer's current public key.
101
+ */
102
+ getPeerPublicKey(peerId) {
103
+ return this.#peerKeys.get(peerId);
104
+ }
105
+
106
+ /**
107
+ * Get the peer's previous public key (during grace period).
108
+ */
109
+ getPreviousPeerPublicKey(peerId) {
110
+ const entry = this.#previousPeerKeys.get(peerId);
111
+ return entry?.publicKey || null;
112
+ }
113
+
114
+ /**
115
+ * Get our own secret key (for passing to MessageCrypto).
116
+ */
117
+ get secretKey() {
118
+ return this.#keyManager.secretKey;
119
+ }
120
+
121
+ /**
122
+ * Get our previous secret key (for decrypting in-flight msgs after rotation).
123
+ */
124
+ get previousSecretKey() {
125
+ return this.#keyManager.previousSecretKey;
126
+ }
127
+
128
+ /**
129
+ * Remove a peer.
130
+ */
131
+ removePeer(peerId) {
132
+ this.#peerKeys.delete(peerId);
133
+ const prev = this.#previousPeerKeys.get(peerId);
134
+ if (prev) {
135
+ clearTimeout(prev.timer);
136
+ this.#previousPeerKeys.delete(peerId);
137
+ }
138
+
139
+ const ratchet = this.#ratchets.get(peerId);
140
+ if (ratchet) {
141
+ ratchet.destroy();
142
+ this.#ratchets.delete(peerId);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Re-map a ratchet from old peer ID to new peer ID (e.g., after reconnect).
148
+ */
149
+ migrateRatchet(oldPeerId, newPeerId) {
150
+ const ratchet = this.#ratchets.get(oldPeerId);
151
+ if (ratchet) {
152
+ this.#ratchets.delete(oldPeerId);
153
+ this.#ratchets.set(newPeerId, ratchet);
154
+ }
155
+ const peerKey = this.#peerKeys.get(oldPeerId);
156
+ if (peerKey) {
157
+ this.#peerKeys.delete(oldPeerId);
158
+ this.#peerKeys.set(newPeerId, peerKey);
159
+ }
160
+ const prevKey = this.#previousPeerKeys.get(oldPeerId);
161
+ if (prevKey) {
162
+ this.#previousPeerKeys.delete(oldPeerId);
163
+ this.#previousPeerKeys.set(newPeerId, prevKey);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Serialize all ratchets + peer keys for encrypted persistence.
169
+ */
170
+ serializeState() {
171
+ const ratchets = {};
172
+ for (const [peerId, ratchet] of this.#ratchets) {
173
+ ratchets[peerId] = ratchet.serialize();
174
+ }
175
+
176
+ const peerKeys = {};
177
+ for (const [peerId, pubKey] of this.#peerKeys) {
178
+ peerKeys[peerId] = pubKey.toString('base64');
179
+ }
180
+
181
+ return {
182
+ mySessionId: this.#mySessionId,
183
+ ratchets,
184
+ peerKeys,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Restore ratchets + peer keys from persisted state.
190
+ */
191
+ restoreState(data) {
192
+ this.#mySessionId = data.mySessionId;
193
+
194
+ for (const [peerId, pubKeyB64] of Object.entries(data.peerKeys || {})) {
195
+ this.#peerKeys.set(peerId, Buffer.from(pubKeyB64, 'base64'));
196
+ }
197
+
198
+ for (const [peerId, ratchetData] of Object.entries(data.ratchets || {})) {
199
+ this.#ratchets.set(peerId, DoubleRatchet.deserialize(ratchetData));
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Destroy all state.
205
+ */
206
+ destroy() {
207
+ this.#peerKeys.clear();
208
+ for (const [, entry] of this.#previousPeerKeys) {
209
+ clearTimeout(entry.timer);
210
+ }
211
+ this.#previousPeerKeys.clear();
212
+
213
+ for (const [, ratchet] of this.#ratchets) {
214
+ ratchet.destroy();
215
+ }
216
+ this.#ratchets.clear();
217
+ this.#mySessionId = null;
218
+ }
219
+ }
@@ -0,0 +1,241 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import sodium from 'sodium-native';
4
+ import { deriveKEK, KDF_LEGACY } from './StateManager.js';
5
+
6
+ const HISTORY_DIR = 'history';
7
+ const HISTORY_FILE = 'history.enc.json';
8
+ const MAX_ENTRIES = 5000;
9
+ const FLUSH_DELAY_MS = 3000;
10
+
11
+ /**
12
+ * Opt-in encrypted local message history.
13
+ * Whole file is an envelope { salt, nonce, ciphertext } — same scheme as
14
+ * StateManager (Argon2id KEK + XSalsa20-Poly1305). Only active when the
15
+ * user provides a passphrase at startup.
16
+ */
17
+ export class HistoryStore {
18
+ #path;
19
+ #kek;
20
+ #salt;
21
+ #entries;
22
+ #flushTimer;
23
+ #open;
24
+ #opslimit;
25
+ #memlimit;
26
+
27
+ constructor(baseDir = '.ciphermesh') {
28
+ const dir = join(baseDir, HISTORY_DIR);
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true });
31
+ }
32
+ this.#path = join(dir, HISTORY_FILE);
33
+ this.#kek = null;
34
+ this.#salt = null;
35
+ this.#entries = [];
36
+ this.#flushTimer = null;
37
+ this.#open = false;
38
+ this.#opslimit = null;
39
+ this.#memlimit = null;
40
+ }
41
+
42
+ /**
43
+ * Derive the KEK and decrypt existing history (if any).
44
+ * @returns {boolean} false when the passphrase does not match the file
45
+ */
46
+ open(passphrase) {
47
+ if (!existsSync(this.#path)) {
48
+ const derived = deriveKEK(passphrase);
49
+ this.#kek = derived.kek;
50
+ this.#salt = derived.salt;
51
+ this.#opslimit = derived.opslimit;
52
+ this.#memlimit = derived.memlimit;
53
+ this.#open = true;
54
+ return true;
55
+ }
56
+
57
+ try {
58
+ const envelope = JSON.parse(readFileSync(this.#path, 'utf-8'));
59
+ const salt = Buffer.from(envelope.salt, 'base64');
60
+ const nonce = Buffer.from(envelope.nonce, 'base64');
61
+ const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
62
+
63
+ // Legacy files have no stored params → written with INTERACTIVE.
64
+ const opslimit = envelope.opslimit ?? KDF_LEGACY.opslimit;
65
+ const memlimit = envelope.memlimit ?? KDF_LEGACY.memlimit;
66
+ const { kek } = deriveKEK(passphrase, salt, opslimit, memlimit);
67
+ const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
68
+ const valid = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, kek);
69
+ if (!valid) {
70
+ sodium.sodium_memzero(kek);
71
+ sodium.sodium_memzero(plaintext);
72
+ return false;
73
+ }
74
+
75
+ this.#entries = JSON.parse(plaintext.toString('utf-8'));
76
+ sodium.sodium_memzero(plaintext);
77
+ this.#kek = kek;
78
+ this.#salt = salt;
79
+ this.#opslimit = opslimit;
80
+ this.#memlimit = memlimit;
81
+ this.#open = true;
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ get isOpen() {
89
+ return this.#open;
90
+ }
91
+
92
+ get size() {
93
+ return this.#entries.length;
94
+ }
95
+
96
+ append({ room, nickname, text, isDM = false }) {
97
+ if (!this.#open) {
98
+ return;
99
+ }
100
+ this.#entries.push({ ts: Date.now(), room, nickname, text, isDM });
101
+ if (this.#entries.length > MAX_ENTRIES) {
102
+ this.#entries.splice(0, this.#entries.length - MAX_ENTRIES);
103
+ }
104
+ this.#scheduleFlush();
105
+ }
106
+
107
+ search(term, limit = 50) {
108
+ if (!this.#open || !term) {
109
+ return [];
110
+ }
111
+ const needle = term.toLowerCase();
112
+ const found = this.#entries.filter(
113
+ (e) => e.text.toLowerCase().includes(needle) || e.nickname.toLowerCase().includes(needle),
114
+ );
115
+ return found.slice(-limit);
116
+ }
117
+
118
+ recent(count = 20) {
119
+ if (!this.#open) {
120
+ return [];
121
+ }
122
+ return this.#entries.slice(-count);
123
+ }
124
+
125
+ /**
126
+ * Drop history entries older than maxAgeMs and persist. Returns how many
127
+ * were removed. Used by the /retention command for a disk-retention policy.
128
+ */
129
+ purgeOlderThan(maxAgeMs) {
130
+ if (!this.#open) {
131
+ return 0;
132
+ }
133
+ const cutoff = Date.now() - maxAgeMs;
134
+ const before = this.#entries.length;
135
+ this.#entries = this.#entries.filter((e) => e.ts >= cutoff);
136
+ const removed = before - this.#entries.length;
137
+ if (removed > 0) {
138
+ this.flush();
139
+ }
140
+ return removed;
141
+ }
142
+
143
+ /**
144
+ * Export history as PLAINTEXT — .json when the path ends with .json,
145
+ * otherwise a human-readable .txt.
146
+ * @returns {number} entries written
147
+ */
148
+ exportTo(filePath) {
149
+ if (!this.#open) {
150
+ return 0;
151
+ }
152
+ let out;
153
+ if (filePath.toLowerCase().endsWith('.json')) {
154
+ out = JSON.stringify(this.#entries, null, 2);
155
+ } else {
156
+ out =
157
+ this.#entries
158
+ .map((e) => {
159
+ const when = new Date(e.ts).toLocaleString('en-US', {
160
+ day: '2-digit',
161
+ month: '2-digit',
162
+ year: 'numeric',
163
+ hour: '2-digit',
164
+ minute: '2-digit',
165
+ });
166
+ const dm = e.isDM ? ' (DM)' : '';
167
+ return `[${when}] [#${e.room}]${dm} ${e.nickname}: ${e.text}`;
168
+ })
169
+ .join('\n') + '\n';
170
+ }
171
+ writeFileSync(filePath, out, 'utf-8');
172
+ return this.#entries.length;
173
+ }
174
+
175
+ #scheduleFlush() {
176
+ if (this.#flushTimer) {
177
+ return;
178
+ }
179
+ this.#flushTimer = setTimeout(() => {
180
+ this.#flushTimer = null;
181
+ this.flush();
182
+ }, FLUSH_DELAY_MS);
183
+ }
184
+
185
+ flush() {
186
+ if (!this.#open) {
187
+ return;
188
+ }
189
+ const plaintext = Buffer.from(JSON.stringify(this.#entries), 'utf-8');
190
+ const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
191
+ sodium.randombytes_buf(nonce);
192
+ const ciphertext = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES);
193
+ sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, this.#kek);
194
+ sodium.sodium_memzero(plaintext);
195
+
196
+ const envelope = {
197
+ salt: this.#salt.toString('base64'),
198
+ nonce: nonce.toString('base64'),
199
+ ciphertext: ciphertext.toString('base64'),
200
+ opslimit: this.#opslimit,
201
+ memlimit: this.#memlimit,
202
+ };
203
+ writeFileSync(this.#path, JSON.stringify(envelope), { encoding: 'utf-8', mode: 0o600 });
204
+ }
205
+
206
+ destroy() {
207
+ if (this.#flushTimer) {
208
+ clearTimeout(this.#flushTimer);
209
+ this.#flushTimer = null;
210
+ }
211
+ if (this.#open) {
212
+ this.flush();
213
+ sodium.sodium_memzero(this.#kek);
214
+ this.#kek = null;
215
+ this.#open = false;
216
+ }
217
+ }
218
+
219
+ // Securely delete the encrypted history from disk (panic / duress). Overwrites
220
+ // before unlinking (best effort) and drops the in-memory key.
221
+ wipe() {
222
+ if (this.#flushTimer) {
223
+ clearTimeout(this.#flushTimer);
224
+ this.#flushTimer = null;
225
+ }
226
+ this.#entries = [];
227
+ if (this.#kek) {
228
+ sodium.sodium_memzero(this.#kek);
229
+ this.#kek = null;
230
+ }
231
+ this.#open = false;
232
+ try {
233
+ if (existsSync(this.#path)) {
234
+ writeFileSync(this.#path, Buffer.alloc(Math.max(256, statSync(this.#path).size)));
235
+ unlinkSync(this.#path);
236
+ }
237
+ } catch {
238
+ /* best effort */
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,70 @@
1
+ import sodium from 'sodium-native';
2
+ import { deriveKEK, KDF_LEGACY } from './StateManager.js';
3
+
4
+ const BACKUP_VERSION = 1;
5
+
6
+ /**
7
+ * Encrypt an identity+trust backup with a passphrase (Argon2id KEK +
8
+ * XSalsa20-Poly1305), returning a self-describing JSON envelope string.
9
+ * The envelope stores the KDF params so it is portable and future-proof.
10
+ *
11
+ * @param {object} data - e.g. { identity, trust }
12
+ * @param {string} passphrase
13
+ * @returns {string} JSON envelope
14
+ */
15
+ export function exportBackup(data, passphrase) {
16
+ const { kek, salt, opslimit, memlimit } = deriveKEK(passphrase);
17
+ const plaintext = Buffer.from(JSON.stringify({ version: BACKUP_VERSION, ...data }), 'utf-8');
18
+ const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
19
+ sodium.randombytes_buf(nonce);
20
+
21
+ const ciphertext = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES);
22
+ sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, kek);
23
+ sodium.sodium_memzero(plaintext);
24
+ sodium.sodium_memzero(kek);
25
+
26
+ return JSON.stringify({
27
+ kind: 'ciphermesh-backup',
28
+ salt: salt.toString('base64'),
29
+ nonce: nonce.toString('base64'),
30
+ ciphertext: ciphertext.toString('base64'),
31
+ opslimit,
32
+ memlimit,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Decrypt a backup envelope. Returns the data object or null on wrong
38
+ * passphrase / corruption.
39
+ * @param {string} raw - the JSON envelope
40
+ * @param {string} passphrase
41
+ * @returns {object|null}
42
+ */
43
+ export function importBackup(raw, passphrase) {
44
+ try {
45
+ const env = JSON.parse(raw);
46
+ if (env.kind !== 'ciphermesh-backup') {
47
+ return null;
48
+ }
49
+ const salt = Buffer.from(env.salt, 'base64');
50
+ const nonce = Buffer.from(env.nonce, 'base64');
51
+ const ciphertext = Buffer.from(env.ciphertext, 'base64');
52
+ const opslimit = env.opslimit ?? KDF_LEGACY.opslimit;
53
+ const memlimit = env.memlimit ?? KDF_LEGACY.memlimit;
54
+
55
+ const { kek } = deriveKEK(passphrase, salt, opslimit, memlimit);
56
+ const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
57
+ const ok = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, kek);
58
+ sodium.sodium_memzero(kek);
59
+
60
+ if (!ok) {
61
+ sodium.sodium_memzero(plaintext);
62
+ return null;
63
+ }
64
+ const data = JSON.parse(plaintext.toString('utf-8'));
65
+ sodium.sodium_memzero(plaintext);
66
+ return data;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
@@ -0,0 +1,134 @@
1
+ // @ts-nocheck
2
+ import sodium from 'sodium-native';
3
+ import { createHash } from 'node:crypto';
4
+ import { KEY_ROTATION_GRACE_MS } from '../shared/constants.js';
5
+
6
+ export class KeyManager {
7
+ #publicKey;
8
+ #secretKey;
9
+ #fingerprint;
10
+ #previousPublicKey;
11
+ #previousSecretKey;
12
+ #graceTimer;
13
+
14
+ constructor() {
15
+ this.#publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
16
+ this.#secretKey = sodium.sodium_malloc(sodium.crypto_box_SECRETKEYBYTES);
17
+ this.#previousPublicKey = null;
18
+ this.#previousSecretKey = null;
19
+ this.#graceTimer = null;
20
+
21
+ sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
22
+
23
+ this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
24
+ }
25
+
26
+ get publicKey() {
27
+ return this.#publicKey;
28
+ }
29
+
30
+ get secretKey() {
31
+ return this.#secretKey;
32
+ }
33
+
34
+ get publicKeyB64() {
35
+ return this.#publicKey.toString('base64');
36
+ }
37
+
38
+ get fingerprint() {
39
+ return this.#fingerprint;
40
+ }
41
+
42
+ get previousPublicKey() {
43
+ return this.#previousPublicKey;
44
+ }
45
+
46
+ get previousSecretKey() {
47
+ return this.#previousSecretKey;
48
+ }
49
+
50
+ /**
51
+ * Generate a new keypair, keeping the old one for a grace period.
52
+ */
53
+ rotate() {
54
+ // Clear any existing grace timer
55
+ if (this.#graceTimer) {
56
+ clearTimeout(this.#graceTimer);
57
+ this.destroyPrevious();
58
+ }
59
+
60
+ // Move current keys to previous
61
+ this.#previousPublicKey = this.#publicKey;
62
+ this.#previousSecretKey = this.#secretKey;
63
+
64
+ // Generate new keypair
65
+ this.#publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
66
+ this.#secretKey = sodium.sodium_malloc(sodium.crypto_box_SECRETKEYBYTES);
67
+ sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
68
+
69
+ this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
70
+
71
+ // Auto-destroy previous keys after grace period
72
+ this.#graceTimer = setTimeout(() => {
73
+ this.destroyPrevious();
74
+ this.#graceTimer = null;
75
+ }, KEY_ROTATION_GRACE_MS);
76
+ }
77
+
78
+ destroyPrevious() {
79
+ if (this.#previousSecretKey) {
80
+ sodium.sodium_memzero(this.#previousSecretKey);
81
+ }
82
+ this.#previousSecretKey = null;
83
+ this.#previousPublicKey = null;
84
+ }
85
+
86
+ static computeFingerprint(publicKey) {
87
+ const hash = createHash('sha256').update(publicKey).digest();
88
+ const parts = [];
89
+ for (let i = 0; i < 8; i += 2) {
90
+ parts.push(
91
+ hash
92
+ .subarray(i, i + 2)
93
+ .toString('hex')
94
+ .toUpperCase(),
95
+ );
96
+ }
97
+ return parts.join(':');
98
+ }
99
+
100
+ destroy() {
101
+ if (this.#graceTimer) {
102
+ clearTimeout(this.#graceTimer);
103
+ }
104
+ this.destroyPrevious();
105
+ if (this.#secretKey) {
106
+ sodium.sodium_memzero(this.#secretKey);
107
+ }
108
+ this.#secretKey = null;
109
+ this.#publicKey = null;
110
+ this.#fingerprint = null;
111
+ }
112
+
113
+ serialize() {
114
+ return {
115
+ publicKey: this.#publicKey.toString('base64'),
116
+ secretKey: this.#secretKey.toString('base64'),
117
+ };
118
+ }
119
+
120
+ static deserialize(data) {
121
+ const km = new KeyManager(); // generates throwaway keys
122
+ sodium.sodium_memzero(km.#secretKey); // wipe throwaway
123
+
124
+ km.#publicKey = Buffer.from(data.publicKey, 'base64');
125
+ const tempSec = Buffer.from(data.secretKey, 'base64');
126
+ km.#secretKey = sodium.sodium_malloc(sodium.crypto_box_SECRETKEYBYTES);
127
+ tempSec.copy(km.#secretKey);
128
+ sodium.sodium_memzero(tempSec);
129
+ km.#fingerprint = KeyManager.computeFingerprint(km.#publicKey);
130
+ km.#previousPublicKey = null;
131
+ km.#previousSecretKey = null;
132
+ return km;
133
+ }
134
+ }