A 6-digit PIN behind PBKDF2 + DPAPI falls in minutes to anything that can open DPAPI (malware running as the user, a disk image plus the Windows password). The PIN is now also the authorization value of a TPM key from the Microsoft Platform Crypto Provider; the key releases a secret mixed with PBKDF2(pin), so the stored blob alone opens nothing and the chip's own lockout (32 failures, then one per 10 minutes) limits guesses however the blob was obtained. Reached through Windows PowerShell's CNG classes with the PIN on stdin, so no native module. Machines without a TPM keep the software PIN, and Settings now says plainly what that protects against. A PIN is no longer stored when there is no real OS keystore (including Linux's basic_text backend, whose key is a constant); a pre-0.31 plain blob is sealed or deleted and remembered, so the panel can tell the user to change the master password if the profile was ever copied.
147 lines
7.8 KiB
JavaScript
147 lines
7.8 KiB
JavaScript
// 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 TheseusNavigator/lib/tpm-pin.cjs (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 };
|