Skip to content

fix: WALLET-1364 — close the P3 residual - #1466

Merged
Comp0te merged 11 commits into
developfrom
WALLET-1364-p3-residual
Aug 18, 2026
Merged

fix: WALLET-1364 — close the P3 residual#1466
Comp0te merged 11 commits into
developfrom
WALLET-1364-p3-residual

Conversation

@ost-ptk

@ost-ptk ost-ptk commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes the six residual P3 defects recorded in WALLET-1364. #1465 — which introduced the
runKeysDownload extraction this branch builds on — is merged, so this now sits directly on
develop.

Jira: https://make-software.atlassian.net/browse/WALLET-1364

What each change removes

# Defect What the user experienced
10 Account mutations waited out the re-encrypt debounce A browser quit or MV3 worker kill inside the 500 ms window dropped the last vault mutation. When that was an imported account, the secret key was gone — it exists nowhere else. The four account-mutating actions now persist immediately via takeEvery; everything re-derivable or cosmetic stays debounced.
11 A failed password change looked exactly like a successful one 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.
12 Unlock worker errors said nothing Both error paths stopped the spinner with no message — indistinguishable from a wrong password, which the same screen reports properly a few lines above.
13 A partial key archive was presented as success Accounts whose createAsymmetricKeys returned secretKey: null were 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.
14 Password hash compared with === Short-circuits at the first differing character. Now a constant-time comparison over the decoded bytes.
16 Non-Chrome style-src had no 'self' A source list of only 'unsafe-inline' matches no external stylesheet, and every app template links /assets/fonts/fonts.css. Fixed in webpack.config.js and in Safari's hand-maintained runtime CSP in src/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.

onerror could not fire for the failures it was added to report. Every worker in this repo is
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 inside 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 was cleared only inside onerror, so a rejected worker left the
button disabled on Loading forever, with no message and no navigation. Before this PR the user at
least left the page.

Failures now travel as a message. src/background/workers/types.ts holds the contract —
{ error: true } with no payload (the error object stays in the worker's console rather than
reaching the UI), plus WorkerResult<T> and an isWorkerError guard. Each worker wraps its body;
each page branches on the guard. onerror is kept for the script-load case it does cover.

The defect was wider than this PR's diff. generate-sync-wallet-qr-data-worker.ts has the same
shape and the same dead onerror, and it can throw for real — scryptAsync, PublicKey.fromHex on
a corrupt imported key. Worse, generateQRCode was async but resolved as soon as the worker was
constructed, so the .catch that PasswordProtectionPage already wraps onClick in was
unreachable 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-page had to be pulled in. It consumes the same verify-password worker, and
without handling { error: true } a worker failure would have destructured isPasswordCorrect: undefined and read as a wrong password — dispatching loginRetryCountIncremented(), burning a
login attempt and eventually locking the wallet. That would have been a new bug, not a leftover. Its
own onerror is no longer silent either, which closes a parity gap this description previously left
open.

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 navigate that
follows either: in the installed 7.18.2, activeRef.current = true is set in a layout effect with no
cleanup, 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-vault was also spawning two workers per submit and terminating neither;
it now disposes them.

The constant-time comparison was unwireable without turning anything red. constantTimeEqualHex
was tested thoroughly in isolation, but nothing observed that verifyPasswordAgainstHash routed
through 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 hardware flag, so they passed the export list's !account.hardware filter. Item 13's
fail-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 a
genuinely 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 isSubmitting pattern already used by password-protection-page.

Limitations, stated rather than buried

  • The constant-time property is not test-enforceable here. Mutating the loop to break on the
    first mismatch does not change the output — |= is monotonic — only the timing. What the tests now
    prove 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.
  • The React paths have no automated coverage. This project's jest runs
    testEnvironment: 'node' with no jsdom, so change-password, unlock-vault,
    password-protection-page and wallet-qr-code cannot be unit-tested. Code reading was the only
    verification. 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 stubbed
    globalThis.onmessage/postMessage.
  • change-password disables the submit button while the worker runs, but unlike its sibling
    password-protection-page it does not also guard Enter re-submission or make the field
    read-only. The button guard covers the double-tap vector; the parity gap is noted rather than
    closed here.
  • The string Something went wrong. Please try again. has no catalogue entry, so the eight
    non-English locales fall back to English. locale:extract_pot was deliberately not run — lang/
    is hundreds of msgids behind and regenerating it would swamp this diff. A shorter
    Something went wrong already exists in four places; consolidating is a reasonable follow-up.

Why item 15 is not here

generate-sync-wallet-qr-data-worker.ts still uses AES-CBC with no MAC. The format is a
cross-application contract: casper-wallet-mobile/src/utils/aes/index.ts decrypts it with
aes-256-cbc and identical scrypt parameters (N = 2¹⁸, r = 8, p = 1, dkLen = 32), and its
react-native-aes-crypto exposes CBC only. Changing the extension alone breaks wallet sync until a
matching 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:

  1. npm run build:firefox, load build/firefox via about:debugging, open the popup and the
    onboarding page, and check the console for a blocked-stylesheet violation on fonts.css
    before and after. Same for the Safari build.
  2. Change password: confirm it lands on Home only after the worker finishes, and that the button is
    disabled while it runs. Then start a change and press back during the scrypt pass — the old
    password must still unlock afterwards.
  3. Unlock with a wrong password — the message must still be the wrong-password one, not
    Something went wrong.
  4. Sync wallet QR — confirm the happy path still produces a scannable code, since its page function
    was promisified.
  5. Export keys with a watch account present, using "select all" — the watch account should not be
    offered at all.

npm run ci-check is green: 94 suites, 903 tests.

@ost-ptk ost-ptk changed the title WALLET 1364 p3 residual fix: WALLET-1364 — close the P3 residual Aug 13, 2026
@ost-ptk
ost-ptk requested a review from Comp0te August 14, 2026 08:27
Base automatically changed from WALLET-1364-test-coverage-gaps to develop August 17, 2026 08:50
@Comp0te
Comp0te force-pushed the WALLET-1364-p3-residual branch from a5e3a3e to 40c1e4c Compare August 17, 2026 08:50

@Comp0te Comp0te left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/apps/popup/pages/change-password/index.tsx
Comment thread src/apps/popup/pages/change-password/index.tsx Outdated
Comment thread src/libs/crypto/hashing.ts
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.
@ost-ptk
ost-ptk requested a review from Comp0te August 18, 2026 08:39
@Comp0te
Comp0te merged commit 5f3a449 into develop Aug 18, 2026
6 checks passed
@Comp0te
Comp0te deleted the WALLET-1364-p3-residual branch August 18, 2026 10:40
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants