theseus/lib/vault-pin.cjs
Local Dev 561cb122ea Vault PIN: tie it to the TPM and never store it unsealed
Theseus's own quick-unlock PIN had the same limit as Aegis's: once the
DPAPI seal is opened (as the user, or from a disk image plus the
Windows password) the 6-digit PIN falls to an offline search. The PIN
is now also the authorization value of a Platform Crypto Provider TPM
key whose secret is mixed into the wrapping key, so the chip's lockout
bounds guessing; lib/tpm-pin.cjs is the same module Aegis uses.

set() now refuses when there is no real OS keystore (Linux basic_text
included) instead of writing the blob in the clear, an unsealed record
from an older build is deleted, and Settings says what the PIN actually
protects against on this machine.
2026-10-04 03:42:02 +02:00

193 lines
8.5 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), 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: <b64 safeStorage blob>, fails, requireMaster }
// 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 = 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 };