theseus/lib/vault-pin.cjs
Local Dev de7735feb3 Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:

- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
  wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
  and the result is sealed with the OS keystore (safeStorage: DPAPI /
  Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
  elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
  lives in the same file, so a restart does not reset it; a successful
  master-password unlock does. A PIN whose password no longer opens the
  vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
  the master password. Extensions call api.vault.requestUnlock({ reason })
  (vault-derive capability) and get { ok } back; what the user typed never
  reaches them. Settings' locked screen offers "Unlock with PIN" through
  the same prompt.
2026-10-03 20:33:26 +02:00

122 lines
4.7 KiB
JavaScript

// 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: <b64 safeStorage blob> | 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 };