// PIN-escrow for the Sirius.X built-in wallet. // // After a user signs in with their full password, they may set a 4–6 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, }; })();