theseus/lib/vault-pin.cjs
Local Dev a6f7b335a5 Vault: PIN setup steps, 6-8 digit PINs, save and offer logins, keep sign-ins
The PIN could only be six digits and was set from three bare inputs; the
unlock prompt sat at the top of the page; and the password manager only
filled when you found the key chip, never offered to save, and "Clear
cookies on quit" signed you out of every site, including the ones whose
login the vault already holds.

- PINs are 6 to 8 digits. The PIN record stores its length so pads draw the
  right number of dots and submit on the last digit; a PIN of the wrong
  length is refused without a strike, so an older Aegis pad cannot burn the
  count against an 8-digit PIN.
- Settings sets a PIN in steps: master password, choose the PIN on a pad
  (6/7/8), repeat it, done. The locked vault opens Theseus's own prompt,
  which is now centred, with the PIN pad or the master password field.
- After a sign-in or sign-up form is sent and the page moves on, Theseus
  offers to save (or update) the login, with an optional "ask for my PIN or
  password before filling it". Focusing a login form offers the saved
  logins under it; on a locked vault it offers to unlock first. A failed
  login (the password field still showing) gets no offer.
- "Keep sign-ins for sites in your vault" (on): the quit clear spares the
  cookies and site storage of sites with a saved login. Their hostnames are
  kept sealed with the OS keystore so the list is readable at quit while
  the vault is locked. Verified end to end on a scratch profile: signed in,
  restarted, still signed in; another site's cookie was cleared.
2026-10-04 20:23:43 +02:00

222 lines
10 KiB
JavaScript

// Quick-unlock PIN for the password vault — the one PIN in Theseus. Settings,
// the unlock prompt, Pithos (requestUnlock) and Aegis's PIN pads
// (api.vault.pin) all check it here, against one strike counter.
//
// 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-8 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.
//
// Five wrong PINs lock the PIN for 15 minutes, and every further wrong PIN
// locks it again (the policy Aegis's PIN screens have always described). The
// master password works throughout, and a correct PIN or a master-password
// unlock clears the count. The counter lives in this file, so a restart does
// not reset it — but anyone who can write the file can, 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: <b64 safeStorage blob>, fails, last }
// blob = { salt, iv, ct, iters, len?, hw? } (all b64 except iters; len is
// the PIN's digit count, absent = 6 from builds that took only 6)
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
"use strict";
const fs = require("node:fs");
const crypto = require("node:crypto");
const tpmPin = require("./tpm-pin.cjs");
const MAX_FAILS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
const ITERATIONS = 600_000;
const PIN_MIN = 6;
const PIN_MAX = 8;
const PIN_RE = /^\d{6,8}$/;
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
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;
}
// Records from the 3-strike build: "master password required" becomes
// one lockout period.
if (rec && rec.requireMaster) { rec.fails = Math.max(rec.fails || 0, MAX_FAILS); rec.last = rec.last || now(); delete rec.requireMaster; }
return rec;
}
function write(rec) {
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 });
fs.renameSync(tmp, file);
}
const lockedMsOf = (rec) => (rec && (rec.fails || 0) >= MAX_FAILS ? Math.max(0, LOCKOUT_MS - (now() - (rec.last || 0))) : 0);
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))));
const self = {
MAX_FAILS,
LOCKOUT_MS,
status() {
const rec = read();
const b = rec ? blobOrNull(rec) : null;
return {
pinSet: !!rec,
fails: rec ? rec.fails || 0 : 0,
last: rec ? rec.last || 0 : 0,
lockedMs: lockedMsOf(rec),
sealed: !!(rec && rec.sealed),
hardware: b ? (b.hw ? "tpm" : "none") : null,
// So PIN pads can draw the right number of dots and submit on the
// last digit. Knowing it saves an attacker under 12% of the search.
length: b ? b.len || PIN_MIN : 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 ${PIN_MIN} to ${PIN_MAX} 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, len: String(pin).length };
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,
last: 0,
});
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" | "locked" | "wrong-pin" | "tpm-locked" | "pin-gone", remaining, lockedMs }
async open(pin) {
const rec = read();
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
const locked = lockedMsOf(rec);
if (locked > 0) throw Object.assign(new Error("too many wrong PINs"), { code: "locked", remaining: 0, lockedMs: locked });
// A PIN of the wrong length cannot be right. Refuse it without a
// strike or a TPM attempt: an older Aegis pad that submits at six
// digits would otherwise burn the count against an 8-digit PIN.
const want = (blobOrNull(rec) || {}).len || PIN_MIN;
if (String(pin || "").length !== want) {
throw Object.assign(new Error(`your PIN has ${want} digits`),
{ code: "wrong-length", length: want, remaining: Math.max(0, MAX_FAILS - (rec.fails || 0)), lockedMs: 0 });
}
// Count the guess before trying it, so a crash mid-check still costs one.
rec.fails = (rec.fails || 0) + 1;
rec.last = now();
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") {
// Not a verdict on the PIN: give this one attempt back.
const cur = read();
if (cur) { cur.fails = Math.max(0, (cur.fails || 0) - 1); write(cur); }
throw Object.assign(new Error(r.code === "locked"
? "The security chip is refusing PINs for a few minutes after too many wrong ones. Use the master password, or wait."
: "The security chip did not answer. Use the master password."), { code: "tpm-locked", remaining: MAX_FAILS - (rec.fails - 1), lockedMs: 0 });
} else if (r.code === "missing") {
self.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: "pin-gone", remaining: 0, lockedMs: 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 === "pin-gone")) throw e;
masterPassword = null;
}
}
if (masterPassword == null) {
const cur = read() || rec;
const remaining = Math.max(0, MAX_FAILS - (cur.fails || 0));
throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs"),
{ code: remaining ? "wrong-pin" : "locked", remaining, lockedMs: lockedMsOf(cur) });
}
const cur = read() || rec;
cur.fails = 0; cur.last = 0; write(cur);
return masterPassword;
},
// A successful master-password unlock clears the strikes.
resetFails() {
const rec = read();
if (rec && (rec.fails || rec.last)) { rec.fails = 0; rec.last = 0; write(rec); }
},
};
return self;
}
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS, PIN_MIN, PIN_MAX };