theseus/bundled-addons/aegis/lib/tpm-pin.js

148 lines
7.8 KiB
JavaScript
Raw Normal View History

// 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 apart from this line) — 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 };