148 lines
7.8 KiB
JavaScript
148 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 };
|