fix: WALLET-1364 — close the P3 residual - #1466
Merged
Merged
Conversation
…re-encrypt debounce
…rd the change-password submit
Comp0te
force-pushed
the
WALLET-1364-p3-residual
branch
from
August 17, 2026 08:50
a5e3a3e to
40c1e4c
Compare
Comp0te
reviewed
Aug 17, 2026
Comp0te
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed the six residual fixes against the flows they touch. The comments cluster on the two items that moved work into worker callbacks: the new onerror handlers can't fire for the failures they were added to report, and the change-password page now outlives its worker without owning it. One more is about the constant-time comparison being unwired-able at its call site without turning anything red. The CSP and export changes read as sound, and the item-15 rationale is convincing as written.
The onerror handlers added for the password workers cannot fire for the
failures they were meant to report. All three workers are written
`onmessage = async` with no try/catch, so anything that throws inside —
encodePassword, deriveEncryptionKey, decryptVault's GCM tag check,
encryptVault's missing-vault guard — becomes a rejected promise, and a
rejection in a worker raises no error event on the parent Worker. Only a
script load failure reaches onerror.
In change-password that was worse than the bug it replaced: isSubmitting
is cleared only inside onerror, so a rejected worker left the button
disabled on "Loading" forever, with no message and no navigation. Before,
the user at least left the page.
Failures now travel as a message ({ error: true }, no payload — the error
object stays in the worker's console rather than reaching the UI), and
the pages branch on isWorkerError. onerror is kept for the script load
case it does cover.
password-protection-page consumes the same verify-password worker, so it
had to learn the message too: without it a worker failure would read as a
wrong password, burn a login attempt and eventually lock the wallet. Its
onerror is no longer silent either.
Second defect, same page: the create-password worker outlived the page.
It was never terminated and there is no unmount cleanup, while the back
link stays live throughout — only the submit button is disabled. Pressing
back during the worker's ~1-2 s of scrypt still committed the rotation
from a stale closure, and react-router's post-unmount guard does not stop
the navigate that follows (activeRef is set in a layout effect with no
cleanup, so it stays true after unmount). The worker is now held in a ref
and terminated on unmount, and both callbacks check a mounted flag.
unlock-vault also leaked a worker per attempt: two were spawned on every
submit and neither was terminated, including the unlock worker that a
wrong password never uses.
generate-sync-wallet-qr-data-worker has the same shape as the three password workers: `onmessage = async` with no try/catch, and a consumer whose onerror can never fire. It can throw for real — scryptAsync, PublicKey.fromHex on a corrupt imported key, Buffer.from. The page hung on failure. generateQRCode was async but resolved as soon as the worker was created, so setIsLoading(false) lived only in the dead onerror and the password page span forever. It now returns a promise that resolves when the QR data exists and rejects when the worker fails, which routes the failure into the .catch that PasswordProtectionPage already wraps onClick in — until now unreachable. The worker is terminated on both paths. That catch only logged, so the failure would still have been silent. It now also puts a message on the password field, matching what the other two password screens do. wallet-qr-code is the only caller passing onClick, so nothing else changes. The cipher is untouched: the AES-CBC format is a cross-application contract with casper-wallet-mobile and stays as it is. This is delivery of the failure, not the format.
…e compare constantTimeEqualHex is tested in isolation, but nothing observed that verifyPasswordAgainstHash routes through it: its three tests assert only correct-password → truthy and wrong-password → falsy, which plain === satisfies identically. Reverting the call site to === left the suite green, so the helper could end up exported, tested and unwired. An upper-case hash is the one input where the two differ — === says false, the byte comparison says true. Reverting the call site now fails. The constant-time property itself stays unenforceable here: mutating the loop to break early does not change the output, only the timing.
Comp0te
approved these changes
Aug 18, 2026
ost-ptk
added a commit
that referenced
this pull request
Aug 18, 2026
`develop` kept evolving the page-worker password change this branch removes: #1466 gave `create-password-worker` the `{ error: true }` result contract, moved the vault encryption into it, and had the page dispatch `keysUpdated` / `encryptionKeyHashCreated` / `vaultCipherCreated` itself. That is the exact round-trip this branch deletes, so the page and the worker resolve to this branch's shape, and `workers.test.ts` loses the `create-password-worker` row along with the module. The `{ error: true }` contract it was given is not lost for this flow — a worker that no longer exists cannot swallow a rejection, and the saga reports through `sagaError` instead. `vault-sagas.test.ts` was a false conflict: both sides appended a new `describe` at the end of the file. Both are kept. `verifyPasswordAgainstHash`, which `changePasswordSaga` now calls to verify the current password, arrives from `develop` with the constant-time compare added in 7e74959.
ost-ptk
added a commit
that referenced
this pull request
Aug 18, 2026
…secrets-on-demand Brings `develop` in through the base. The one conflict is the sync-wallet QR page, where both sides rewrote `generateQRCode` for different reasons: this branch made it `async` to fetch the phrase and the imported accounts' keys on demand, while #1466 turned it into a promise that rejects on a worker failure — a rejection inside an async `onmessage` raises no error event, so without it the password page spun forever. Both are kept: the awaits and the refusal paths stay, and the worker half returns a promise that resolves on a result and rejects through `fail`, which also terminates the worker. The two on-demand refusals return early instead — they render `PrivateStateErrorPage`, and an async return resolves the promise the password page is waiting on.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the six residual P3 defects recorded in WALLET-1364. #1465 — which introduced the
runKeysDownloadextraction this branch builds on — is merged, so this now sits directly ondevelop.Jira: https://make-software.atlassian.net/browse/WALLET-1364
What each change removes
takeEvery; everything re-derivable or cosmetic stays debounced.navigate(Home)sat outside both worker callbacks and ran unconditionally. The user landed on Home believing their password had changed when it had not. Navigation now happens only on a successful worker result; a failure shows a field error and stays put.createAsymmetricKeysreturnedsecretKey: nullwere silently omitted from the zip while Success was shown. Now the export fails loudly, before anything is written to disk, and logs a count only.===style-srchad no'self''unsafe-inline'matches no external stylesheet, and every app template links/assets/fonts/fonts.css. Fixed inwebpack.config.jsand in Safari's hand-maintained runtime CSP insrc/utils.ts, which had drifted the same way.Item 15 (QR sync uses AES-CBC with no MAC) is deliberately not fixed — see below.
What the review round changed
Three findings, all confirmed against the code before acting on them. The first was the important
one: it meant items 11 and 12 were not actually delivered.
onerrorcould not fire for the failures it was added to report. Every worker in this repo iswritten
onmessage = asyncwith notry/catch, so anything that throws inside —encodePassword,deriveEncryptionKey,decryptVault's GCM tag check,encryptVault's missing-vault guard —becomes a rejected promise, and a rejection inside a worker raises no
errorevent on the parentWorker. Only a script load failure reachesonerror. Inchange-passwordthat was worse thanthe bug it replaced:
isSubmittingwas cleared only insideonerror, so a rejected worker left thebutton disabled on
Loadingforever, with no message and no navigation. Before this PR the user atleast left the page.
Failures now travel as a message.
src/background/workers/types.tsholds the contract —{ error: true }with no payload (the error object stays in the worker's console rather thanreaching the UI), plus
WorkerResult<T>and anisWorkerErrorguard. Each worker wraps its body;each page branches on the guard.
onerroris kept for the script-load case it does cover.The defect was wider than this PR's diff.
generate-sync-wallet-qr-data-worker.tshas the sameshape and the same dead
onerror, and it can throw for real —scryptAsync,PublicKey.fromHexona corrupt imported key. Worse,
generateQRCodewasasyncbut resolved as soon as the worker wasconstructed, so the
.catchthatPasswordProtectionPagealready wrapsonClickin wasunreachable and the page span forever. It now returns a promise that resolves when the QR data
exists and rejects on failure. The cipher is untouched — this is delivery of the failure, not the
format; item 15 below still stands.
password-protection-pagehad to be pulled in. It consumes the same verify-password worker, andwithout handling
{ error: true }a worker failure would have destructuredisPasswordCorrect: undefinedand read as a wrong password — dispatchingloginRetryCountIncremented(), burning alogin attempt and eventually locking the wallet. That would have been a new bug, not a leftover. Its
own
onerroris no longer silent either, which closes a parity gap this description previously leftopen.
The change-password worker outlived its page. It was never terminated and there was no unmount
cleanup, while the back link stays live throughout — only the submit button is disabled. Pressing
back during the worker's ~1-2 s of scrypt still committed the rotation from a stale closure, so the
user's next unlock with the old password would fail. React-router does not stop the
navigatethatfollows either: in the installed 7.18.2,
activeRef.current = trueis set in a layout effect with nocleanup, so the post-unmount guard passes. The worker is now held in a ref and terminated on unmount,
and both callbacks check a mounted flag. The back link stays enabled — cancelling mid-scrypt now
genuinely cancels.
unlock-vaultwas also spawning two workers per submit and terminating neither;it now disposes them.
The constant-time comparison was unwireable without turning anything red.
constantTimeEqualHexwas tested thoroughly in isolation, but nothing observed that
verifyPasswordAgainstHashroutedthrough it — its three tests assert only correct → truthy and wrong → falsy, which plain
===satisfies identically. Reverting the call site left the suite green. An upper-case hash is the one
input where the two differ, and that assertion is now in the suite.
Two things an earlier review found
A watch account used to break the whole export. Watch accounts are stored with
secretKey: ''and no
hardwareflag, so they passed the export list's!account.hardwarefilter. Item 13'sfail-loudly rule then meant a user with three real accounts and one watch account got no archive
at all. That is worse than the bug being fixed, so the export list now excludes accounts with
nothing to export (
selectVaultAccountsAvailableForExport), and the hard failure is reserved for agenuinely unexpected null — data corruption, which should still never be shown as success.
Change-password now waits for the worker, so it needed a submitting state. Navigation correctly
moved inside the success path, which leaves the page on screen for the worker's two scrypt passes
(N=2¹⁸, roughly 1-2 s). Without a guard, two taps spawn two ~256 MB scrypt workers in the popup.
It now mirrors the
isSubmittingpattern already used bypassword-protection-page.Limitations, stated rather than buried
breakon thefirst mismatch does not change the output —
|=is monotonic — only the timing. What the tests nowprove is that the comparison is correct and that the call site routes through it; the in-code
comment is still the only thing standing between a future edit and a reintroduced short-circuit.
testEnvironment: 'node'with no jsdom, sochange-password,unlock-vault,password-protection-pageandwallet-qr-codecannot be unit-tested. Code reading was the onlyverification. Items to click through are listed below. The workers themselves are covered —
see
src/background/workers/workers.test.ts, which drives all four through a stubbedglobalThis.onmessage/postMessage.change-passworddisables the submit button while the worker runs, but unlike its siblingpassword-protection-pageit does not also guard Enter re-submission or make the fieldread-only. The button guard covers the double-tap vector; the parity gap is noted rather than
closed here.
Something went wrong. Please try again.has no catalogue entry, so the eightnon-English locales fall back to English.
locale:extract_potwas deliberately not run —lang/is hundreds of msgids behind and regenerating it would swamp this diff. A shorter
Something went wrongalready exists in four places; consolidating is a reasonable follow-up.Why item 15 is not here
generate-sync-wallet-qr-data-worker.tsstill uses AES-CBC with no MAC. The format is across-application contract:
casper-wallet-mobile/src/utils/aes/index.tsdecrypts it withaes-256-cbcand identical scrypt parameters (N = 2¹⁸, r = 8, p = 1, dkLen = 32), and itsreact-native-aes-cryptoexposes CBC only. Changing the extension alone breaks wallet sync until amatching mobile release ships. The residual risk is also narrow — the ciphertext travels from the
user's own screen to their own camera. If picked up, it needs a versioned envelope landed on mobile
first. The review round touched this worker's error delivery only; the ciphertext it produces is
byte-for-byte what it was.
Please verify before merging
The CSP change needs a real browser, which is the one thing this branch cannot settle itself:
npm run build:firefox, loadbuild/firefoxviaabout:debugging, open the popup and theonboarding page, and check the console for a blocked-stylesheet violation on
fonts.css—before and after. Same for the Safari build.
disabled while it runs. Then start a change and press back during the scrypt pass — the old
password must still unlock afterwards.
Something went wrong.was promisified.
offered at all.
npm run ci-checkis green: 94 suites, 903 tests.