git / moonbite-network / tests/test_client_hardening_2026_10_09.py · raw

"""Client-side hardening from the 2026-10-09 infra audit (M8, L2, L3, L4, L7, L8, L10).Each test pins one fix so it cannot silently regress:  M8  shared PIN guard: backoff after 3 failures, wipe after 10  L2  classic wallet keeps onboarding secrets out of sessionStorage / window  L3  legacy miner refuses non-loopback RPC hosts without --insecure-remote  L4  service worker never caches per-address API data  L7  bech32 decoder rejects mixed case / overlong / malformed input  L8  marketing page CDN scripts carry SRI hashes  L10 stale root-level wallet-sw.js is gone; server serves static/wallet-sw.js"""import importlib.utilimport jsonimport reimport shutilimport subprocessimport sysfrom pathlib import Pathimport pytestREPO = Path(__file__).resolve().parent.parentnode = shutil.which("node")requires_node = pytest.mark.skipif(node is None, reason="node is not installed")def _run_node_module(script: str, name: str) -> str:    tmp = REPO / f"_{name}_check.mjs"    tmp.write_text(script, encoding="utf-8")    try:        proc = subprocess.run([node, str(tmp)], cwd=REPO, capture_output=True,                              text=True, timeout=120)        if proc.returncode != 0:            raise AssertionError(f"node failed: {proc.stderr.strip()}")        return proc.stdout    finally:        tmp.unlink(missing_ok=True)# ---------------------------------------------------------------- M8 guard --@requires_nodedef test_pin_guard_backoff_and_wipe():    script = """    import { createPinGuard } from './static/moonbite-pin-guard.js';    const store = new Map();    globalThis.localStorage = {      getItem: k => store.has(k) ? store.get(k) : null,      setItem: (k, v) => store.set(k, String(v)),      removeItem: k => store.delete(k),    };    store.set('seed', 'blob'); store.set('bio', 'blob2');    let wiped = 0;    const g = createPinGuard({ storageKey: 'guard', wipeKeys: ['seed','bio'], onWipe: () => wiped++ });    const log = [];    for (let i = 0; i < 10; i++) {      const r = g.fail();      log.push({ fails: r.fails, waitMs: r.waitMs, wiped: r.wiped, remaining: r.remaining,                 allowed: g.check().allowed });    }    console.log(JSON.stringify({ log, seedLeft: store.has('seed'), bioLeft: store.has('bio'),      guardLeft: store.has('guard'), wiped, cap: g.delayFor(100) }));    """    out = json.loads(_run_node_module(script, "pin_guard"))    log = out["log"]    # three free attempts    assert [e["waitMs"] for e in log[:3]] == [0, 0, 0]    assert all(e["allowed"] for e in log[:3])    # exponential backoff from the 4th failure    assert [e["waitMs"] for e in log[3:9]] == [1000, 2000, 4000, 8000, 16000, 32000]    assert not log[3]["allowed"]    assert log[8]["remaining"] == 1    # tenth consecutive failure wipes the wallet material    assert log[9]["wiped"] is True    assert out["seedLeft"] is False and out["bioLeft"] is False    assert out["guardLeft"] is False and out["wiped"] == 1    assert out["cap"] == 15 * 60 * 1000@requires_nodedef test_pin_guard_reset_clears_counter_and_persists():    script = """    import { createPinGuard } from './static/moonbite-pin-guard.js';    const store = new Map();    globalThis.localStorage = {      getItem: k => store.has(k) ? store.get(k) : null,      setItem: (k, v) => store.set(k, String(v)),      removeItem: k => store.delete(k),    };    const a = createPinGuard({ storageKey: 'g', wipeKeys: [] });    a.fail(); a.fail(); a.fail(); a.fail();    // a fresh instance (page reload) must see the persisted lockout    const b = createPinGuard({ storageKey: 'g', wipeKeys: [] });    const before = b.check();    b.reset();    const after = createPinGuard({ storageKey: 'g', wipeKeys: [] }).check();    console.log(JSON.stringify({ before, after }));    """    out = json.loads(_run_node_module(script, "pin_guard_reset"))    assert out["before"]["allowed"] is False and out["before"]["fails"] == 4    assert out["after"]["allowed"] is True and out["after"]["fails"] == 0def test_both_wallets_wire_the_guard():    flagship = (REPO / "static" / "wallet-flagship.js").read_text(encoding="utf-8")    assert "moonbite-pin-guard.js" in flagship    # every decrypt-with-PIN site is preceded by a guard check and followed by fail/reset    assert flagship.count("guardBlocked(") >= 6    assert flagship.count("guardFail(") >= 3    assert flagship.count("pinGuard.reset()") >= 4    assert "IDLE_LOCK_MS" in flagship  # inactivity auto-lock    pwa = (REPO / "templates" / "wallet-pwa-app.html").read_text(encoding="utf-8")    assert "moonbite-pin-guard.js" in pwa    assert pwa.count("pinGuardBlockedMessage()") >= 3    assert pwa.count("pinGuardFail()") >= 3    assert pwa.count("pinGuard().reset()") >= 4# ------------------------------------------------------------------- L2 ----def test_classic_wallet_keeps_onboarding_secrets_in_closure():    pwa = (REPO / "templates" / "wallet-pwa-app.html").read_text(encoding="utf-8")    assert "sessionStorage.setItem('pendingSeedPhrase'" not in pwa    assert "sessionStorage.setItem('recoverySeedPhrase'" not in pwa    assert "sessionStorage.getItem('pendingSeedPhrase'" not in pwa    assert "sessionStorage.getItem('recoverySeedPhrase'" not in pwa    assert "window.tempPin =" not in pwa    assert "=== window.tempPin" not in pwa    assert "addEventListener('pagehide', clearOnboardingSecrets)" in pwa# ------------------------------------------------------------------- L3 ----def _load_miner():    spec = importlib.util.spec_from_file_location(        "moonbite_miner", REPO / "miner" / "moonbite_miner.py")    mod = importlib.util.module_from_spec(spec)    spec.loader.exec_module(mod)    return moddef test_miner_loopback_detection():    m = _load_miner()    for h in ("127.0.0.1", "127.9.9.9", "localhost", "LOCALHOST", "::1", "[::1]"):        assert m.is_loopback_host(h), h    for h in ("10.0.0.5", "192.168.1.2", "moonbite.org", "0.0.0.0", "", "165.232.106.9"):        assert not m.is_loopback_host(h), hdef test_miner_refuses_remote_rpc_host_without_flag(capsys):    m = _load_miner()    with pytest.raises(SystemExit) as exc:        m.main(["--address", "moon1qtest", "--rpc-host", "203.0.113.7"])    assert exc.value.code == 2    err = capsys.readouterr().err    assert "not loopback" in err and "--insecure-remote" in err# ------------------------------------------------------------------- L4 ----def test_service_worker_never_caches_address_data():    sw = (REPO / "static" / "wallet-sw.js").read_text(encoding="utf-8")    assert "CACHE_VERSION = 'moonbite-wallet-v6-fresh'" not in sw, "cache name must be bumped"    assert "isSensitiveApi(url.pathname)" in sw    for p in ("/api/chain/", "/api/explorer/tx", "/api/wallet/", "/api/explorer/address"):        assert f"'{p}'" in sw, p    # the sensitive branch must come before the generic cached /api/ branch    assert sw.index("isSensitiveApi(url.pathname)") < sw.index("url.pathname.startsWith('/api/')")# ------------------------------------------------------------------- L7 ----@requires_nodedef test_bech32_decoder_rejects_malformed_input():    script = """    import { deriveFromSeedPhrase, isValidAddress } from './static/moonbite-address.js';    const d = await deriveFromSeedPhrase('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');    const a = d.address;    const mixed = a.slice(0, 6) + a.slice(6, 8).toUpperCase() + a.slice(8);    const out = {      lower: isValidAddress(a),      upper: isValidAddress(a.toUpperCase()),      mixed: isValidAddress(mixed),      tooLong: isValidAddress(a + 'q'.repeat(91 - a.length)),      noSep: isValidAddress(a.replace('1', 'q')),      sepAtZero: isValidAddress('1' + a.slice(1)),      otherHrp: isValidAddress('tmoon' + a.slice(4)),      nonString: isValidAddress(12345),      empty: isValidAddress(''),      controlChar: isValidAddress(a.slice(0, -1) + '\\u0001'),    };    console.log(JSON.stringify(out));    """    out = json.loads(_run_node_module(script, "bech32"))    assert out["lower"] is True    assert out["upper"] is True  # all-uppercase is legal bech32    for k in ("mixed", "tooLong", "noSep", "sepAtZero", "otherHrp", "nonString", "empty", "controlChar"):        assert out[k] is False, k# ------------------------------------------------------------------- L8 ----def test_marketing_page_cdn_scripts_have_sri():    html = (REPO / "moonbite-site" / "index.html").read_text(encoding="utf-8")    tags = re.findall(r'<script src="(https://[^"]+)"([^>]*)>', html)    assert len(tags) == 4, tags    for src, attrs in tags:        assert re.search(r'integrity="sha384-[A-Za-z0-9+/=]{64}"', attrs), src        assert 'crossorigin="anonymous"' in attrs, src# ------------------------------------------------------------------ L10 ----def test_root_service_worker_removed_and_static_one_served():    assert not (REPO / "wallet-sw.js").exists()    web = (REPO / "web_app.py").read_text(encoding="utf-8")    assert '"static", "wallet-sw.js"' in web# ------------------------------------------------------- release README ----def test_release_readme_has_relaunch_genesis_and_no_example_password():    readme = (REPO / "release" / "README.md").read_text(encoding="utf-8")    assert "ffe25d9248b7f56d8934719ed5c7357f7595451c4b9d391d9b8377e62a83e39c" in readme    assert "7b93fff9db74d851ca19bd9a976d018d78010cfba61ee06cd33d4160d84c8909" not in readme    assert "3d053c590c9dcaa972d12f20793cde15e060f6cbcd1ea7bd05f4c80724f39573" not in readme    assert "rpcpassword=big" not in readme