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