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:
parent
cda17fc885
commit
561cb122ea
5 changed files with 253 additions and 32 deletions
|
|
@ -19,7 +19,7 @@
|
|||
// by -EncodedCommand; the PIN and secrets travel only on stdin/stdout, never
|
||||
// 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";
|
||||
|
||||
const { spawn } = require("node:child_process");
|
||||
|
|
|
|||
147
lib/tpm-pin.cjs
Normal file
147
lib/tpm-pin.cjs
Normal 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 };
|
||||
|
|
@ -3,34 +3,64 @@
|
|||
// 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) where available, so a copied
|
||||
// vault-pin.json is useless on another machine or OS account. A 6-digit PIN
|
||||
// alone would fall to an offline search in minutes; the OS seal is what
|
||||
// stops that.
|
||||
// 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.
|
||||
// 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 }
|
||||
// blob = { salt, iv, ct, iters } (all b64 except iters)
|
||||
// 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 }) {
|
||||
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||
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() {
|
||||
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) {
|
||||
const tmp = file + ".tmp";
|
||||
|
|
@ -40,74 +70,115 @@ function createVaultPin({ file, safeStorage }) {
|
|||
|
||||
function blobOf(rec) {
|
||||
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");
|
||||
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 {
|
||||
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.
|
||||
set(pin, masterPassword) {
|
||||
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);
|
||||
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 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({
|
||||
v: 1,
|
||||
sealed,
|
||||
data: sealed ? safeStorage.encryptString(JSON.stringify(blob)).toString("base64") : blob,
|
||||
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", remaining }
|
||||
open(pin) {
|
||||
// { 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");
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", keyFor(pin, Buffer.from(b.salt, "base64"), b.iters), Buffer.from(b.iv, "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 { masterPassword = null; }
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && (e.code === "tpm-locked" || e.code === "master-required")) throw e;
|
||||
masterPassword = null;
|
||||
}
|
||||
}
|
||||
if (masterPassword == null) {
|
||||
rec.fails = (rec.fails || 0) + 1;
|
||||
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 });
|
||||
}
|
||||
if (rec.fails) { rec.fails = 0; write(rec); }
|
||||
rec.fails = 0; write(rec);
|
||||
return masterPassword;
|
||||
},
|
||||
|
||||
|
|
|
|||
8
main.js
8
main.js
|
|
@ -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.
|
||||
const { createVaultPin } = require("./lib/vault-pin.cjs");
|
||||
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) {
|
||||
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." };
|
||||
let masterPassword = value;
|
||||
if (mode === "pin") {
|
||||
try { masterPassword = vaultPin().open(value); }
|
||||
try { masterPassword = await vaultPin().open(value); }
|
||||
catch (err) {
|
||||
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 {
|
||||
|
|
@ -6857,7 +6857,7 @@ ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => {
|
|||
// Proves the password before it is wrapped; also unlocks the vault.
|
||||
await unlockVaultWithMaster(String(masterPassword || ""));
|
||||
} 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); }
|
||||
});
|
||||
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); });
|
||||
|
|
|
|||
|
|
@ -2064,8 +2064,11 @@
|
|||
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.";
|
||||
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;
|
||||
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
|
||||
}
|
||||
const pinForm = document.getElementById("pinForm");
|
||||
const pinErr = document.getElementById("pinErr");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue