sirius/js/pin-escrow.js

119 lines
4.4 KiB
JavaScript
Raw Permalink Normal View History

feat(sirius-x/signin): PIN escrow — encrypted-at-rest quick unlock, 3-strike password fallback Removes the sessionStorage plaintext-mnemonic cache (fixed under the same commit) and replaces it with a PIN-encrypted blob in localStorage. No plaintext secret ever touches disk or memory outside the live BuiltInWallet object. New js/pin-escrow.js — WebCrypto PBKDF2(50k) + AES-GCM(256). Public API on window.siriusPin: savePinBlob(mnemonic, pin), tryUnlock(pin), hasPin(), attemptsUsed(), attemptsRemaining(), clear(), MAX_ATTEMPTS. Iteration count is lighter than BuiltInWallet's 250k because a 4-6 digit PIN's key space is small anyway; the point is 'not plaintext at rest,' not brute-force resistance — the durable secret is the full password. register-flow.js: - After a fresh password unlock (Import / Create / Unlock), stepSetPin offers a 4-6 digit PIN with confirm — skippable with 'Not now'. Never overwrites an existing PIN blob. - stepUnlock now shows a numeric PIN pad when a PIN blob is present; the password field only appears when the user opts to 'Use password instead' or after the blob was wiped. - Wrong PIN → increment counter, surface 'N attempts left'. Third wrong PIN → wipe blob and route to a 'PIN reset' screen that hands off to the password form. - Correct PIN → decrypt the mnemonic in-browser, rebuild the BuiltInWallet, finishSignIn(). Attempt counter resets to 0. profile-menu.js: - Sign-out clears the PIN blob (via siriusPin.clear()) alongside the siriusProfile so the device isn't quick-unlockable with a stale PIN. All pages that host the sign-in flow now include pin-escrow.js. Same tabbed layout in stepUnlock — the PIN pad and the password field both live under 🔓 Unlock, transparent tab-switch works exactly as before. Verified live: - Fresh Import → 'Set a PIN' step → 4242 confirmed → blob written - Reload → PIN pad, 3 attempts remaining - Correct PIN 4242 → signed in, counter resets to 0 - 3 wrong PIN attempts → 'Wrong PIN — N attempts left' per attempt, then 'PIN locked — enter your full password to continue', blob wiped, next reload shows the password form - localStorage contains only ciphertext + salt + iv + counter; no plaintext mnemonic anywhere on disk or in sessionStorage.
2026-09-09 01:31:18 +02:00
// PIN-escrow for the Sirius.X built-in wallet.
//
// After a user signs in with their full password, they may set a 46 digit
// PIN. The wallet's mnemonic is encrypted with the PIN via PBKDF2 → AES-GCM
// and stored under localStorage."siriusPinBlob" (survives browser close, so
// next visit shows the PIN pad instead of the password field).
//
// Wrong PIN increments localStorage."siriusPinAttempts". After 3 wrong tries
// the PIN blob is destroyed and the user falls back to their full password —
// same UX pattern the Digibyte.x/web mobile wallet uses.
//
// Iteration counts are lower than the BuiltInWallet's 250k because a numeric
// PIN's key space is tiny anyway. The point of PIN encryption is "not
// plaintext in localStorage", not resistance to serious brute-force. Users
// keep the real password as their durable secret.
(() => {
const PIN_BLOB_KEY = "siriusPinBlob";
const PIN_ATTEMPTS_KEY = "siriusPinAttempts";
const MAX_ATTEMPTS = 3;
const PBKDF2_ITERATIONS = 50_000;
const enc = new TextEncoder();
const dec = new TextDecoder();
const b64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
const unb64 = (str) => Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
async function deriveKey(pin, salt) {
const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
function validPin(pin) {
return typeof pin === "string" && /^\d{4,6}$/.test(pin);
}
// Encrypt the mnemonic (or any string secret) with the PIN and stash it.
// Called right after a successful password unlock, when the user opts in
// to the PIN shortcut.
async function savePinBlob(secret, pin) {
if (!validPin(pin)) throw new Error("PIN must be 4-6 digits");
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(pin, salt);
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(secret));
const blob = {
v: 1,
it: PBKDF2_ITERATIONS,
s: b64(salt),
iv: b64(iv),
ct: b64(ct),
};
localStorage.setItem(PIN_BLOB_KEY, JSON.stringify(blob));
localStorage.setItem(PIN_ATTEMPTS_KEY, "0");
}
// Try decrypting with the supplied PIN. On success returns the mnemonic
// and resets the attempt counter. On failure increments attempts and
// wipes the blob when the ceiling is hit (forcing password fallback).
async function tryUnlock(pin) {
const raw = localStorage.getItem(PIN_BLOB_KEY);
if (!raw) throw new Error("no PIN set on this device");
if (!validPin(pin)) throw new Error("PIN must be 4-6 digits");
const blob = JSON.parse(raw);
try {
const key = await deriveKey(pin, unb64(blob.s));
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(blob.iv) }, key, unb64(blob.ct));
const secret = dec.decode(pt);
localStorage.setItem(PIN_ATTEMPTS_KEY, "0");
return secret;
} catch {
const now = (parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || "0", 10) || 0) + 1;
if (now >= MAX_ATTEMPTS) {
localStorage.removeItem(PIN_BLOB_KEY);
localStorage.removeItem(PIN_ATTEMPTS_KEY);
const err = new Error("PIN locked — enter your full password to continue");
err.locked = true;
throw err;
}
localStorage.setItem(PIN_ATTEMPTS_KEY, String(now));
const err = new Error(`Wrong PIN — ${MAX_ATTEMPTS - now} attempt${MAX_ATTEMPTS - now === 1 ? "" : "s"} left`);
err.attemptsRemaining = MAX_ATTEMPTS - now;
throw err;
}
}
function hasPin() {
try { return !!localStorage.getItem(PIN_BLOB_KEY); } catch { return false; }
}
function attemptsUsed() {
try { return parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || "0", 10) || 0; } catch { return 0; }
}
function attemptsRemaining() {
return Math.max(0, MAX_ATTEMPTS - attemptsUsed());
}
function clear() {
try { localStorage.removeItem(PIN_BLOB_KEY); localStorage.removeItem(PIN_ATTEMPTS_KEY); } catch {}
}
window.siriusPin = {
savePinBlob,
tryUnlock,
hasPin,
attemptsUsed,
attemptsRemaining,
clear,
MAX_ATTEMPTS,
};
})();