// Quick-unlock PIN for the password vault. // // The PIN is an alias for the master password, never a replacement: it // encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the // result is sealed again with Electron safeStorage (DPAPI on Windows, // Keychain on macOS, libsecret on Linux) where available, so a copied // vault-pin.json is useless on another machine or OS account. A 6-digit PIN // alone would fall to an offline search in minutes; the OS seal is what // stops that. // // Three wrong PINs in a row switch to "master password required". That flag // lives in the same file, so restarting Theseus does not reset it; only a // successful master-password unlock does. // // File: { v: 1, sealed: bool, data: | blob, fails, requireMaster } // blob = { salt, iv, ct, iters } (all b64 except iters) "use strict"; const fs = require("node:fs"); const crypto = require("node:crypto"); const MAX_FAILS = 3; const ITERATIONS = 600_000; const PIN_RE = /^\d{6}$/; function createVaultPin({ file, safeStorage }) { const sealAvailable = () => { try { return !!(safeStorage && safeStorage.isEncryptionAvailable()); } catch { return false; } }; function read() { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } function write(rec) { const tmp = file + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 }); fs.renameSync(tmp, file); } function blobOf(rec) { if (!rec) return null; if (!rec.sealed) return rec.data; if (!sealAvailable()) throw new Error("this PIN was sealed by the system keystore, which is not available now"); return JSON.parse(safeStorage.decryptString(Buffer.from(rec.data, "base64"))); } const keyFor = (pin, salt, iters) => crypto.pbkdf2Sync(String(pin), salt, iters, 32, "sha256"); return { MAX_FAILS, status() { const rec = read(); return { pinSet: !!rec, fails: rec ? rec.fails || 0 : 0, requireMaster: !!(rec && rec.requireMaster), sealed: !!(rec && rec.sealed), }; }, // Caller must have verified masterPassword against the vault first. set(pin, masterPassword) { if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits"); if (!masterPassword) throw new Error("master password required"); const salt = crypto.randomBytes(16); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-256-gcm", keyFor(pin, salt, ITERATIONS), iv); const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]); const blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS }; const sealed = sealAvailable(); write({ v: 1, sealed, data: sealed ? safeStorage.encryptString(JSON.stringify(blob)).toString("base64") : blob, fails: 0, requireMaster: false, }); }, clear() { try { fs.unlinkSync(file); } catch {} }, // Returns the master password, or throws: // { code: "no-pin" | "master-required" | "wrong-pin", remaining } open(pin) { const rec = read(); if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin" }); if (rec.requireMaster) throw Object.assign(new Error("enter the master password"), { code: "master-required", remaining: 0 }); let masterPassword = null; if (PIN_RE.test(String(pin || ""))) { try { const b = blobOf(rec); const ct = Buffer.from(b.ct, "base64"); const decipher = crypto.createDecipheriv("aes-256-gcm", keyFor(pin, Buffer.from(b.salt, "base64"), b.iters), Buffer.from(b.iv, "base64")); decipher.setAuthTag(ct.subarray(ct.length - 16)); masterPassword = Buffer.concat([decipher.update(ct.subarray(0, ct.length - 16)), decipher.final()]).toString("utf8"); } catch { masterPassword = null; } } if (masterPassword == null) { rec.fails = (rec.fails || 0) + 1; if (rec.fails >= MAX_FAILS) rec.requireMaster = true; write(rec); const remaining = Math.max(0, MAX_FAILS - rec.fails); throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs, enter the master password"), { code: remaining ? "wrong-pin" : "master-required", remaining }); } if (rec.fails) { rec.fails = 0; write(rec); } return masterPassword; }, // A successful master-password unlock clears the strikes. resetFails() { const rec = read(); if (rec && (rec.fails || rec.requireMaster)) { rec.fails = 0; rec.requireMaster = false; write(rec); } }, }; } module.exports = { createVaultPin, MAX_FAILS };