theseus/lib/vault-pin.cjs
Local Dev 9d6d8c3cc5 Theseus: one PIN — the vault PIN, offered to Aegis through api.vault.pin
Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.

The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
2026-10-04 04:15:15 +02:00

208 lines
9.4 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-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, hw? } (all b64 except iters)
// 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_RE = /^\d{6}$/;
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,
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,
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 });
// 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 };