2026-10-13 10:33:31 +02:00
|
|
|
'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();
|
2026-10-13 19:14:32 +02:00
|
|
|
let key = null, entries = [], envelope = null, timer = null, accessGeneration = 0;
|
|
|
|
|
// Process-local capability epoch. Never persisted or included in public metadata.
|
|
|
|
|
// create/unlock begin with lock, so even reopening the same vault invalidates refs.
|
|
|
|
|
function invalidateAccess() {
|
|
|
|
|
if (accessGeneration >= Number.MAX_SAFE_INTEGER) { lock(); fail(); }
|
|
|
|
|
accessGeneration += 1;
|
|
|
|
|
}
|
2026-10-13 10:33:31 +02:00
|
|
|
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;
|
2026-10-13 19:14:32 +02:00
|
|
|
if (accessGeneration >= Number.MAX_SAFE_INTEGER) fail();
|
|
|
|
|
accessGeneration += 1;
|
2026-10-13 10:33:31 +02:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-10-13 19:14:32 +02:00
|
|
|
requireUnlocked(); invalidateAccess(); const previous = entries.map(entry => ({ ...entry }));
|
2026-10-13 10:33:31 +02:00
|
|
|
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 };
|
|
|
|
|
}
|
2026-10-13 19:14:32 +02:00
|
|
|
return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword, accessGeneration: () => accessGeneration };
|
2026-10-13 10:33:31 +02:00
|
|
|
}
|
|
|
|
|
module.exports = { createVault, generatePassword };
|