diff --git a/bundled-addons/aegis/lib/tpm-pin.js b/bundled-addons/aegis/lib/tpm-pin.js index 185d529a..a6b2af27 100644 --- a/bundled-addons/aegis/lib/tpm-pin.js +++ b/bundled-addons/aegis/lib/tpm-pin.js @@ -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"); diff --git a/lib/tpm-pin.cjs b/lib/tpm-pin.cjs new file mode 100644 index 00000000..10b3fbf9 --- /dev/null +++ b/lib/tpm-pin.cjs @@ -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 }; diff --git a/lib/vault-pin.cjs b/lib/vault-pin.cjs index c6db67df..068d7832 100644 --- a/lib/vault-pin.cjs +++ b/lib/vault-pin.cjs @@ -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: | blob, fails, requireMaster } -// blob = { salt, iv, ct, iters } (all b64 except iters) +// File: { v: 1, sealed: true, data: , fails, requireMaster } +// blob = { salt, iv, ct, iters, hw? } (all b64 except iters) +// hw = { kind: "tpm", key: , wrapped: } "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); - 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")); - 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; } + 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) { - 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; }, diff --git a/main.js b/main.js index e2aacaef..05c6ee04 100644 --- a/main.js +++ b/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(); }); diff --git a/settings.html b/settings.html index d43477be..7949beec 100644 --- a/settings.html +++ b/settings.html @@ -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");