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.
118 lines
4.4 KiB
JavaScript
118 lines
4.4 KiB
JavaScript
// 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,
|
||
};
|
||
})();
|