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