Aegis 0.32.0: tie the PIN to the TPM, never store it unsealed
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.
This commit is contained in:
parent
0514d2d4e3
commit
cda17fc885
5 changed files with 324 additions and 27 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "aegis",
|
||||
"name": "Aegis Wallet",
|
||||
"version": "0.31.1",
|
||||
"version": "0.32.0",
|
||||
"category": "plugin",
|
||||
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.",
|
||||
"author": "Silent Mode",
|
||||
|
|
|
|||
|
|
@ -1208,17 +1208,41 @@ function requireSelected() { return requireWallet(selectedWalletId()); }
|
|||
// another machine, a disk image) — the panel's lockout counter never sees an
|
||||
// offline guess. It is therefore sealed with the OS keystore (DPAPI /
|
||||
// Keychain / libsecret) before it touches disk, the same as Theseus's own
|
||||
// vault PIN: a copied file is useless without this OS account. A plain blob
|
||||
// from an older build is sealed the first time it is read.
|
||||
// vault PIN: a copied file is useless without this OS account.
|
||||
//
|
||||
// There is no unsealed fallback any more: without a real OS keystore a PIN is
|
||||
// not stored at all (pinSet refuses) and the master password is asked for
|
||||
// instead. On Linux, safeStorage reports "available" even on its basic_text
|
||||
// backend, whose key is a constant compiled into Chromium — that counts as
|
||||
// unsealed. A plain blob from an older build is sealed the first time it is
|
||||
// read, and the fact that it once sat on disk in the clear is remembered so
|
||||
// the panel can tell the user to change the master password (see
|
||||
// PIN_EXPOSED_KEY); where it cannot be sealed it is deleted.
|
||||
//
|
||||
// The OS seal does not stop someone running as this OS user, nor a disk
|
||||
// image plus the Windows password. Where a TPM is available the PIN is
|
||||
// therefore also the authorization value of a TPM key (lib/tpm-pin.js), and
|
||||
// the chip's own lockout limits guesses to ~144 a day however the blob is
|
||||
// obtained. See pinWrap.
|
||||
const PIN_BLOB_KEY = "aegis/pin/v1";
|
||||
const PIN_EXPOSED_KEY = "aegis/pin/legacyExposure";
|
||||
const tpmPin = require("./lib/tpm-pin.js");
|
||||
function osSealUsable(ss) {
|
||||
try {
|
||||
if (!ss || !ss.isEncryptionAvailable()) return false;
|
||||
if (process.platform === "linux") {
|
||||
const backend = typeof ss.getSelectedStorageBackend === "function" ? ss.getSelectedStorageBackend() : "unknown";
|
||||
if (backend === "basic_text" || backend === "unknown") return false;
|
||||
}
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
// → the sealed record, or null when this system has no keystore worth the name.
|
||||
function sealPinBlob(api, blob) {
|
||||
const ss = safeStorageOr(api);
|
||||
try {
|
||||
if (ss && ss.isEncryptionAvailable()) {
|
||||
return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") };
|
||||
}
|
||||
} catch { /* fall through: unsealed is still better than no PIN at all */ }
|
||||
return blob;
|
||||
if (!osSealUsable(ss)) return null;
|
||||
try { return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") }; }
|
||||
catch { return null; }
|
||||
}
|
||||
function openPinBlob(api) {
|
||||
const b = api.storage.get(PIN_BLOB_KEY, null);
|
||||
|
|
@ -1231,10 +1255,15 @@ function openPinBlob(api) {
|
|||
return (plain && typeof plain === "object") ? plain : null;
|
||||
} catch { return null; } // sealed under another OS account: the PIN is simply gone
|
||||
}
|
||||
// A plain blob from before 0.31: the master password sat on disk behind
|
||||
// only a 6-digit PIN. Seal it (or drop it), and remember that it was ever
|
||||
// there — copies of the profile made before now still hold it.
|
||||
const plain = { salt: b.salt, iv: b.iv, ct: b.ct, iters: b.iters };
|
||||
if (!api.storage.get(PIN_EXPOSED_KEY, null)) api.storage.set(PIN_EXPOSED_KEY, { at: Date.now(), iters: Number(b.iters) || 0 });
|
||||
const sealed = sealPinBlob(api, plain);
|
||||
if (sealed.sealed) api.storage.set(PIN_BLOB_KEY, sealed);
|
||||
return plain;
|
||||
if (sealed) { api.storage.set(PIN_BLOB_KEY, sealed); return plain; }
|
||||
api.storage.set(PIN_BLOB_KEY, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The PIN is checked here, never in the panel. The panel used to fetch the
|
||||
|
|
@ -1255,19 +1284,41 @@ const PIN_RE = /^\d{6}$/;
|
|||
const nodeCrypto = require("node:crypto");
|
||||
const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) =>
|
||||
nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
|
||||
async function pinWrap(pin, masterPassword) {
|
||||
// With a TPM, `hw` is { keyName, wrapped, secret } from tpmPin.create and the
|
||||
// AES key needs both the chip's secret and PBKDF2(pin) (tpmPin.mixKey); the
|
||||
// blob records which TPM key to ask. Without one the blob is PBKDF2 only.
|
||||
async function pinWrap(pin, masterPassword, hw = null) {
|
||||
const salt = nodeCrypto.randomBytes(16).toString("hex");
|
||||
const iv = nodeCrypto.randomBytes(12);
|
||||
const c = nodeCrypto.createCipheriv("aes-256-gcm", await pinKey(pin, salt, PIN_ITERS), iv);
|
||||
let key = await pinKey(pin, salt, PIN_ITERS);
|
||||
if (hw) key = tpmPin.mixKey(hw.secret, key);
|
||||
const c = nodeCrypto.createCipheriv("aes-256-gcm", key, iv);
|
||||
const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]);
|
||||
return { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS };
|
||||
const out = { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS };
|
||||
if (hw) out.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
|
||||
return out;
|
||||
}
|
||||
async function pinUnwrapBlob(pin, blob) {
|
||||
// `tpmSecret` is what the TPM released for this PIN (required when blob.hw).
|
||||
async function pinUnwrapBlob(pin, blob, tpmSecret = null) {
|
||||
const ct = Buffer.from(String(blob.ct), "hex");
|
||||
const d = nodeCrypto.createDecipheriv("aes-256-gcm", await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS), Buffer.from(String(blob.iv), "hex"));
|
||||
let key = await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS);
|
||||
if (blob.hw) {
|
||||
if (!tpmSecret) throw new Error("TPM secret required");
|
||||
key = tpmPin.mixKey(tpmSecret, key);
|
||||
}
|
||||
const d = nodeCrypto.createDecipheriv("aes-256-gcm", key, Buffer.from(String(blob.iv), "hex"));
|
||||
d.setAuthTag(ct.subarray(ct.length - 16));
|
||||
return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8");
|
||||
}
|
||||
// A TPM key for a new PIN, or null where there is none (not Windows, no TPM,
|
||||
// PowerShell blocked by policy). Never throws: the PIN then works as before,
|
||||
// and pinStatus says honestly that it is software-only.
|
||||
let tpmUnavailableThisBoot = false;
|
||||
async function tryTpmKey(api, pin) {
|
||||
if (!tpmPin.supported() || tpmUnavailableThisBoot) return null;
|
||||
try { return await tpmPin.create(pin); }
|
||||
catch (e) { tpmUnavailableThisBoot = true; api.log("PIN: no TPM key:", e?.message || e); return null; }
|
||||
}
|
||||
function pinFails(api) {
|
||||
const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
|
||||
const last = Number(api.storage.get("aegis/pin/failLast", 0)) || 0;
|
||||
|
|
@ -2627,8 +2678,16 @@ function registerPanelMessages(api) {
|
|||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
catch { throw new Error("wrong master password"); }
|
||||
noteMasterVerified(api);
|
||||
api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw)));
|
||||
return true;
|
||||
if (!osSealUsable(safeStorageOr(api))) {
|
||||
throw new Error("this system has no protected keystore, so a PIN cannot be stored safely; Aegis will ask for the master password instead");
|
||||
}
|
||||
const old = openPinBlob(api);
|
||||
const hw = await tryTpmKey(api, pin);
|
||||
const sealed = sealPinBlob(api, await pinWrap(pin, pw, hw));
|
||||
if (!sealed) { if (hw) tpmPin.remove(hw.keyName).catch(() => {}); throw new Error("the PIN could not be sealed by the OS keystore"); }
|
||||
api.storage.set(PIN_BLOB_KEY, sealed);
|
||||
if (old?.hw?.key && old.hw.key !== hw?.keyName) tpmPin.remove(old.hw.key).catch(() => {});
|
||||
return { ok: true, hardware: hw ? "tpm" : "none" };
|
||||
});
|
||||
// Try a PIN. Counts the guess before trying it, so a crash or a closed
|
||||
// panel mid-check still costs an attempt. Answers
|
||||
|
|
@ -2643,27 +2702,60 @@ function registerPanelMessages(api) {
|
|||
api.storage.set("aegis/pin/failCount", st.fails + 1);
|
||||
api.storage.set("aegis/pin/failLast", Date.now());
|
||||
let pw = null;
|
||||
if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } }
|
||||
if (PIN_RE.test(pin)) {
|
||||
let secret = null;
|
||||
if (blob.hw) {
|
||||
// The chip decides first. Its own counter is the limit that holds
|
||||
// even against someone who resets ours (it lives in this same file).
|
||||
const r = await tpmPin.open(blob.hw.key, blob.hw.wrapped, pin);
|
||||
if (r.ok) secret = r.secret;
|
||||
else if (r.code === "locked" || r.code === "error") {
|
||||
// Not a verdict on the PIN: give the attempt back.
|
||||
api.storage.set("aegis/pin/failCount", st.fails);
|
||||
api.storage.set("aegis/pin/failLast", st.last);
|
||||
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - st.fails), lockedMs: 0, hwLocked: r.code === "locked",
|
||||
error: r.code === "locked" ? "The security chip is refusing PINs for a few minutes after too many wrong ones. Use the master password, or wait." : "The security chip did not answer. Use the master password." };
|
||||
} else if (r.code === "missing") {
|
||||
// The TPM key is gone (profile copied to another machine, TPM
|
||||
// cleared): this PIN can never open again.
|
||||
api.storage.set(PIN_BLOB_KEY, null);
|
||||
api.storage.set("aegis/pin/failCount", 0);
|
||||
api.storage.set("aegis/pin/failLast", 0);
|
||||
return { ok: false, remaining: 0, lockedMs: 0, pinGone: true, error: "This PIN was tied to a security chip that no longer has its key. Enter the master password and set the PIN again." };
|
||||
}
|
||||
}
|
||||
if (!blob.hw || secret) { try { pw = await pinUnwrapBlob(pin, blob, secret); } catch { pw = null; } }
|
||||
}
|
||||
if (pw == null) {
|
||||
const now = pinFails(api);
|
||||
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - now.fails), lockedMs: now.lockedMs };
|
||||
}
|
||||
api.storage.set("aegis/pin/failCount", 0);
|
||||
api.storage.set("aegis/pin/failLast", 0);
|
||||
// A blob from an older build (200k iterations, or from before sealing)
|
||||
// is re-made now, under a fresh salt, while the PIN is at hand.
|
||||
if ((Number(blob.iters) || 0) < PIN_ITERS) {
|
||||
try { api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); } catch (e) { api.log("PIN re-wrap:", e?.message || e); }
|
||||
// A blob from an older build (200k iterations, from before sealing, or
|
||||
// without a TPM key on a machine that has one) is re-made now, under a
|
||||
// fresh salt, while the PIN is at hand.
|
||||
if (!blob.hw && ((Number(blob.iters) || 0) < PIN_ITERS || (tpmPin.supported() && !tpmUnavailableThisBoot))) {
|
||||
try {
|
||||
const hw = await tryTpmKey(api, pin);
|
||||
if ((Number(blob.iters) || 0) < PIN_ITERS || hw) {
|
||||
const sealed = sealPinBlob(api, await pinWrap(pin, pw, hw));
|
||||
if (sealed) api.storage.set(PIN_BLOB_KEY, sealed);
|
||||
else if (hw) tpmPin.remove(hw.keyName).catch(() => {});
|
||||
}
|
||||
} catch (e) { api.log("PIN re-wrap:", e?.message || e); }
|
||||
}
|
||||
return { ok: true, masterPassword: pw };
|
||||
});
|
||||
api.onMessage("pinStatus", (_p, m) => {
|
||||
fromPanel(m);
|
||||
const f = pinFails(api);
|
||||
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs };
|
||||
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs, ...pinProtection() };
|
||||
});
|
||||
api.onMessage("pinBlobClear", (_p, m) => {
|
||||
fromPanel(m);
|
||||
const old = openPinBlob(api);
|
||||
if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {});
|
||||
api.storage.set("aegis/pin/v1", null);
|
||||
api.storage.set("aegis/pin/failCount", 0);
|
||||
api.storage.set("aegis/pin/failLast", 0);
|
||||
|
|
@ -2694,6 +2786,24 @@ function registerPanelMessages(api) {
|
|||
transaction: on ? !!on.transaction : !!cfg.requirePinForSending,
|
||||
};
|
||||
}
|
||||
// What actually protects the PIN on this machine, for honest wording in the
|
||||
// panel: "tpm" = the chip limits guesses; "none" = only PBKDF2 + the OS
|
||||
// keystore, which falls to anyone who can run as this user.
|
||||
function pinProtection() {
|
||||
const blob = api.storage.get(PIN_BLOB_KEY, null) ? openPinBlob(api) : null;
|
||||
const exposed = api.storage.get(PIN_EXPOSED_KEY, null);
|
||||
return {
|
||||
pinHardware: blob ? (blob.hw ? "tpm" : "none") : null,
|
||||
pinStorable: osSealUsable(safeStorageOr(api)),
|
||||
pinLegacyExposure: exposed && !exposed.dismissed ? { at: exposed.at } : null,
|
||||
};
|
||||
}
|
||||
api.onMessage("pinExposureDismiss", (_p, m) => {
|
||||
fromPanel(m);
|
||||
const e = api.storage.get(PIN_EXPOSED_KEY, null);
|
||||
if (e) api.storage.set(PIN_EXPOSED_KEY, { ...e, dismissed: Date.now() });
|
||||
return true;
|
||||
});
|
||||
const pinGate = () => {
|
||||
const g = api.storage.get("aegis/pin/gate", null);
|
||||
return (g && typeof g === "object") ? g : {};
|
||||
|
|
@ -2778,6 +2888,7 @@ function registerPanelMessages(api) {
|
|||
// make a secret free to read — it moves the prompt to the master
|
||||
// password, which is the stronger credential, not a weaker one.
|
||||
requirePinForReveal: cfg.requirePinForReveal !== false,
|
||||
...pinProtection(),
|
||||
};
|
||||
});
|
||||
api.onMessage("securitySet", (p, m) => {
|
||||
|
|
@ -2807,6 +2918,7 @@ function registerPanelMessages(api) {
|
|||
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
|
||||
requirePinForSending: !!next.requirePinForSending,
|
||||
requirePinForReveal: next.requirePinForReveal !== false,
|
||||
...pinProtection(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
147
bundled-addons/aegis/lib/tpm-pin.js
Normal file
147
bundled-addons/aegis/lib/tpm-pin.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// 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 };
|
||||
|
|
@ -722,6 +722,8 @@
|
|||
.gsec .gline:first-of-type { border-top: 0; }
|
||||
.gsec .gline .glabel { min-width: 0; font-size: 12.5px; color: var(--ink); }
|
||||
.gsec .gline .ghint { display: block; color: var(--dim); font-size: 11px; margin-top: 2px; }
|
||||
.gsec .gline .pinwarn { color: var(--danger); }
|
||||
.gsec .gline .pinwarn a { cursor: pointer; text-decoration: underline; }
|
||||
.gsec .gline .gactions { flex: none; display: inline-flex; gap: 6px; }
|
||||
.gsec .gsoon { font-size: 10px; padding: 1px 6px; border-radius: 999px; letter-spacing: .04em;
|
||||
background: rgba(224,179,65,.14); color: #e0b341; text-transform: uppercase; }
|
||||
|
|
|
|||
|
|
@ -307,7 +307,14 @@ function fiatSkeleton() {
|
|||
// blob, so it cannot be searched from here, and it cannot reset the counter.
|
||||
// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, lockedMs }
|
||||
const PIN_MAX_FAILS = 5;
|
||||
const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) });
|
||||
// A refusal that is not a verdict on the PIN (the TPM is in its lockout
|
||||
// window, or its key is gone) carries `error`; it is thrown, so each pad's
|
||||
// catch shows it as it is instead of as a wrong PIN.
|
||||
const pinTry = async (pin) => {
|
||||
const r = await S.invoke("pinUnwrap", { pin: String(pin) });
|
||||
if (r && !r.ok && r.error) throw new Error(r.error);
|
||||
return r;
|
||||
};
|
||||
async function pinLockoutRemainingMs() {
|
||||
try { return Number((await S.invoke("pinStatus")).lockedMs) || 0; } catch { return 0; }
|
||||
}
|
||||
|
|
@ -4747,9 +4754,38 @@ async function renderGeneralSecurity() {
|
|||
if (set) set.hidden = hasPin;
|
||||
if (chg) chg.hidden = !hasPin;
|
||||
if (rm) rm.hidden = !hasPin;
|
||||
// Say what the PIN really protects against on this machine.
|
||||
if (hint) hint.textContent = hasPin
|
||||
? "On — Aegis accepts a 6-digit PIN as an alias for your master password."
|
||||
: "Off — Aegis asks for the master password every time.";
|
||||
? (securityState.pinHardware === "tpm"
|
||||
? "On — a 6-digit PIN unlocks Aegis. It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
|
||||
: "On — a 6-digit PIN unlocks Aegis. This computer has no usable security chip, so the PIN only stops casual use: anything that runs as your Windows account, or a copy of this disk with your Windows password, can find the PIN in minutes and with it your master password.")
|
||||
: (securityState.pinStorable === false
|
||||
? "Off — this system has no protected keystore, so Aegis cannot store a PIN safely and asks for the master password every time."
|
||||
: "Off — Aegis asks for the master password every time.");
|
||||
if (set) set.disabled = !hasPin && securityState.pinStorable === false;
|
||||
// A PIN stored by Aegis before 0.31 sat on disk without OS protection.
|
||||
// Copies of the profile made back then still hold the master password
|
||||
// behind just the PIN, so the only real fix is a new master password.
|
||||
let exp = $("pinExposureNote");
|
||||
if (securityState.pinLegacyExposure) {
|
||||
if (!exp && hint) {
|
||||
exp = document.createElement("div");
|
||||
exp.id = "pinExposureNote";
|
||||
exp.className = "ghint pinwarn";
|
||||
hint.after(exp);
|
||||
}
|
||||
if (exp) {
|
||||
exp.textContent = "An earlier Aegis kept your PIN on disk without OS protection. If this Theseus profile was ever backed up, synced or copied, change your Theseus master password; changing the PIN alone does not help. ";
|
||||
const dismiss = document.createElement("a");
|
||||
dismiss.textContent = "Dismiss";
|
||||
dismiss.addEventListener("click", async () => {
|
||||
try { await S.invoke("pinExposureDismiss"); } catch {}
|
||||
await refreshSecurityState();
|
||||
renderGeneralSecurity();
|
||||
});
|
||||
exp.appendChild(dismiss);
|
||||
}
|
||||
} else if (exp) exp.remove();
|
||||
// "Ask for PIN" triggers. Meaningless without a PIN to ask for.
|
||||
const onLine = $("gsPinOnLine");
|
||||
if (onLine) onLine.hidden = !hasPin;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue