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/auth-flow.js +6 -53
- package/auth-gate.js +253 -0
- package/cloud-adapter.js +38 -15
- package/crypto.js +386 -0
- package/data-store.js +9352 -0
- package/data-store.json-backup.js +1264 -0
- package/db.js +2292 -0
- package/image-moderator.js +491 -0
- package/image-processor.js +160 -0
- package/image-upload-service.js +398 -0
- package/index.js +2294 -1433
- package/migrate-json-to-sqlite.js +256 -0
- package/package.json +29 -21
- package/publish/auth-flow.js +275 -0
- package/publish/cloud-adapter.js +246 -0
- package/publish/credential-store.js +93 -0
- package/publish/index.js +1433 -0
- package/publish/package.json +21 -0
- package/publish/tool-map.js +1146 -0
- package/scripts/build-publish.sh +95 -0
- package/scripts/test-auth-failure.js +68 -0
- package/scripts/test-success.js +232 -0
- package/scripts/test-validation.js +105 -0
- package/tool-map.js +58 -0
- /package/{README.md → publish/README.md} +0 -0
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
|
+
};
|