Browser extension wallets keep their deepest secrets in the most hostile runtime there is. The typical design decrypts mnemonics straight into JavaScript objects — inspectable in a debugger, copyable by any code that shares the context, and never reliably erased by a garbage collector. Gandalf is a development wallet I built for protocol work on my Tetra chain and a private Bitcoin devnet, designed around one uncompromising line: everything security-critical lives in Rust compiled to WebAssembly, and the boundary between Rust and JavaScript never passes a plaintext secret in either direction. The name is the security model: secrets shall not pass.

Role Design and implementation — Rust core, WASM boundary, extension shell
Stack Rust (gandalf-core, gandalf-wasm), wasm-bindgen, Chrome Manifest V3, TypeScript UI
Cryptography Argon2id, XChaCha20-Poly1305, BIP39, HD derivation, Ed25519, secp256k1 ECDSA + Schnorr (k256)
Ledgers Tetra + private Bitcoin devnet — BIP143 P2WPKH segwit and BIP341 P2TR Taproot key-path
Status Beta — stages 0–6 of a 10-stage acceptance-tested roadmap complete; 32 Rust tests

The problem

I needed a wallet that speaks two ledgers no mainnet wallet understands — Tetra (Ed25519, its own address formats and transaction shapes) and a private Bitcoin fork with ten-second blocks — without resorting to test wallets that treat security as optional because “it’s only a devnet.”

That framing is backwards. A development wallet sits next to debuggers, dev servers, experimental pages, and half-trusted tooling all day; its runtime is more hostile than a production user’s browser, not less. And the habits a wallet teaches its developer become the habits of the protocol it serves. So the constraint I set was: build the devnet wallet with a mainnet threat model, and treat the isolation architecture itself as a reusable reference pattern.

Concretely, four failure classes had to be closed:

  • Secrets in script memory — keys living in JS objects that any same-context code can read and no GC promise can erase.
  • Sprawling attack surface — content scripts injected into every page, remote code, deep dependency trees: each one a path from a hostile web page to key material.
  • Offline vault cracking — exported or synced vault files protected by fast, GPU-friendly KDFs are one wordlist away from compromise.
  • Wrong-chain confusion — a devnet wallet pointed at the wrong network signing something it shouldn’t.

Design decisions

The WASM wall

All key material handling lives in two Rust crates: gandalf-core (vault, derivation, signing) and gandalf-wasm (the boundary). The WASM API is serialized and typed, and it is constructed so that no call returns plaintext secrets — requests go in, signatures and public data come out. Sensitive buffers are zeroized after each operation. JavaScript’s role is reduced to rendering screens and relaying messages; even a fully compromised UI layer can at worst ask for a signature, and every ask surfaces in the confirmation flow with amount, fee, inputs, and change spelled out.

This is a stronger claim than “we’re careful with secrets.” It’s a structural property of the API surface: there is no function whose return type could carry a mnemonic or private key across the boundary. The compiler enforces what convention can’t.

Memory-hard vault crypto

The vault is sealed with Argon2id — chosen precisely because it is memory-hard, so an exported vault file resists GPU wordlist attacks — and XChaCha20-Poly1305 authenticated encryption. Vault metadata is authenticated too: a tampered vault fails closed rather than decrypting into garbage. Wrong-password and tamper paths are covered by dedicated tests, because failure behavior in a vault is as security-relevant as success behavior. The extended XChaCha nonce removes an entire class of nonce-reuse bookkeeping bugs.

One seed, two signature families

A single BIP39 mnemonic drives everything:

                         BIP39 mnemonic (24 words)
                                   │
                                   ▼
                          m / 1204' / 1980' / account' / 0 / 0
                                   │
                 ┌─────────────────┴─────────────────┐
                 ▼                                   ▼
          Ed25519 keys                        secp256k1 keys (k256)
          TETRA ledger                        BITCOIN devnet
          bech32 addresses                    ├─ ECDSA  → BIP143 P2WPKH (segwit v0)
                                              └─ Schnorr → BIP341 P2TR (Taproot key-path)

One backup phrase reproduces every account on both ledgers — the property that actually matters when a developer wipes a devnet for the tenth time. Address handling covers bech32 and base58; the Bitcoin sighash implementations (BIP143 and BIP341 with BIP340 tagged hashes) match the reference implementations, and the end-to-end proof is on-chain: a P2WPKH send and a Taproot key-path round trip confirmed on the devnet, plus Tetra transfers on a five-node localnet.

Minimal Manifest V3 surface

