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