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.
This commit is contained in:
Local Dev 2026-10-04 03:39:14 +02:00
parent cda17fc885
commit 561cb122ea
5 changed files with 253 additions and 32 deletions

View file

@ -19,7 +19,7 @@
// by -EncodedCommand; the PIN and secrets travel only on stdin/stdout, never // by -EncodedCommand; the PIN and secrets travel only on stdin/stdout, never
// on the command line. // on the command line.
// //
// Shared with TheseusNavigator/lib/tpm-pin.cjs (same code) — keep them equal. // Shared with TheseusNavigator/lib/tpm-pin.cjs (same code apart from this line) — keep them equal.
"use strict"; "use strict";
const { spawn } = require("node:child_process"); const { spawn } = require("node:child_process");

147
lib/tpm-pin.cjs Normal file
View file

@ -0,0 +1,147 @@
// Hardware rate limiting for a short PIN: a TPM key whose use needs the PIN.
//
// A 6-digit PIN wrapped only by PBKDF2 + the OS keystore falls to anyone who
// can open that keystore (malware running as the user, or a disk image plus
// the Windows password): 10^6 guesses take minutes on a GPU. Here the PIN is
// instead the authorization value of an RSA key created inside the TPM by
// the Microsoft Platform Crypto Provider. The private key never leaves the
// chip, and the chip itself counts wrong authorizations: Windows configures
// TPM 2.0 to lock after 32 failures and to forget one every 10 minutes, so an
// attacker gets ~144 guesses a day instead of millions (about 19 years for
// all 10^6 PINs). The counter is global to the TPM and only the TPM owner
// (an administrator) can reset it.
//
// The key decrypts a random 32-byte secret; callers mix that secret with
// their own PBKDF2(pin) so neither half alone opens anything.
//
// No native module: Windows PowerShell 5.1 ships on every Windows 10/11 and
// reaches CNG through .NET (CngKey / RSACng). The script is a constant passed
// by -EncodedCommand; the PIN and secrets travel only on stdin/stdout, never
// on the command line.
//
// Shared with bundled-addons/aegis/lib/tpm-pin.js (same code) — keep them equal.
"use strict";
const { spawn } = require("node:child_process");
const path = require("node:path");
const crypto = require("node:crypto");
const PROVIDER = "Microsoft Platform Crypto Provider";
const TIMEOUT_MS = 30_000;
const PS_SCRIPT = String.raw`
$ErrorActionPreference = 'Stop'
$in = [Console]::In.ReadToEnd() | ConvertFrom-Json
$prov = New-Object System.Security.Cryptography.CngProvider('${PROVIDER}')
function PinProp($pin) { New-Object System.Security.Cryptography.CngProperty('SmartCardPin', [Text.Encoding]::Unicode.GetBytes([string]$pin + [char]0), [System.Security.Cryptography.CngPropertyOptions]::None) }
function Out($o) { [Console]::Out.Write(($o | ConvertTo-Json -Compress)) }
function Fail($e) {
$x = $e.Exception; while ($x.InnerException) { $x = $x.InnerException }
Out @{ ok = $false; hr = ('0x{0:X8}' -f $x.HResult); msg = [string]$x.Message }
}
try {
if ($in.op -eq 'create') {
$p = New-Object System.Security.Cryptography.CngKeyCreationParameters
$p.Provider = $prov
$p.ExportPolicy = [System.Security.Cryptography.CngExportPolicies]::None
$p.KeyUsage = [System.Security.Cryptography.CngKeyUsages]::Decryption
$p.Parameters.Add((New-Object System.Security.Cryptography.CngProperty('Length', [BitConverter]::GetBytes(2048), [System.Security.Cryptography.CngPropertyOptions]::None)))
$p.Parameters.Add((PinProp $in.pin))
$k = [System.Security.Cryptography.CngKey]::Create([System.Security.Cryptography.CngAlgorithm]::Rsa, [string]$in.name, $p)
try {
$rsa = New-Object System.Security.Cryptography.RSACng($k)
$ct = $rsa.Encrypt([Convert]::FromBase64String($in.secret), [System.Security.Cryptography.RSAEncryptionPadding]::OaepSHA256)
Out @{ ok = $true; wrapped = [Convert]::ToBase64String($ct) }
} finally { $k.Dispose() }
} elseif ($in.op -eq 'open') {
$k = [System.Security.Cryptography.CngKey]::Open([string]$in.name, $prov, [System.Security.Cryptography.CngKeyOpenOptions]::Silent)
try {
$k.SetProperty((PinProp $in.pin))
$rsa = New-Object System.Security.Cryptography.RSACng($k)
$pt = $rsa.Decrypt([Convert]::FromBase64String($in.wrapped), [System.Security.Cryptography.RSAEncryptionPadding]::OaepSHA256)
Out @{ ok = $true; secret = [Convert]::ToBase64String($pt) }
} finally { $k.Dispose() }
} elseif ($in.op -eq 'remove') {
if ([System.Security.Cryptography.CngKey]::Exists([string]$in.name, $prov)) {
$k = [System.Security.Cryptography.CngKey]::Open([string]$in.name, $prov, [System.Security.Cryptography.CngKeyOpenOptions]::Silent)
$k.Delete()
}
Out @{ ok = $true }
} else { Out @{ ok = $false; hr = '0x00000000'; msg = 'unknown op' } }
} catch { Fail $_ }
`;
// HRESULTs that decide what a failure means.
const WRONG_PIN = new Set(["0x80090010", "0x80280922", "0x8028008E"]); // NTE_PERM, TPM_20_E_AUTH_FAIL, TPM_20_E_BAD_AUTH
const LOCKED = new Set(["0x80280921", "0x80280803"]); // TPM_20_E_LOCKOUT, TPM_E_DEFEND_LOCK_RUNNING
const MISSING = new Set(["0x80090016", "0x80090011"]); // NTE_BAD_KEYSET, NTE_NOT_FOUND
function powershellPath() {
const root = process.env.SystemRoot || process.env.windir || "C:\\Windows";
return path.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}
function run(input) {
return new Promise((resolve) => {
let child;
try {
child = spawn(powershellPath(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-EncodedCommand", Buffer.from(PS_SCRIPT, "utf16le").toString("base64")], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
} catch (e) { resolve({ ok: false, hr: "spawn", msg: e.message }); return; }
let out = "";
let err = "";
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve({ ok: false, hr: "timeout", msg: "the security chip did not answer" }); }, TIMEOUT_MS);
child.stdout.on("data", (d) => { out += d; });
child.stderr.on("data", (d) => { err += d; });
child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, hr: "spawn", msg: e.message }); });
child.on("close", () => {
clearTimeout(timer);
try { resolve(JSON.parse(out)); } catch { resolve({ ok: false, hr: "output", msg: (err || out).slice(0, 200) }); }
});
child.stdin.end(JSON.stringify(input));
});
}
function classify(r) {
const hr = String(r.hr || "").toUpperCase().replace(/^0X/, "0x");
if (WRONG_PIN.has(hr)) return "wrong-pin";
if (LOCKED.has(hr) || /lock|dictionary/i.test(String(r.msg || ""))) return "locked";
if (MISSING.has(hr)) return "missing";
return "error";
}
const supported = () => process.platform === "win32";
// Creates a TPM key that needs `pin`, and returns { keyName, wrapped, secret }
// (secret: 32 random bytes the caller mixes into its own key). Throws when
// there is no usable TPM; the caller then falls back and says so.
async function create(pin, prefix = "Aegis-PIN") {
if (!supported()) throw Object.assign(new Error("no TPM support on this system"), { code: "unsupported" });
const keyName = `${prefix}-${crypto.randomBytes(12).toString("hex")}`;
const secret = crypto.randomBytes(32);
const r = await run({ op: "create", name: keyName, pin: String(pin), secret: secret.toString("base64") });
if (!r || !r.ok || !r.wrapped) throw Object.assign(new Error(`TPM key not created: ${r && r.msg || "unknown error"}`), { code: "unsupported", hr: r && r.hr });
return { keyName, wrapped: r.wrapped, secret };
}
// → { ok: true, secret } | { ok: false, code: "wrong-pin" | "locked" | "missing" | "error", msg }
async function open(keyName, wrapped, pin) {
if (!supported()) return { ok: false, code: "missing", msg: "no TPM support on this system" };
const r = await run({ op: "open", name: String(keyName), wrapped: String(wrapped), pin: String(pin) });
if (r && r.ok && r.secret) return { ok: true, secret: Buffer.from(r.secret, "base64") };
return { ok: false, code: classify(r || {}), msg: r && r.msg, hr: r && r.hr };
}
async function remove(keyName) {
if (!supported() || !keyName) return false;
const r = await run({ op: "remove", name: String(keyName) });
return !!(r && r.ok);
}
// The AES key that wraps the master password: needs the TPM secret AND the
// PIN's own PBKDF2, so a broken chip still leaves the PBKDF2 + OS-seal layers.
function mixKey(tpmSecret, pbkdf2Key) {
return Buffer.from(crypto.hkdfSync("sha256", Buffer.concat([Buffer.from(tpmSecret), Buffer.from(pbkdf2Key)]), Buffer.alloc(0), "silentmode/pin/tpm/v1", 32));
}
module.exports = { create, open, remove, mixKey, supported, classify, PROVIDER };