Strict Content-Security-Policy, no content scripts, no remote code, and permissions limited to storage plus an optional local-node host. Most wallet extensions inject into every page; Gandalf injects into none. Network identity is checked by genesis hash, so the wallet cannot be tricked into signing against the wrong chain even if an endpoint is misconfigured — the fourth failure class closed structurally rather than by operator care.

Architecture

  ┌─────────────────────────────────────────────────────────────┐
  │                CHROME EXTENSION (Manifest V3)               │
  │                                                             │
  │  ┌──────────────────┐        ┌───────────────────────────┐  │
  │  │ Popup / Onboard  │ typed  │ Background service worker │  │
  │  │ UI (TypeScript)  │◄──────►│ message router            │  │
  │  │ PUBLIC DATA ONLY │  msgs  └─────────────┬─────────────┘  │
  │  └──────────────────┘                      │                │
  │        strict CSP · no content scripts · no remote code     │
  ╞════════════════════════════════════════════╪════════════════╡
  │              THE WASM WALL                 ▼                │
  │  ┌───────────────────────────────────────────────────────┐  │
  │  │ gandalf-wasm — serialized typed API                   │  │
  │  │ requests in → signatures + public data out            │  │
  │  │ NO plaintext egress · buffers zeroized                │  │
  │  └─────────────────────────┬─────────────────────────────┘  │
  │  ┌─────────────────────────▼─────────────────────────────┐  │
  │  │ gandalf-core (Rust)                                   │  │
  │  │  Vault: Argon2id KDF · XChaCha20-Poly1305 AEAD        │  │
  │  │  HD: BIP39 · m/1204'/1980'/account'/0/0               │  │
  │  │  Sign: Ed25519 · k256 ECDSA · k256 Schnorr            │  │
  │  └───────────────────────────────────────────────────────┘  │
  └───────────────────┬──────────────────────┬──────────────────┘
                      ▼ broadcast            ▼ broadcast
              Tetra devnet node      Private Bitcoin devnet
              (genesis-hash          (Core-based signet fork,
               identity check)        ~10s blocks)

The life of a signature

 ① UNLOCK              ② DERIVE               ③ SIGN                ④ ZEROIZE & BROADCAST
 password ──► Argon2id  mnemonic ──► HD tree   UI review:            plaintext buffers
 stretches to vault key      │                 amount·fee·inputs     zeroized
      │                      ▼                      │                     │
      ▼                 account keys                ▼                     ▼
 XChaCha20-Poly1305     Ed25519 / k256         sign INSIDE WASM      signed tx submitted
 decrypts INSIDE WASM   addresses out          only the signature    to target devnet;
 (tamper ⇒ fail closed) as public data         crosses to JS         confirmation tracked

Secrets exist only inside the WASM module, and only for as long as the operation needs them. Every step’s failure mode is a test case: wrong password, tampered vault, network mismatch, rejected broadcast.

Process: an acceptance-tested roadmap

Gandalf follows a ten-stage roadmap in which an item moves to done only when its acceptance tests pass:

Stage Scope Status
0–2 Rust core, vault crypto, WASM boundary ✅ Done
3–4 MV3 shell, onboarding, accounts ✅ Done
5–6 Read-only sync; first end-to-end Tetra transfer (5-node localnet) ✅ Done
Bitcoin devnet adapter: segwit + Taproot sends confirmed ✅ Done
7 Reliability & recovery: failure matrix, encrypted vault export/import, mnemonic reveal, service-worker restart resilience Next
8 Security & release: fuzzing, threat-model review, dependency/license audit, no-secrets-in-logs, reproducible builds Next
9–10 Fee-economics simulator, multi-asset transfers, website connectivity, further ledger adapters (Cardano next) Planned

Writing the release checklist before the release is the discipline that keeps a security tool honest — stage 8 exists as an explicit gate, not a hope.

Status and limits

Gandalf is a development wallet, deliberately not for mainnet funds, and its remaining roadmap stages are exactly the reliability and release-hardening work that separates a working tool from a shippable one. The isolation pattern, though, is production-relevant as it stands: the trust line drawn below JavaScript, enforced by a boundary that structurally cannot leak, is what I would build first in any browser wallet.

What this demonstrates

Gandalf is threat-model-driven design in a small, sharp package: choosing where the trust boundary goes and enforcing it with language and runtime isolation rather than convention; selecting cryptography for the actual attack (memory-hardness against offline cracking, authenticated metadata against tampering); designing one HD scheme that spans two signature families; and pacing delivery with acceptance tests and a pre-written release gate. If you’re hardening a browser extension, building a wallet, or drawing trust boundaries in a hostile runtime, book a consultation.