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');}