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.