>_ DigitalGuards
← all posts
2026-07-12 myqrlwalletdesktopsecurityelectron

MyQRLWallet Desktop: A Post-Quantum Wallet Where the UI Never Touches a Key

Our QRL wallet is now a desktop app. Under the familiar interface sits a hardened four-process Electron architecture: a sandboxed renderer, a narrow bridge, a brokering main process, and an isolated signer that is the only place plaintext key material ever exists.

MyQRLWallet started as a web wallet, grew a mobile app and a browser extension, and now ships as a desktop app for Windows and Linux: MyQRLWallet Desktop, currently at v0.3.5. Wrapping a web wallet in Electron is easy to do badly, and wallet history is full of examples where “the desktop app” meant the same JavaScript with filesystem access bolted on. We built ours around one non-negotiable property instead.

The MyQRLWallet Desktop home screen showing the active account, balance, and token list The wallet home in the new Obsidian & Ember design. This is the real web wallet frontend, rendered by a process that has no access to keys, Node, or the filesystem.

The cardinal property

A fully compromised renderer yields no key material, because keys never live there. Not “the renderer protects its keys well”: the process that renders the UI has nothing to steal. The mnemonic, the hex seed, and the ML-DSA-87 secret key are materialised in exactly one place, an isolated signer process, and everything else in the app can only ask it for a signature over a payload the user explicitly confirmed.

Four-process architecture: renderer, preload bridge, main broker, isolated signer Trust runs lowest to highest, left to right. Key material never moves down the gradient.

Four processes, one trust gradient

The renderer is the wallet UI: React 19, fully sandboxed (sandbox: true, contextIsolation: true, nodeIntegration: false), treated as hostile. Its content security policy is script-src 'self' with no inline script and no eval, so a renderer compromise can neither inject nor evaluate code. Its in-page seed and signing primitives are neutered under desktop: they throw if reached, as defense-in-depth.

The preload layer mounts a single narrow API, window.qrlWallet, over Electron’s contextBridge. No raw ipcRenderer, no require, no channel strings: only named, typed wrappers like getBalance, requestSignature, and sendRawTransaction.

The main process is a broker, trusted but deliberately key-free. Every IPC handler first validates the sender (top frame of the wallet window, file: origin, sub-frames rejected) and then parses its argument against a zod schema that rejects malformed, oversized, or extra-keyed payloads before anything acts on them. Main owns the window lifecycle, the RPC proxy, the trusted confirmation modal, and the encrypted seed file on disk, which it never decrypts.

The signer runs as an isolated Electron utilityProcess, speaks only to main over parentPort, and is the sole holder of plaintext key material, transiently: it derives, signs, and zeroizes every secret buffer on its way out. The renderer has no handle to it at all.

What a spend actually goes through

  1. The renderer calls window.qrlWallet.requestSignature({ kind: 'transaction', tx }).
  2. Preload forwards it over the signature IPC channel.
  3. Main validates the sender and zod-parses the payload.
  4. Main draws a trusted confirmation modal, a native window the renderer cannot spoof or overdraw, showing exactly what is being signed and to whom.
  5. On approval, the signer unwraps the seed, signs with ML-DSA-87, zeroizes, and returns the raw signed transaction.
  6. The renderer broadcasts it through the brokered RPC proxy.

Signing is fully offline and byte-faithful to the web wallet, so signatures verify identically, and the chain ID shown in the confirmation is exactly the one signed. The signing implementation is @theqrl/mldsa87, the Halborn-audited ML-DSA-87 (FIPS 204, NIST security level 5) library, and it runs only inside the signer.

The same trusted-window rule covers the other irreversible action: removing a wallet requires a main-drawn confirmation that names the address before the encrypted seed is deleted.

The native unlock window with account picker and password field The unlock window is app-drawn native UI. The password never passes through the sandboxed frontend.

Keys at rest

The encrypted seed sits on disk in one envelope per account: an Argon2id password-derived key encrypting the seed under AES-256-GCM, with the KDF parameters persisted alongside so they can be calibrated per hardware generation without breaking existing wallets. You can hold any number of accounts, each under its own password; the signer keeps at most one session unlocked at a time, and a sliding auto-lock zeroizes it after inactivity.

We are deliberately honest about the OS-integration layer. Keychain and safeStorage bindings raise attacker cost and generate detection signal, but same-user malware defeats them, as the long history of Chrome app-bound-encryption bypasses shows. That is why the password is the load-bearing control: even if every OS binding falls, the attacker holds an Argon2id-hardened, AES-256-GCM-sealed blob. The full analysis, including what we do not defend against, is in the repo’s THREAT_MODEL.md.

The renderer is the web wallet, unforked

The desktop UI is not a port of qrlwallet.com; it is the same myqrlwallet-frontend, built by its own toolchain into the app bundle. The frontend source is never copied into the desktop repo, so the two cannot drift, and a desktop UI update is just a rebuild of the current frontend.

Making an unmodified web bundle behave inside a hardened shell took a few precise seams. The app registers a custom file:// protocol handler that attaches the strict CSP as a real response header (Electron’s webRequest hooks do not reliably fire for file:// document loads) and contains every served path to the app bundle, so even a compromised renderer cannot read arbitrary host files. Routing switches from history-based to hash-based at runtime when window.qrlWallet is present, and every key-touching call site in the frontend delegates to the bridge under desktop. The web build stays byte-identical.

The shell itself is locked down the rest of the way: Electron fuses flipped at package time, ASAR integrity validation, deny-by-default renderer permissions, navigation lockdown with a trusted-host allowlist for external links, and DevTools disabled in packaged builds.

dApps, out of the box

The desktop app registers the qrlconnect:// scheme, so pairing with a dApp is one click from the browser, with cold-start buffering (a link that launches the app still arrives after the renderer boots) and rate limiting (a launch flood collapses to one consent prompt). Every dApp signature request passes through the same trusted confirmation modal, tagged with the requesting origin.

In practice this means QuantaSwap works with the desktop wallet today: pair, and the HTLC lock, claim, and refund calls arrive as signature requests with proper gas estimation (contract calls are estimated via qrl_estimateGas with a 1.2x buffer, and an estimate that would revert is refused before signing, which saves the on-chain fee).

Obsidian & Ember

v0.3.5 ships the new MyQRLWallet design system across the renderer and both native windows: deep warm-ink surfaces with layered ember glow, ember orange as the single action color, QRL battery blue as the identity color, and self-hosted Sora, Instrument Sans, and JetBrains Mono. The unlock and settings windows are retokened to match, so the native and web surfaces read as one app. None of it touches key handling, signer isolation, IPC, or the security model.

Get it

Prebuilt installers are on the releases page: a Windows installer, a Linux AppImage, and a Debian/Ubuntu .deb, each release with SHA-256 checksums. The binaries are currently unsigned, so Windows SmartScreen warns on first launch and the AppImage needs chmod +x; verify the checksums, and expect Authenticode signing and a macOS build as follow-ups. The signer logic ships with a node --test suite covering KDF determinism, AEAD round-trip and tamper rejection, offline transaction signing bound to the chain ID, and the IPC schema boundary.

The code, the architecture research, and the threat model are all public at github.com/DigitalGuards/myqrlwallet-desktop. If you find a hole in the model, we want to hear about it.

← more from the blog work with us