'use strict'; const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const MAX_FILE = 8 * 1024 * 1024; const SCRYPT = Object.freeze({ N: 131072, r: 8, p: 1, maxmem: 192 * 1024 * 1024 }); const FORMAT = 'safetynet-local-vault-v1'; const fail = () => { throw new Error('Vault operation refused'); }; const wipe = value => { if (Buffer.isBuffer(value)) value.fill(0); }; const encode = value => value.toString('base64'); function decode(value, maximum = MAX_FILE) { if (typeof value !== 'string' || value.length > Math.ceil(maximum / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) fail(); const result = Buffer.from(value, 'base64'); if (result.length > maximum || encode(result) !== value) fail(); return result; } function seal(key, bytes, aad) { const nonce = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce); cipher.setAAD(Buffer.from(aad)); return { nonce: encode(nonce), ciphertext: encode(Buffer.concat([cipher.update(bytes), cipher.final()])), tag: encode(cipher.getAuthTag()) }; } function open(key, record, aad) { if (!record || typeof record !== 'object') fail(); const nonce = decode(record.nonce, 12), tag = decode(record.tag, 16); if (nonce.length !== 12 || tag.length !== 16) fail(); const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce); decipher.setAAD(Buffer.from(aad)); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(decode(record.ciphertext)), decipher.final()]); } function origin(value) { if (typeof value !== 'string' || value.length > 2048) fail(); let url; try { url = new URL(value); } catch { fail(); } if (url.protocol !== 'https:' || url.username || url.password || url.origin !== value) fail(); return url.origin; } function text(value, maximum) { if (typeof value !== 'string' || value.length > maximum || value.includes('\0')) fail(); return value; } function master(value) { return text(value, 1024).length >= 16 ? value : fail(); } function wrappingKey(shared, vaultId) { return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.from(vaultId), Buffer.from(FORMAT + ':kem-wrap'), 32)); } function generatePassword(length = 32) { if (!Number.isInteger(length) || length < 24 || length > 128) fail(); const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'; return Array.from({ length }, () => alphabet[crypto.randomInt(alphabet.length)]).join(''); } function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) { if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail(); let key = null, entries = [], envelope = null, timer = null; function lock() { clearTimeout(timer); timer = null; wipe(key); key = null; for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; } entries = []; envelope = null; } function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); } function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); } function checkedFile() { const stat = fs.lstatSync(filePath); if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1 || stat.size > MAX_FILE) fail(); const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); const bytes = Buffer.alloc(MAX_FILE + 1); try { if (fs.fstatSync(fd).size > MAX_FILE) fail(); let length = 0, count; while (length < bytes.length && (count = fs.readSync(fd, bytes, length, bytes.length - length, null))) length += count; if (length > MAX_FILE) fail(); return JSON.parse(bytes.subarray(0, length).toString('utf8')); } finally { wipe(bytes); fs.closeSync(fd); } } function header(record) { return JSON.stringify({ format: record.format, vaultId: record.vaultId, salt: record.salt, privateKey: record.privateKey, kemCiphertext: record.kemCiphertext }); } function persist() { const bytes = Buffer.from(JSON.stringify({ entries })); let temporary; try { const aad = header(envelope); const output = Buffer.from(JSON.stringify({ ...envelope, payload: seal(key, bytes, aad + ':' + JSON.stringify(envelope.wrappedKey)) })); if (output.length > MAX_FILE) fail(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); if (fs.existsSync(filePath) && !fs.lstatSync(filePath).isFile()) fail(); temporary = filePath + '.' + crypto.randomBytes(16).toString('hex') + '.tmp'; const fd = fs.openSync(temporary, 'wx', 0o600); try { fs.writeFileSync(fd, output); fs.fsyncSync(fd); } finally { fs.closeSync(fd); wipe(output); } fs.renameSync(temporary, filePath); temporary = null; } finally { wipe(bytes); if (temporary) { try { fs.unlinkSync(temporary); } catch {} } } } function create(password) { lock(); let derived, privateDer, protectedBytes, shared, wrap; try { master(password); if (fs.existsSync(filePath)) fail(); if (typeof crypto.encapsulate !== 'function' || typeof crypto.decapsulate !== 'function') fail(); const salt = crypto.randomBytes(32), vaultId = crypto.randomUUID(); derived = crypto.scryptSync(password, salt, 32, SCRYPT); const pair = crypto.generateKeyPairSync('ml-kem-1024'); privateDer = pair.privateKey.export({ format: 'der', type: 'pkcs8' }); protectedBytes = Buffer.from(JSON.stringify(seal(derived, privateDer, FORMAT + ':private:' + vaultId))); const platformProtected = protect(protectedBytes); if (!Buffer.isBuffer(platformProtected) || !platformProtected.length || platformProtected.length > 65536) fail(); const encapsulated = crypto.encapsulate(pair.publicKey); shared = encapsulated.sharedKey; wrap = wrappingKey(shared, vaultId); key = crypto.randomBytes(32); envelope = { format: FORMAT, vaultId, salt: encode(salt), privateKey: encode(platformProtected), kemCiphertext: encode(encapsulated.ciphertext) }; envelope.wrappedKey = seal(wrap, key, header(envelope)); persist(); touch(); return status(); } catch { lock(); fail(); } finally { [derived, privateDer, protectedBytes, shared, wrap].forEach(wipe); } } function unlock(password) { lock(); let derived, protectedBytes, privateDer, shared, wrap, plaintext; try { master(password); const record = checkedFile(); if (record.format !== FORMAT || typeof record.vaultId !== 'string' || !/^[a-f0-9-]{36}$/.test(record.vaultId)) fail(); const salt = decode(record.salt, 32), ciphertext = decode(record.kemCiphertext, 1568); if (salt.length !== 32 || ciphertext.length !== 1568) fail(); protectedBytes = unprotect(decode(record.privateKey, 65536)); if (!Buffer.isBuffer(protectedBytes) || protectedBytes.length > 65536) fail(); derived = crypto.scryptSync(password, salt, 32, SCRYPT); privateDer = open(derived, JSON.parse(protectedBytes.toString('utf8')), FORMAT + ':private:' + record.vaultId); const privateKey = crypto.createPrivateKey({ key: privateDer, format: 'der', type: 'pkcs8' }); if (privateKey.asymmetricKeyType !== 'ml-kem-1024') fail(); shared = crypto.decapsulate(privateKey, ciphertext); wrap = wrappingKey(shared, record.vaultId); key = open(wrap, record.wrappedKey, header(record)); if (key.length !== 32) fail(); plaintext = open(key, record.payload, header(record) + ':' + JSON.stringify(record.wrappedKey)); const decoded = JSON.parse(plaintext.toString('utf8')); if (!Array.isArray(decoded.entries) || decoded.entries.length > 1000) fail(); const ids = new Set(); for (const entry of decoded.entries) { if (typeof entry.id !== 'string' || !/^[a-f0-9-]{36}$/.test(entry.id) || ids.has(entry.id)) fail(); ids.add(entry.id); origin(entry.origin); text(entry.username, 512); text(entry.password, 4096); text(entry.label, 256); } entries = decoded.entries; envelope = { ...record }; delete envelope.payload; touch(); return status(); } catch { lock(); fail(); } finally { [derived, protectedBytes, privateDer, shared, wrap, plaintext].forEach(wipe); } } function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; } function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); } function mutate(action) { requireUnlocked(); const previous = entries.map(entry => ({ ...entry })); try { const result = action(); persist(); return result; } catch { entries = previous; fail(); } } function upsert(entry) { return mutate(() => { if (!entry || typeof entry !== 'object') fail(); const current = entry.id ? entries.find(item => item.id === entry.id) : null; if (entry.id && !current) fail(); if (!current && entries.length >= 1000) fail(); const updated = { id: current?.id || crypto.randomUUID(), origin: origin(entry.origin), username: text(entry.username, 512), password: text(entry.password, 4096), label: text(entry.label ?? '', 256) }; if (!updated.password) fail(); if (current) entries[entries.indexOf(current)] = updated; else entries.push(updated); return { id: updated.id }; }); } function remove(id) { return mutate(() => { const index = entries.findIndex(entry => entry.id === id); if (index < 0) fail(); entries.splice(index, 1); return { deleted: true }; }); } function credential(id, targetOrigin) { requireUnlocked(); const expected = origin(targetOrigin); const entry = entries.find(item => item.id === id); if (!entry || entry.origin !== expected) fail(); return { username: entry.username, password: entry.password }; } return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword }; } module.exports = { createVault, generatePassword };