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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- 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
|
+
}
|