theseus/lib/vault-pin.cjs

209 lines
9.4 KiB
JavaScript
Raw Normal View History

// 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 };