theseus/lib/tpm-pin.cjs
Local Dev 561cb122ea Vault PIN: tie it to the TPM and never store it unsealed
Theseus's own quick-unlock PIN had the same limit as Aegis's: once the
DPAPI seal is opened (as the user, or from a disk image plus the
Windows password) the 6-digit PIN falls to an offline search. The PIN
is now also the authorization value of a Platform Crypto Provider TPM
key whose secret is mixed into the wrapping key, so the chip's lockout
bounds guessing; lib/tpm-pin.cjs is the same module Aegis uses.

set() now refuses when there is no real OS keystore (Linux basic_text
included) instead of writing the blob in the clear, an unsealed record
from an older build is deleted, and Settings says what the PIN actually
protects against on this machine.
2026-10-04 03:42:02 +02:00

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