Files
safetynet/apps/browser/vault.cjs
T

161 lines
10 KiB
JavaScript
Raw Normal View History

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();
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 };