sirius/js/pin-escrow.js
Local Dev 495054ca8a 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

118 lines
4.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
};
})();