A review of the web wallet’s cryptography after chunk 16. It checked how randomness is drawn, where it’s drawn needlessly, and whether a secret can leak. Branch web-wallet/crypto-review, from web-wallet/lovejoin.
Scope:
seedelf-crypto: the derivation, the Seedelf Schnorr prover, Lovejoin’s provers, registers.seedelf-core the wallet uses: script spends, Lovejoin’s mixes and withdraws.| # | Severity | What | Fix |
|---|---|---|---|
| 1 | High | A one-time session account could be used twice. A new session took the next index from the sealed session record, and that record is only on this device. A restored wallet, one removed and restored, the same phrase in another browser, or a record that failed to open all start again at 0. Two sessions on one key are linked on chain. | Before funding a session, the worker asks Koios which of the next 20 indexes’ stake keys any address has used (account_addresses, one request), and takes the first that none has (SessionService.freshIndex). Since the launch review (#22), it asks about the next index alone first, and the 20 only when that one was used (keys-and-accounts.md). Bring back and Bring everything back now build in turn with the sends: a save they overlapped could roll the record back. |
| 2 | Medium | The vault’s entropy reached every open UI page. The UI’s chrome.storage.onChanged listener was given session storage’s changes, the entropy among them at every unlock and lock. |
The UI listens to chrome.storage.local.onChanged alone. |
| 3 | Medium | A Seedelf proof’s nonce was only as good as the random source. A repeated nonce on two statements reveals the Seedelf key: every private UTxO, and every Lovejoin box. | The nonce is hedged: HMAC-SHA-512 keyed by the scalar, over the register, the one-time key’s hash and 32 fresh random bytes (schnorr::proof_nonce). The verifier is unchanged, and the CLI gets it too. |
| 4 | Medium | Lovejoin withdraws came back in a burst (privacy). Boxes are withdrawn only while unlocked, and the minute alarm stopped once no swap or chain ran. So after a lock that outlasted their delays (1–6 hours against a 15-minute lock), every due box went back to back at the next unlock. That undid much of the random delay. | The user’s call (2026-09-25): “even if they all came out in a burst then delay them so its not a chunk.” One box a run; each of the others due waits a fresh 5 to 60 minutes, drawn on its own (WITHDRAW_SPREAD_MS). The alarm keeps running while boxes wait and the wallet is unlocked. |
| 5 | Medium | Drafts went to Koios’s Ogmios for their budgets. A draft carries valid proofs, so Koios learned which contract UTxOs an IP address owns, even for a review never sent. | The user’s call: the wallet’s own evaluator. buildMint, buildTransfer, buildWithdraw and buildRemove prove and measure in one WebAssembly call (ScriptSpend::measure_locally), a request fewer each. The recorded preprod transfer’s fee comes out the same, or 602 lovelace less: the scripts see the signers sorted by hash, so a one-time key hash that sorts before giveme.my’s (about 7% of them) is found a step sooner. The account-paid mint keeps Ogmios: its draft is public-account data, and a public UTxO may carry a reference script the evaluator doesn’t take yet. |
| 6 | Low | Lovejoin’s withdraw delays used Math.random, whose state can be worked out from its outputs. The delay is what keeps a withdraw from being matched to its deposit. |
secureRandom(): 53 bits from crypto.getRandomValues. |
| 7 | Low | Content scripts could read chrome.storage.local (Chrome’s default), which holds the sealed vault and the unsealed settings. The connector’s bridge runs in every site’s renderer. |
The worker sets local and session storage to TRUSTED_CONTEXTS at every start. The call is checked on Chrome 153; an older Chrome that refuses it keeps the default. |
| 8 | Low | A connected site could hang the worker. The CBOR reader read past the end of what a site passed to submitTx or signTx, and 84bf looped forever. |
Every read is bounded: a length or count longer than what’s left throws. tests/cbor.test.ts. |
| 9 | Low | dApp approval ids were a count that started again with the worker. A window still showing an old request 1 could answer a new one. | crypto.randomUUID(). |
| 10 | Low | Two connects from one site (two tabs, or enable() twice) could leave it on the public account after the user had funded a private session for it. |
Funding is refused once the site is connected, both at build and at send. |
| 11 | Info | Randomness and the key were used where nothing needed them. Sizing an output drew two random scalars and did three multiplications a call. A Lovejoin withdraw’s first, thrown-away build was proven with the Seedelf key. | A fixed register for sizing (every datum is the same size), and a fixed stand-in proof. |
| 12 | Info | Secret wipes could be optimized away. Plain fill(0) and assignment before a free are dead stores to the compiler. |
zeroize for the scalar on free(), the rebuilt phrase, the BIP39 seed, and the HKDF and one-time key material. bip39’s zeroize feature wipes its mnemonics.It was incomplete (launch review #32): parse_phrase’s strings, the phrase String handed to pallas and cryptoxide’s HKDF key were left in memory unwiped after lock. Now unlock doesn’t write the phrase out, and every copy the wallet’s code makes is wiped. It’s still best effort: copies inside the libraries, on the stack and in JavaScript remain. |
getrandom’s js backend (crypto.getRandomValues), and a failure throws rather than yielding zeros. The places it’s drawn:
d on both points;externally_connectable and no web_accessible_resources.console output, and no key material in errors.Sig_structure, so a site’s message can never be a transaction signature.packages/lib/core/src/secret-box/kdf.ts has exactly these values.SBV2 magic and a reseal at the next unlock.chrome.storage.session while unlocked. That is what lets a restarted worker stay unlocked.
withinFunding bounds the amount, not the receiver.