git / moonbite-network / static/moonbite-pin-guard.js · raw
/* MoonBite wallet PIN guard — shared by the flagship and classic wallets. * * A 6-digit PIN has a keyspace of 10^6, so an unthrottled on-device guess loop * is a matter of minutes. This module persists a failed-attempt counter and a * next-allowed timestamp in localStorage so that: * - after MAX_FREE failures each further failure doubles the wait * (1s, 2s, 4s ... capped at 15 minutes); * - after WIPE_AT consecutive failures the encrypted seed blob (and related * keys) is erased, so an attacker who keeps guessing on a stolen device is * left with nothing to decrypt. The owner restores from the recovery * phrase. * A correct PIN resets the counter. * * Note: this is a deterrent against on-device guessing. It does not change the * offline strength of the encrypted blob itself (that is the KDF's job). */export const PIN_GUARD_DEFAULTS = Object.freeze({ maxFree: 3, // failures allowed before backoff starts wipeAt: 10, // consecutive failures that wipe the wallet baseDelayMs: 1000, // first backoff step maxDelayMs: 15 * 60 * 1000,});function _now() { return Date.now(); }export function createPinGuard(opts) { const o = Object.assign({}, PIN_GUARD_DEFAULTS, opts || {}); const storageKey = o.storageKey || 'moonbite_pin_guard'; const wipeKeys = Array.isArray(o.wipeKeys) ? o.wipeKeys : []; const onWipe = typeof o.onWipe === 'function' ? o.onWipe : null; function _load() { try { const raw = localStorage.getItem(storageKey); if (!raw) return { fails: 0, until: 0 }; const j = JSON.parse(raw); return { fails: Math.max(0, parseInt(j.fails, 10) || 0), until: Math.max(0, parseInt(j.until, 10) || 0), }; } catch (e) { return { fails: 0, until: 0 }; } } function _save(st) { try { localStorage.setItem(storageKey, JSON.stringify({ fails: st.fails, until: st.until })); } catch (e) { /* storage unavailable: the in-memory state still applies for this page */ } } // In-memory mirror so the guard still throttles when localStorage is blocked. let mem = _load(); function delayFor(fails) { if (fails <= o.maxFree) return 0; const exp = fails - o.maxFree - 1; // 0 on the 4th failure return Math.min(o.maxDelayMs, o.baseDelayMs * Math.pow(2, exp)); } function state() { const st = _load(); // Take the stricter of persisted and in-memory state. if (mem.fails > st.fails) st.fails = mem.fails; if (mem.until > st.until) st.until = mem.until; mem = st; const waitMs = Math.max(0, st.until - _now()); return { fails: st.fails, waitMs, allowed: waitMs === 0, remaining: Math.max(0, o.wipeAt - st.fails), }; } /* Returns {allowed, waitMs, remaining, fails}. Call before verifying a PIN; if !allowed, do not run the decrypt at all. */ function check() { return state(); } /* Record a failed attempt. Returns {wiped, waitMs, remaining, fails}. */ function fail() { const st = state(); const fails = st.fails + 1; if (fails >= o.wipeAt) { wipe(); return { wiped: true, waitMs: 0, remaining: 0, fails }; } const waitMs = delayFor(fails); mem = { fails, until: waitMs ? _now() + waitMs : 0 }; _save(mem); return { wiped: false, waitMs, remaining: o.wipeAt - fails, fails }; } /* A correct PIN clears the counter. */ function reset() { mem = { fails: 0, until: 0 }; try { localStorage.removeItem(storageKey); } catch (e) {} } /* Erase the encrypted wallet material. Idempotent. */ function wipe() { for (const k of wipeKeys) { try { localStorage.removeItem(k); } catch (e) {} } reset(); if (onWipe) { try { onWipe(); } catch (e) {} } } /* Human-readable status for the UI (no emoji). */ function message(r) { const s = r || state(); if (s.wiped) { return 'Too many wrong PINs. The wallet on this device has been erased. Restore it with your recovery phrase.'; } if (s.waitMs > 0) return 'Too many wrong PINs. Try again in ' + formatWait(s.waitMs) + '.'; if (typeof s.remaining === 'number' && s.remaining <= o.wipeAt - o.maxFree) { return 'Wrong PIN. ' + s.remaining + (s.remaining === 1 ? ' attempt' : ' attempts') + ' left before this wallet is erased from this device.'; } return 'Wrong PIN.'; } return { check, fail, reset, wipe, message, delayFor, storageKey };}export function formatWait(ms) { const s = Math.ceil(ms / 1000); if (s < 60) return s + (s === 1 ? ' second' : ' seconds'); const m = Math.ceil(s / 60); return m + (m === 1 ? ' minute' : ' minutes');}