Qloak
A wallet that runs in a page.
wallet.qnero.io testnet, live
Qloak creates a spending key in the page, encrypts it there, scans the chain there, proves a payment there in a background worker, and contacts nothing but the node you configure. There is no server component and there is no account. The only thing between the page and a chain is one WebSocket to a node.
This is dev grade and browser hosted. Use it with testnet QNR only. Whoever serves these files can serve different ones tomorrow, and the different ones can send the seed somewhere. There is no code signing in a browser, and no way for a page to prove to you that it is the page you read the source of. That is the whole trust assumption, and nothing on this page softens it.
What it does
Create or restore
32 bytes from crypto.getRandomValues, shown once as eight groups of
eight hex characters, then three of the eight groups asked back before the wallet is
written. Somebody who wrote nothing down cannot answer, which is the point: that
screen is the last moment the key is recoverable.
A restore takes the 64 hex characters and nothing else. There is no restore height, because a scan that started at a height the wallet named would tell the node roughly when the wallet was created.
Lock
PBKDF2-SHA-256 at 600,000 iterations over a 16-byte salt derives one
non-extractable AES-256-GCM key. Each note's rho, r,
nullifier and memo, and the seed itself, are sealed under it with a fresh 12-byte IV
per record per write, bound to their own slot. The eight-character floor is enforced
at the key derivation, where the only path to a key is.
Sync
Every ciphertext on the chain is read by leaf index in batches and tried against this wallet's viewing key in the worker. Mining rewards are rebuilt from the miner key. The whole settled nullifier set is paged and spent status is decided locally. The node is told nothing about which leaves or which nullifiers belong to this wallet.
Balance and receive
The balance screen reads out of the local store, so a locked wallet still shows one. The receive screen holds the 2571-character address.
Settings
The node endpoint is chosen here at runtime, which is why one build serves the testnet and a local chain. The screen names the prover module the origin allowed, threaded or single, and it offers stopping the worker, which is the one thing that gives its memory back.
Send
The payment is proved in the worker and submitted over the same socket. Measured in the suite's own browser on the development workstation: 11.2 s to prove a payment on four threads against 37.6 s on one, and 24.6 s from pressing send to a settled block against 53.4 s.
Shielding is a command-line step
Entering the pool is a signed extrinsic, and the signature is ML-DSA-87 under the FIPS 204 context. The wasm module exports no signing at all. So a browser wallet can predict and recognise a shield, and it cannot make one.
Fund it from qnero-wallet shield followed by a send, or from
a node configured with its miner key. Closing this properly means a wasm-clean crate
split and an ML-DSA-87 signer in the module, and that work is queued.
In plain words
- The page host can do anything the page can. Whoever serves these files can serve different ones tomorrow, and the different ones can send the seed somewhere.
- A spend key is shown as hex and never as a code. A QR of one is harvested by any camera, screen share or shoulder in the room in one frame, so the seed screen prints hex with no code beside it and nothing in this wallet scans one.
- A locked wallet is only as strong as its passphrase. 600,000 PBKDF2 iterations is about a second per guess on this workstation, and far less on a machine built for guessing. A short passphrase is a short delay, and eight characters is a floor. The iteration count a store was written under is recorded in the store and is what opens it, so raising this build's figure seals new wallets harder and leaves existing ones readable.
-
Zeroing buys one buffer and no more. A JavaScript string is
immutable and garbage collected, so a seed that has ever been a string cannot be
wiped, and
crypto.subtle.decrypthands back a buffer allocated before this code sees it. Secrets are carried asUint8Arrayand zeroed when done, which erases that one buffer. - IndexedDB is evictable, and the seed is what recovers from that. The wallet asks for persistent storage and reports the answer. Write the seed down. Everything this wallet holds re-derives from it, because each transfer's plaintext is on the chain inside its ciphertext, so an eviction costs a full rescan and the record of what has been spent. Without the seed it costs the wallet.
-
The store keeps the value graph in the clear. Four fields are
sealed, plus the seed:
rho,r, the nullifier and the memo. Everything else is plaintext by choice, so that a locked wallet can still show a balance and still sync: the address, and per note its commitment, its leaf index, its block, its value, its origin and whether it is spent. A copied browser profile therefore gives up this wallet's complete receive history mapped onto public leaf indices, with no passphrase guess needed, which is the linkage the pool exists to hide. A threat model that wants the graph hidden has to seal the value and the leaf index too, and give up the locked balance screen. - What a chain reader sees is a settlement, its fee, its anchor block, two commitments and two ciphertexts of a fixed size. The amounts, the sender, the recipient and which output is the change all stay hidden. The gap between a spend's anchor block and its inclusion block is public, and it tracks the speed of the machine that proved it.
-
This wallet verifies no proof of work, and will not in v1. A
RandomX verification wants a 256 MiB cache and has no browser build, so above the
newest checkpoint a node picks every header field. Two per-leaf values are bound to
a leaf by nothing on chain: the ciphertext bytes, and where a commitment sits inside
its block's leaf range. A node whose headers check out can move an incoming payment
onto a position this wallet reads as somebody else's, and the payment reads as never
arriving. A rescan against a second node is the recovery, and a pass that read
leaves and received nothing says so on the balance screen.
state_getReadProofin M13 closes it. - What the node learns is your network address, that a wallet syncs from it and roughly how often, and the exact bytes of every settlement submitted through it. Which leaf and which nullifier are yours stay unasked. The one request that names a nullifier is the confirmation of a settlement this wallet has just broadcast, whose proof published both of them a moment ago.
Against the testnet, or a local chain
The hosted copy at wallet.qnero.io is already pointed at the testnet. To run the wallet from source instead, start a local node and fund an address with the command-line wallet first. The repository README carries the build preconditions.
The dev server runs the wallet at http://127.0.0.1:5173 against the
node on port 9944. One runtime JSON file points a built copy at a different chain,
so one build serves the testnet and a local chain.
stage-wasm.sh --threaded stages the threaded prover module. Without it,
or without the two headers beside this, the page loads the single-threaded one.
What a host has to send
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp, which is what a page needs
before SharedArrayBuffer exists, and therefore before the threaded
prover can run. Without them the page falls back to the single-threaded module and
the settings screen says which one it got.
The built page carries its own content policy, so a static drop is already covered.
It refuses every http(s) destination, every third-party script, style,
image and font, every <base> rewrite and every form post.
The residual path
The wide policy still permits a WebSocket to any host. With
connect-src 'self' ws: wss:, a socket opened to an arbitrary host
raises no violation. That is the residual exfiltration path, and it is the price of
choosing the endpoint at runtime from the settings screen. A deployment with a fixed
node closes it at build time.
cd wallet-web
nice -n 19 npm ci
./scripts/stage-wasm.sh --threaded
nice -n 19 npm run dev
Four threads, on purpose
Proving runs in a dedicated worker, always. On a cross-origin isolated origin the
worker loads the threaded module and starts a rayon pool of
min(hardwareConcurrency, 4).
The cap is four on purpose. Beyond four the measured return falls off, every thread reserves its own stack against a shared memory whose maximum is declared at build time, and a wallet that takes every core of the machine it is a tab on is a wallet somebody closes. The circuits hold most of a gigabyte of linear memory once built and wasm linear memory never shrinks, so the circuits are built once per worker and every later payment is answered from that build. The settings screen offers stopping the worker by name, which is the one thing that gives the memory back.