View file

@ -3,34 +3,64 @@
// The PIN is an alias for the master password, never a replacement: it // The PIN is an alias for the master password, never a replacement: it
// encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the // encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the
// result is sealed again with Electron safeStorage (DPAPI on Windows, // result is sealed again with Electron safeStorage (DPAPI on Windows,
// Keychain on macOS, libsecret on Linux) where available, so a copied // Keychain on macOS, libsecret on Linux), so a copied vault-pin.json is
// vault-pin.json is useless on another machine or OS account. A 6-digit PIN // useless on another machine or OS account.
// alone would fall to an offline search in minutes; the OS seal is what //
// stops that. // 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 // 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 // lives in the same file, so restarting Theseus does not reset it; only a
// successful master-password unlock does. // 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: bool, data: <b64 safeStorage blob> | blob, fails, requireMaster } // File: { v: 1, sealed: true, data: <b64 safeStorage blob>, fails, requireMaster }
// blob = { salt, iv, ct, iters } (all b64 except iters) // blob = { salt, iv, ct, iters, hw? } (all b64 except iters)
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
"use strict"; "use strict";
const fs = require("node:fs"); const fs = require("node:fs");
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const tpmPin = require("./tpm-pin.cjs");
const MAX_FAILS = 3; const MAX_FAILS = 3;
const ITERATIONS = 600_000; const ITERATIONS = 600_000;
const PIN_RE = /^\d{6}$/; const PIN_RE = /^\d{6}$/;
function createVaultPin({ file, safeStorage }) { function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
const sealAvailable = () => { const sealAvailable = () => {
try { return !!(safeStorage && safeStorage.isEncryptionAvailable()); } catch { return false; } 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() { function read() {
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } 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) { function write(rec) {
const tmp = file + ".tmp"; const tmp = file + ".tmp";
@ -40,74 +70,115 @@ function createVaultPin({ file, safeStorage }) {
function blobOf(rec) { function blobOf(rec) {
if (!rec) return null; 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"); 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"))); 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) => crypto.pbkdf2Sync(String(pin), salt, iters, 32, "sha256"); 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 { return {
MAX_FAILS, MAX_FAILS,
status() { status() {
const rec = read(); const rec = read();
const b = rec ? blobOrNull(rec) : null;
return { return {
pinSet: !!rec, pinSet: !!rec,
fails: rec ? rec.fails || 0 : 0, fails: rec ? rec.fails || 0 : 0,
requireMaster: !!(rec && rec.requireMaster), requireMaster: !!(rec && rec.requireMaster),
sealed: !!(rec && rec.sealed), sealed: !!(rec && rec.sealed),
hardware: b ? (b.hw ? "tpm" : "none") : null,
storable: sealAvailable(),
}; };
}, },
// Caller must have verified masterPassword against the vault first. // Caller must have verified masterPassword against the vault first.
set(pin, masterPassword) { async set(pin, masterPassword) {
if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits"); if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits");
if (!masterPassword) throw new Error("master password required"); 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 salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12); const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", keyFor(pin, salt, ITERATIONS), iv); 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 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 blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS };
const sealed = sealAvailable(); if (hw) blob.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
write({ write({
v: 1, v: 1,
sealed, sealed: true,
data: sealed ? safeStorage.encryptString(JSON.stringify(blob)).toString("base64") : blob, data: safeStorage.encryptString(JSON.stringify(blob)).toString("base64"),
fails: 0, fails: 0,
requireMaster: false, requireMaster: false,
}); });
if (old?.hw?.key && old.hw.key !== hw?.keyName) tpm.remove(old.hw.key).catch(() => {});
return { hardware: hw ? "tpm" : "none" };
}, },
clear() { clear() {
const old = blobOrNull(read());
if (old?.hw?.key) tpm.remove(old.hw.key).catch(() => {});
try { fs.unlinkSync(file); } catch {} try { fs.unlinkSync(file); } catch {}
}, },
// Returns the master password, or throws: // Returns the master password, or throws:
// { code: "no-pin" | "master-required" | "wrong-pin", remaining } // { code: "no-pin" | "master-required" | "wrong-pin" | "tpm-locked", remaining }
open(pin) { async open(pin) {
const rec = read(); const rec = read();
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin" }); 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 }); 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; let masterPassword = null;
if (PIN_RE.test(String(pin || ""))) { if (PIN_RE.test(String(pin || ""))) {
try { try {
const b = blobOf(rec); const b = blobOf(rec);
const ct = Buffer.from(b.ct, "base64"); let secret = null;
const decipher = crypto.createDecipheriv("aes-256-gcm", keyFor(pin, Buffer.from(b.salt, "base64"), b.iters), Buffer.from(b.iv, "base64")); if (b.hw) {
decipher.setAuthTag(ct.subarray(ct.length - 16)); const r = await tpm.open(b.hw.key, b.hw.wrapped, pin);
masterPassword = Buffer.concat([decipher.update(ct.subarray(0, ct.length - 16)), decipher.final()]).toString("utf8"); if (r.ok) secret = r.secret;
} catch { masterPassword = null; } 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 (masterPassword == null) {
rec.fails = (rec.fails || 0) + 1;
if (rec.fails >= MAX_FAILS) rec.requireMaster = true; if (rec.fails >= MAX_FAILS) rec.requireMaster = true;
write(rec); write(rec);
const remaining = Math.max(0, MAX_FAILS - rec.fails); 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"), throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs, enter the master password"),
{ code: remaining ? "wrong-pin" : "master-required", remaining }); { code: remaining ? "wrong-pin" : "master-required", remaining });
} }
if (rec.fails) { rec.fails = 0; write(rec); } rec.fails = 0; write(rec);
return masterPassword; return masterPassword;
}, },

View file

@ -6765,7 +6765,7 @@ const vaultErr = (m) => ({ ok: false, err: String(m) });
// its own prompt (unlock.html) and the PIN or password never reaches them. // its own prompt (unlock.html) and the PIN or password never reaches them.
const { createVaultPin } = require("./lib/vault-pin.cjs"); const { createVaultPin } = require("./lib/vault-pin.cjs");
let vaultPinInst = null; let vaultPinInst = null;
const vaultPin = () => (vaultPinInst ||= createVaultPin({ file: path.join(app.getPath("userData"), "vault-pin.json"), safeStorage })); const vaultPin = () => (vaultPinInst ||= createVaultPin({ file: path.join(app.getPath("userData"), "vault-pin.json"), safeStorage, log: (...a) => console.log("[vault-pin]", ...a) }));
async function unlockVaultWithMaster(masterPassword) { async function unlockVaultWithMaster(masterPassword) {
if (!fs.existsSync(vaultFile())) throw new Error("no vault"); if (!fs.existsSync(vaultFile())) throw new Error("no vault");
@ -6824,10 +6824,10 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
if (!unlockCurrent || unlockCurrent.req.reqId !== reqId) return { ok: false, error: "This prompt has expired." }; if (!unlockCurrent || unlockCurrent.req.reqId !== reqId) return { ok: false, error: "This prompt has expired." };
let masterPassword = value; let masterPassword = value;
if (mode === "pin") { if (mode === "pin") {
try { masterPassword = vaultPin().open(value); } try { masterPassword = await vaultPin().open(value); }
catch (err) { catch (err) {
if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} ${err.remaining === 1 ? "try" : "tries"} left.` }; if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} ${err.remaining === 1 ? "try" : "tries"} left.` };
return { ok: false, mode: "password", error: err.code === "master-required" ? "Too many wrong PINs. Enter the master password." : err.message }; return { ok: false, mode: "password", error: err.code === "master-required" && !/security chip/.test(err.message) ? "Too many wrong PINs. Enter the master password." : err.message };
} }
} }
try { try {
@ -6857,7 +6857,7 @@ ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => {
// Proves the password before it is wrapped; also unlocks the vault. // Proves the password before it is wrapped; also unlocks the vault.
await unlockVaultWithMaster(String(masterPassword || "")); await unlockVaultWithMaster(String(masterPassword || ""));
} catch { await new Promise((r) => setTimeout(r, 600)); return vaultErr("wrong master password"); } } catch { await new Promise((r) => setTimeout(r, 600)); return vaultErr("wrong master password"); }
try { vaultPin().set(String(pin || ""), String(masterPassword)); return vaultOk(); } try { const r = await vaultPin().set(String(pin || ""), String(masterPassword)); return { ...vaultOk(), ...r }; }
catch (e) { return vaultErr(e.message); } catch (e) { return vaultErr(e.message); }
}); });
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); }); ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); });

View file

@ -2064,8 +2064,11 @@
const desc = document.getElementById("pinDesc"); const desc = document.getElementById("pinDesc");
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here and in extensions such as Pithos. Three wrong PINs and the master password is required."; const base = "A 6-digit PIN that unlocks the vault instead of the master password, here and in extensions such as Pithos. Three wrong PINs and the master password is required.";
desc.textContent = set && pin.requireMaster ? base + " The PIN is paused after wrong tries; it works again after the next master-password unlock." desc.textContent = set && pin.requireMaster ? base + " The PIN is paused after wrong tries; it works again after the next master-password unlock."
: set && !pin.sealed ? base + " This system has no keystore to seal it with, so choose it carefully." : set && pin.hardware === "tpm" ? base + " It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
: set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password."
: pin && pin.storable === false ? base + " This system has no protected keystore, so a PIN cannot be stored safely here."
: base; : base;
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
} }
const pinForm = document.getElementById("pinForm"); const pinForm = document.getElementById("pinForm");
const pinErr = document.getElementById("pinErr"); const pinErr = document.getElementById("pinErr");