taskover-mcp 1.1.0 → 1.2.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/crypto.js ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * S6A-1: Centralized Encryption Module
3
+ *
4
+ * All cryptographic primitives for TaskOver at-rest encryption.
5
+ * KEK (Key Encryption Key) derived from TASKOVER_MASTER_KEY env var.
6
+ * Per-user DEKs (Data Encryption Keys) wrapped with KEK.
7
+ *
8
+ * Ciphertext format: "v1:<iv_hex>:<ciphertext_hex>:<authTag_hex>"
9
+ */
10
+
11
+ const crypto = require("crypto");
12
+
13
+ // ===== STATE =====
14
+
15
+ let _kek = null; // Key Encryption Key (32 bytes from master key)
16
+ let _hmacKey = null; // HMAC key derived via HKDF from KEK
17
+ let _initialized = false;
18
+
19
+ // DEK cache: userId -> { dek: Buffer, expires: number }
20
+ const _dekCache = new Map();
21
+ const DEK_CACHE_MAX = 100;
22
+ const DEK_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
23
+
24
+ // ===== ARGON2 (optional — falls back to scrypt) =====
25
+
26
+ let argon2 = null;
27
+ try {
28
+ argon2 = require("@node-rs/argon2");
29
+ } catch (_) {
30
+ // Argon2 unavailable (Electron / dev), will use scrypt
31
+ }
32
+
33
+ // ===== INITIALIZATION =====
34
+
35
+ function initCrypto(masterKeyHex) {
36
+ if (!masterKeyHex || typeof masterKeyHex !== "string") {
37
+ throw new Error("TASKOVER_MASTER_KEY must be a non-empty string");
38
+ }
39
+ const keyBuf = Buffer.from(masterKeyHex, "hex");
40
+ if (keyBuf.length !== 32) {
41
+ throw new Error("TASKOVER_MASTER_KEY must be exactly 64 hex characters (32 bytes)");
42
+ }
43
+ _kek = keyBuf;
44
+ // Derive independent HMAC key via HKDF
45
+ _hmacKey = crypto.hkdfSync("sha256", _kek, Buffer.alloc(0), Buffer.from("taskover-hmac-v1"), 32);
46
+ _initialized = true;
47
+ }
48
+
49
+ function isCryptoInitialized() {
50
+ return _initialized;
51
+ }
52
+
53
+ // ===== AES-256-GCM ENCRYPTION =====
54
+
55
+ function encryptWithKey(plaintext, key) {
56
+ if (!plaintext && plaintext !== "") return null;
57
+ const iv = crypto.randomBytes(12); // 96-bit IV for GCM
58
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
59
+ const plaintextBuf = Buffer.from(String(plaintext), "utf8");
60
+ const encrypted = Buffer.concat([cipher.update(plaintextBuf), cipher.final()]);
61
+ const authTag = cipher.getAuthTag(); // 16 bytes
62
+ return "v1:" + iv.toString("hex") + ":" + encrypted.toString("hex") + ":" + authTag.toString("hex");
63
+ }
64
+
65
+ function decryptWithKey(ciphertext, key) {
66
+ if (!ciphertext) return null;
67
+ const str = String(ciphertext);
68
+
69
+ // v1: GCM format
70
+ if (str.startsWith("v1:")) {
71
+ const parts = str.split(":");
72
+ if (parts.length !== 4) throw new Error("Invalid v1 ciphertext format");
73
+ const iv = Buffer.from(parts[1], "hex");
74
+ const encrypted = Buffer.from(parts[2], "hex");
75
+ const authTag = Buffer.from(parts[3], "hex");
76
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
77
+ decipher.setAuthTag(authTag);
78
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
79
+ return decrypted.toString("utf8");
80
+ }
81
+
82
+ // Legacy CBC fallback (iv_hex:ciphertext_hex, no auth tag)
83
+ const [ivHex, encHex] = str.split(":");
84
+ if (!ivHex || !encHex) throw new Error("Invalid legacy ciphertext format");
85
+ const iv = Buffer.from(ivHex, "hex");
86
+ const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
87
+ let decrypted = decipher.update(encHex, "hex", "utf8");
88
+ decrypted += decipher.final("utf8");
89
+ return decrypted;
90
+ }
91
+
92
+ // ===== DEK MANAGEMENT =====
93
+
94
+ function generateDEK() {
95
+ if (!_initialized) throw new Error("Crypto not initialized");
96
+ const dek = crypto.randomBytes(32);
97
+ const encryptedDek = encryptWithKey(dek.toString("hex"), _kek);
98
+ return { dek, encryptedDek };
99
+ }
100
+
101
+ function decryptDEK(encryptedDek) {
102
+ if (!_initialized) throw new Error("Crypto not initialized");
103
+ if (!encryptedDek) return null;
104
+ const dekHex = decryptWithKey(encryptedDek, _kek);
105
+ return Buffer.from(dekHex, "hex");
106
+ }
107
+
108
+ function reEncryptDEK(encryptedDek, newKEK) {
109
+ // Decrypt with current KEK, re-encrypt with new KEK
110
+ const dekHex = decryptWithKey(encryptedDek, _kek);
111
+ return encryptWithKey(dekHex, newKEK);
112
+ }
113
+
114
+ // ===== FIELD-LEVEL ENCRYPTION =====
115
+
116
+ function encryptField(plaintext, dek) {
117
+ if (plaintext == null || plaintext === "") return plaintext;
118
+ if (!dek) return plaintext;
119
+ return encryptWithKey(String(plaintext), dek);
120
+ }
121
+
122
+ function decryptField(ciphertext, dek) {
123
+ if (ciphertext == null || ciphertext === "") return ciphertext;
124
+ if (!dek) return ciphertext;
125
+ const str = String(ciphertext);
126
+ // Only decrypt if it has the v1: prefix (migration window: plaintext passes through)
127
+ if (!str.startsWith("v1:")) return ciphertext;
128
+ return decryptWithKey(str, dek);
129
+ }
130
+
131
+ // ===== BLIND INDEX (HMAC) =====
132
+
133
+ function blindIndex(value) {
134
+ if (!_initialized) throw new Error("Crypto not initialized");
135
+ if (!value) return null;
136
+ return crypto.createHmac("sha256", _hmacKey).update(String(value)).digest("hex");
137
+ }
138
+
139
+ // ===== PASSWORD HASHING =====
140
+
141
+ // OWASP recommended Argon2id parameters
142
+ const ARGON2_OPTS = {
143
+ memoryCost: 19456, // 19 MiB
144
+ timeCost: 2,
145
+ parallelism: 1,
146
+ outputLen: 64,
147
+ };
148
+
149
+ function hashPasswordSync(password) {
150
+ const salt = crypto.randomBytes(16).toString("hex");
151
+
152
+ if (argon2) {
153
+ try {
154
+ const hash = argon2.hashSync(password, {
155
+ memoryCost: ARGON2_OPTS.memoryCost,
156
+ timeCost: ARGON2_OPTS.timeCost,
157
+ parallelism: ARGON2_OPTS.parallelism,
158
+ outputLen: ARGON2_OPTS.outputLen,
159
+ salt: Buffer.from(salt, "hex"),
160
+ });
161
+ return { hash, salt, algorithm: "argon2id" };
162
+ } catch (_) {
163
+ // Fall through to scrypt
164
+ }
165
+ }
166
+
167
+ // Fallback: scrypt
168
+ const hash = crypto.scryptSync(password, salt, 64).toString("hex");
169
+ return { hash, salt, algorithm: "scrypt" };
170
+ }
171
+
172
+ function verifyPasswordSync(password, storedHash, salt, algorithm) {
173
+ if (!password || !storedHash || !salt) return false;
174
+
175
+ if (algorithm === "argon2id" && argon2) {
176
+ try {
177
+ return argon2.verifySync(storedHash, password);
178
+ } catch (_) {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ if (algorithm === "scrypt" || !algorithm) {
184
+ const hash = crypto.scryptSync(password, salt, 64).toString("hex");
185
+ return safeCompare(hash, storedHash);
186
+ }
187
+
188
+ if (algorithm === "sha256") {
189
+ const hash = crypto.createHash("sha256").update(password + salt).digest("hex");
190
+ return safeCompare(hash, storedHash);
191
+ }
192
+
193
+ // Unknown algorithm — try scrypt then sha256
194
+ const scryptHash = crypto.scryptSync(password, salt, 64).toString("hex");
195
+ if (safeCompare(scryptHash, storedHash)) return true;
196
+ const sha256Hash = crypto.createHash("sha256").update(password + salt).digest("hex");
197
+ return safeCompare(sha256Hash, storedHash);
198
+ }
199
+
200
+ function passwordNeedsUpgrade(algorithm) {
201
+ if (!algorithm || algorithm === "sha256" || algorithm === "scrypt") {
202
+ return argon2 ? "argon2id" : (algorithm === "sha256" ? "scrypt" : null);
203
+ }
204
+ return null; // argon2id is current best
205
+ }
206
+
207
+ // ===== BACKUP CODES =====
208
+
209
+ function hashBackupCode(code) {
210
+ const salt = crypto.randomBytes(16).toString("hex");
211
+ const hash = crypto.createHash("sha256").update(code + salt).digest("hex");
212
+ return { hash, salt };
213
+ }
214
+
215
+ function verifyBackupCode(code, storedHash, salt) {
216
+ const hash = crypto.createHash("sha256").update(code + salt).digest("hex");
217
+ return safeCompare(hash, storedHash);
218
+ }
219
+
220
+ function verifyBackupCodeLegacy(code, storedHash) {
221
+ const hash = crypto.createHash("sha256").update(code).digest("hex");
222
+ return safeCompare(hash, storedHash);
223
+ }
224
+
225
+ // ===== BACKUP FILE ENCRYPTION =====
226
+
227
+ const BACKUP_MAGIC = Buffer.from("TOBE"); // TaskOver Backup Encrypted
228
+ const BACKUP_VERSION = 1;
229
+
230
+ function encryptBackup(inputPath, outputPath) {
231
+ if (!_initialized) throw new Error("Crypto not initialized");
232
+ const fs = require("fs");
233
+ const plaintext = fs.readFileSync(inputPath);
234
+
235
+ // Random file key, wrapped with KEK
236
+ const fileKey = crypto.randomBytes(32);
237
+ const wrappedKey = encryptWithKey(fileKey.toString("hex"), _kek);
238
+ const wrappedKeyBuf = Buffer.from(wrappedKey, "utf8");
239
+
240
+ // Encrypt file content
241
+ const iv = crypto.randomBytes(12);
242
+ const cipher = crypto.createCipheriv("aes-256-gcm", fileKey, iv);
243
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
244
+ const authTag = cipher.getAuthTag();
245
+
246
+ // Header: TOBE + version(1) + wrappedKeyLen(2) + wrappedKey + iv(12) + authTag(16) + ciphertext
247
+ const header = Buffer.alloc(4 + 1 + 2);
248
+ BACKUP_MAGIC.copy(header, 0);
249
+ header.writeUInt8(BACKUP_VERSION, 4);
250
+ header.writeUInt16BE(wrappedKeyBuf.length, 5);
251
+
252
+ fs.writeFileSync(outputPath, Buffer.concat([header, wrappedKeyBuf, iv, authTag, encrypted]));
253
+ }
254
+
255
+ function decryptBackup(inputPath, outputPath) {
256
+ if (!_initialized) throw new Error("Crypto not initialized");
257
+ const fs = require("fs");
258
+ const data = fs.readFileSync(inputPath);
259
+
260
+ // Minimum: 4 magic + 1 version + 2 keyLen + 1 wrappedKey + 12 iv + 16 authTag + 1 ciphertext = 37
261
+ if (data.length < 37) throw new Error("Backup file too small — truncated or corrupt");
262
+
263
+ // Parse header
264
+ const magic = data.subarray(0, 4);
265
+ if (!magic.equals(BACKUP_MAGIC)) throw new Error("Not a TaskOver encrypted backup");
266
+ const version = data.readUInt8(4);
267
+ if (version !== BACKUP_VERSION) throw new Error(`Unsupported backup version: ${version}`);
268
+
269
+ const wrappedKeyLen = data.readUInt16BE(5);
270
+ if (wrappedKeyLen === 0 || 7 + wrappedKeyLen + 12 + 16 > data.length) {
271
+ throw new Error(`Invalid wrapped key length: ${wrappedKeyLen} (file is ${data.length} bytes)`);
272
+ }
273
+ let offset = 7;
274
+ const wrappedKey = data.subarray(offset, offset + wrappedKeyLen).toString("utf8");
275
+ offset += wrappedKeyLen;
276
+
277
+ const iv = data.subarray(offset, offset + 12);
278
+ offset += 12;
279
+ const authTag = data.subarray(offset, offset + 16);
280
+ offset += 16;
281
+ const encrypted = data.subarray(offset);
282
+
283
+ // Unwrap file key
284
+ const fileKeyHex = decryptWithKey(wrappedKey, _kek);
285
+ const fileKey = Buffer.from(fileKeyHex, "hex");
286
+
287
+ // Decrypt
288
+ const decipher = crypto.createDecipheriv("aes-256-gcm", fileKey, iv);
289
+ decipher.setAuthTag(authTag);
290
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
291
+
292
+ fs.writeFileSync(outputPath, decrypted);
293
+ }
294
+
295
+ // ===== DEK CACHE =====
296
+
297
+ function getCachedDEK(userId, encryptedDek) {
298
+ if (!encryptedDek) return null;
299
+
300
+ const cached = _dekCache.get(userId);
301
+ if (cached && cached.expires > Date.now() && cached.encryptedDek === encryptedDek) {
302
+ return cached.dek;
303
+ }
304
+
305
+ const dek = decryptDEK(encryptedDek);
306
+ if (!dek) return null;
307
+
308
+ // Evict oldest if at capacity
309
+ if (_dekCache.size >= DEK_CACHE_MAX) {
310
+ const oldestKey = _dekCache.keys().next().value;
311
+ _dekCache.delete(oldestKey);
312
+ }
313
+
314
+ _dekCache.set(userId, { dek, encryptedDek, expires: Date.now() + DEK_CACHE_TTL });
315
+ return dek;
316
+ }
317
+
318
+ function clearDEKCache(userId) {
319
+ if (userId) {
320
+ _dekCache.delete(userId);
321
+ } else {
322
+ _dekCache.clear();
323
+ }
324
+ }
325
+
326
+ // ===== LOGGING GUARD =====
327
+
328
+ const SENSITIVE_PATTERNS = /^(password|secret|token|key|salt|hash|mfa|totp|dek|kek|authorization|cookie|credential|api_key|apikey)/i;
329
+
330
+ function redactForLogging(obj) {
331
+ if (!obj || typeof obj !== "object") return obj;
332
+ if (Array.isArray(obj)) return obj.map(redactForLogging);
333
+
334
+ const result = {};
335
+ for (const [k, v] of Object.entries(obj)) {
336
+ if (SENSITIVE_PATTERNS.test(k)) {
337
+ result[k] = "[REDACTED]";
338
+ } else if (typeof v === "object" && v !== null) {
339
+ result[k] = redactForLogging(v);
340
+ } else {
341
+ result[k] = v;
342
+ }
343
+ }
344
+ return result;
345
+ }
346
+
347
+ // ===== HELPERS =====
348
+
349
+ function safeCompare(a, b) {
350
+ if (typeof a !== "string" || typeof b !== "string") return false;
351
+ const bufA = Buffer.from(a);
352
+ const bufB = Buffer.from(b);
353
+ if (bufA.length !== bufB.length) {
354
+ // Compare against self to keep constant time, then return false
355
+ crypto.timingSafeEqual(bufA, bufA);
356
+ return false;
357
+ }
358
+ return crypto.timingSafeEqual(bufA, bufB);
359
+ }
360
+
361
+ // ===== EXPORTS =====
362
+
363
+ module.exports = {
364
+ initCrypto,
365
+ isCryptoInitialized,
366
+ encryptWithKey,
367
+ decryptWithKey,
368
+ generateDEK,
369
+ decryptDEK,
370
+ reEncryptDEK,
371
+ encryptField,
372
+ decryptField,
373
+ blindIndex,
374
+ hashPasswordSync,
375
+ verifyPasswordSync,
376
+ passwordNeedsUpgrade,
377
+ hashBackupCode,
378
+ verifyBackupCode,
379
+ verifyBackupCodeLegacy,
380
+ encryptBackup,
381
+ decryptBackup,
382
+ getCachedDEK,
383
+ clearDEKCache,
384
+ redactForLogging,
385
+ safeCompare,
386
+ };