// 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), so a copied vault-pin.json is // useless on another machine or OS account. // // The OS seal does not stop anything that runs as this OS user, nor a disk // image plus the Windows password; for those a 6-digit PIN falls to an // offline search in minutes. Where a TPM is available the PIN is therefore // also the authorization value of a TPM key (lib/tpm-pin.cjs) whose secret is // mixed into the AES key, and the chip's own lockout limits guesses to about // 144 a day however the file was obtained. Without a TPM the PIN is // software-only, and status().hardware says so. // // Nothing is stored without a real OS keystore: set() refuses, and an // unsealed record from an older build is deleted. On Linux the basic_text // backend (a constant key compiled into Chromium) counts as no keystore. // // 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. Anyone who can write the file can // reset it, which is why the TPM lockout, not this counter, is the limit that // matters against an attacker on the machine. // // File: { v: 1, sealed: true, data: , fails, requireMaster } // blob = { salt, iv, ct, iters, hw? } (all b64 except iters) // hw = { kind: "tpm", key: , wrapped: } "use strict"; const fs = require("node:fs"); const crypto = require("node:crypto"); const tpmPin = require("./tpm-pin.cjs"); const MAX_FAILS = 3; const ITERATIONS = 600_000; const PIN_RE = /^\d{6}$/; function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) { const sealAvailable = () => { try { if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false; if (process.platform === "linux") { const backend = typeof safeStorage.getSelectedStorageBackend === "function" ? safeStorage.getSelectedStorageBackend() : "unknown"; if (backend === "basic_text" || backend === "unknown") return false; } return true; } catch { return false; } }; let tpmUnavailable = false; function read() { let rec; try { rec = JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } if (rec && !rec.sealed) { // Written by a build that stored the blob in the clear when the OS // keystore was missing. Never use it; drop it. try { fs.unlinkSync(file); } catch {} return null; } return rec; } 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 (!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"))); } function blobOrNull(rec) { try { return blobOf(rec); } catch { return null; } } const keyFor = (pin, salt, iters) => new Promise((resolve, reject) => crypto.pbkdf2(String(pin), salt, iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k)))); return { MAX_FAILS, status() { const rec = read(); const b = rec ? blobOrNull(rec) : null; return { pinSet: !!rec, fails: rec ? rec.fails || 0 : 0, requireMaster: !!(rec && rec.requireMaster), sealed: !!(rec && rec.sealed), hardware: b ? (b.hw ? "tpm" : "none") : null, storable: sealAvailable(), }; }, // Caller must have verified masterPassword against the vault first. async 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"); if (!sealAvailable()) throw new Error("this system has no protected keystore, so a PIN cannot be stored safely"); const old = blobOrNull(read()); let hw = null; if (tpm.supported() && !tpmUnavailable) { try { hw = await tpm.create(pin, "Theseus-PIN"); } catch (e) { tpmUnavailable = true; log("vault PIN: no TPM key:", e?.message || e); } } const salt = crypto.randomBytes(16); const iv = crypto.randomBytes(12); let key = await keyFor(pin, salt, ITERATIONS); if (hw) key = tpm.mixKey(hw.secret, key); const cipher = crypto.createCipheriv("aes-256-gcm", key, 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 }; if (hw) blob.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped }; write({ v: 1, sealed: true, data: safeStorage.encryptString(JSON.stringify(blob)).toString("base64"), fails: 0, requireMaster: false, }); if (old?.hw?.key && old.hw.key !== hw?.keyName) tpm.remove(old.hw.key).catch(() => {}); return { hardware: hw ? "tpm" : "none" }; }, clear() { const old = blobOrNull(read()); if (old?.hw?.key) tpm.remove(old.hw.key).catch(() => {}); try { fs.unlinkSync(file); } catch {} }, // Returns the master password, or throws: // { code: "no-pin" | "master-required" | "wrong-pin" | "tpm-locked", remaining } async 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 }); // Count the guess before trying it, so a crash mid-check still costs one. const before = rec.fails || 0; rec.fails = before + 1; write(rec); let masterPassword = null; if (PIN_RE.test(String(pin || ""))) { try { const b = blobOf(rec); let secret = null; if (b.hw) { const r = await tpm.open(b.hw.key, b.hw.wrapped, pin); if (r.ok) secret = r.secret; else if (r.code === "locked" || r.code === "error") { rec.fails = before; write(rec); // not a verdict on the PIN throw Object.assign(new Error(r.code === "locked" ? "The security chip is refusing PINs for a few minutes after too many wrong ones. Enter the master password, or wait." : "The security chip did not answer. Enter the master password."), { code: "tpm-locked", remaining: MAX_FAILS - before }); } else if (r.code === "missing") { this.clear(); throw Object.assign(new Error("This PIN was tied to a security chip that no longer has its key. Enter the master password, then set the PIN again."), { code: "master-required", remaining: 0 }); } } if (!b.hw || secret) { const ct = Buffer.from(b.ct, "base64"); let key = await keyFor(pin, Buffer.from(b.salt, "base64"), b.iters); if (b.hw) key = tpm.mixKey(secret, key); const decipher = crypto.createDecipheriv("aes-256-gcm", key, 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 (e) { if (e && (e.code === "tpm-locked" || e.code === "master-required")) throw e; masterPassword = null; } } if (masterPassword == null) { 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 }); } 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 };