security: the user's plaintext password crosses the extension messaging boundary during dApp approvals #157

Closed
opened 2026-08-09 03:44:09 +02:00 by clawbot · 3 comments
Collaborator

Problem

For dApp-initiated transaction and signature approvals, the approval popup
sends the user's plaintext password to the background script over
runtime.sendMessage, and the background does the decryption there.

Senders:

  • src/popup/views/approval.js:449 (AUTISTMASK_TX_RESPONSE)
  • src/popup/views/approval.js:488 (AUTISTMASK_SIGN_RESPONSE)

Consumers:

  • src/background/index.js:745-748
  • src/background/index.js:804-807

The codebase already carries four standing markers for this, all identical:

  • src/popup/views/approval.js:448
  • src/popup/views/approval.js:487
  • src/background/index.js:744
  • src/background/index.js:803

> // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage

The correct pattern already exists in-tree: the popup-side eth_sendTransaction
path decrypts locally at src/popup/views/confirmTx.js:305 and never puts the
password on the wire. Only the dApp approval paths diverge.

This is a pre-1.0 hardening item and is explicitly in scope for the
"Pre-1.0 security review" carried in TODO.md.

Implementation requirements

  • Move decryption into the popup for both the tx-approval and sign-approval
    paths, mirroring confirmTx.js:305. The popup should derive the signer and
    produce the signed artifact locally.
  • Decide what crosses the boundary instead and justify it in the PR. The
    natural answer is the signed result (raw signed transaction, or the
    signature) rather than the password or the private key. Under no
    circumstances should the private key or the recovery phrase replace the
    password on the wire — that would be strictly worse.
  • The background retains responsibility for broadcasting and for resolving the
    pending approval back to the requesting page; only the secret handling
    moves.
  • Remove all four TODO(security) comments once the flaw is actually gone —
    not before.
  • Zero the password variable after use where the language allows, and avoid
    retaining it in any closure longer than necessary.
  • Do not weaken the existing behaviour: rejected approvals must still return
    EIP-1193 error code 4001, and approvals must still survive popup
    close/reopen as they do today.
  • Confirm no password value can reach src/shared/log.js output on any path,
    including debugFetch.

Definition of done

  • No runtime.sendMessage payload anywhere in the codebase contains a
    password field. Demonstrate with a grep in the PR description.
  • dApp-initiated eth_sendTransaction approval still works end to end and
    the site receives the tx hash.
  • dApp-initiated personal_sign and eth_signTypedData_v4 approvals still
    work end to end and the site receives a valid signature.
  • A wrong password still fails cleanly with a full-sentence error and does
    not resolve the pending approval.
  • Rejection still returns EIP-1193 code 4001.
  • All four TODO(security) comments are removed.
  • The approach is verified on both Chrome and Firefox, or the PR states
    that Firefox verification is blocked on the Firefox compat issue.
  • TODO.md updated in the same commit.
  • make check passes.
## Problem For dApp-initiated transaction and signature approvals, the approval popup sends the user's **plaintext password** to the background script over `runtime.sendMessage`, and the background does the decryption there. Senders: - `src/popup/views/approval.js:449` (`AUTISTMASK_TX_RESPONSE`) - `src/popup/views/approval.js:488` (`AUTISTMASK_SIGN_RESPONSE`) Consumers: - `src/background/index.js:745-748` - `src/background/index.js:804-807` The codebase already carries four standing markers for this, all identical: - `src/popup/views/approval.js:448` - `src/popup/views/approval.js:487` - `src/background/index.js:744` - `src/background/index.js:803` > `// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage` The correct pattern already exists in-tree: the popup-side `eth_sendTransaction` path decrypts locally at `src/popup/views/confirmTx.js:305` and never puts the password on the wire. Only the dApp approval paths diverge. This is a pre-1.0 hardening item and is explicitly in scope for the "Pre-1.0 security review" carried in `TODO.md`. ## Implementation requirements - Move decryption into the popup for both the tx-approval and sign-approval paths, mirroring `confirmTx.js:305`. The popup should derive the signer and produce the signed artifact locally. - Decide what crosses the boundary instead and justify it in the PR. The natural answer is the **signed** result (raw signed transaction, or the signature) rather than the password or the private key. Under no circumstances should the private key or the recovery phrase replace the password on the wire — that would be strictly worse. - The background retains responsibility for broadcasting and for resolving the pending approval back to the requesting page; only the secret handling moves. - Remove all four `TODO(security)` comments once the flaw is actually gone — not before. - Zero the password variable after use where the language allows, and avoid retaining it in any closure longer than necessary. - Do not weaken the existing behaviour: rejected approvals must still return EIP-1193 error code 4001, and approvals must still survive popup close/reopen as they do today. - Confirm no password value can reach `src/shared/log.js` output on any path, including `debugFetch`. ## Definition of done - [ ] No `runtime.sendMessage` payload anywhere in the codebase contains a password field. Demonstrate with a grep in the PR description. - [ ] dApp-initiated `eth_sendTransaction` approval still works end to end and the site receives the tx hash. - [ ] dApp-initiated `personal_sign` and `eth_signTypedData_v4` approvals still work end to end and the site receives a valid signature. - [ ] A wrong password still fails cleanly with a full-sentence error and does not resolve the pending approval. - [ ] Rejection still returns EIP-1193 code 4001. - [ ] All four `TODO(security)` comments are removed. - [ ] The approach is verified on both Chrome and Firefox, or the PR states that Firefox verification is blocked on the Firefox compat issue. - [ ] `TODO.md` updated in the same commit. - [ ] `make check` passes.
clawbot added this to the 1.0.0 milestone 2026-08-09 03:44:09 +02:00
Author
Collaborator

Manager note — dispatching this now as the second work unit of the 1.0.0 push,
ahead of the rest of the milestone. #149 (the hardcoded recovery phrase) is
merge-ready as PR #169 and awaiting merge; this is the next-highest security
item.

Notes for the implementer beyond the issue body:

1. Branch from main, and expect a small TODO.md conflict. PR #169 is
open and unmerged, and it also touches TODO.md. That is expected and fine —
keep your TODO.md edit surgical (touch only the lines you actually need)
so the rebase is trivial. If #169 has landed by the time you branch, rebase
onto it first and refresh the Status/Next Step block, which will still say
"Land #149 ... PR open, awaiting review" from that PR.

2. Do not let the fix make things worse. The obvious wrong turn here is to
stop sending the password and start sending the decrypted private key or the
recovery phrase instead. That is strictly worse than the current state. What
should cross the boundary is the signed artifact — the raw signed
transaction, or the signature — with the background retaining only broadcast
and approval-resolution duties. The issue body says this; I am repeating it
because it is the one way this change can regress security while looking like
a fix.

3. confirmTx.js:305 is the reference implementation. The popup-side
eth_sendTransaction path already decrypts locally and never puts the password
on the wire. Match that shape rather than inventing a new one; the goal is that
all three signing paths look the same afterwards.

4. Removing the four TODO(security) comments is part of the DoD, but only
once the flaw is actually gone.
Do not delete them as a tidy-up if you end up
partially implementing this.

5. Firefox verification is expected to be blocked. #153 documents that the
Firefox target is currently non-functional for exactly these approval paths
(Chrome callback APIs against the promise-only browser namespace). Verify on
Chrome, and state plainly in the PR that Firefox verification is blocked on
#153 rather than claiming it works. Do not try to fix #153 here — that is a
separate unit and folding it in would make this PR unreviewable.

6. Watch the logging path. The DoD requires proving no password reaches
src/shared/log.js output, including debugFetch. Note that after #149 lands,
a release build has DEBUG false but the #145 runtime toggle can still raise
the log level at runtime — so "it isn't logged because DEBUG is off" is not an
acceptable answer. Check the code path, not the current flag value.

Context: make check is green on main at 23aeae4. Also note script/lint
is still only prettier --check and cannot catch undefined identifiers (#152),
so do not rely on it to tell you a refactor is complete — grep.

Manager note — dispatching this now as the second work unit of the 1.0.0 push, ahead of the rest of the milestone. #149 (the hardcoded recovery phrase) is merge-ready as PR #169 and awaiting merge; this is the next-highest security item. Notes for the implementer beyond the issue body: **1. Branch from `main`, and expect a small `TODO.md` conflict.** PR #169 is open and unmerged, and it also touches `TODO.md`. That is expected and fine — keep your `TODO.md` edit surgical (touch only the lines you actually need) so the rebase is trivial. If #169 has landed by the time you branch, rebase onto it first and refresh the Status/Next Step block, which will still say "Land #149 ... PR open, awaiting review" from that PR. **2. Do not let the fix make things worse.** The obvious wrong turn here is to stop sending the password and start sending the decrypted private key or the recovery phrase instead. That is strictly worse than the current state. What should cross the boundary is the *signed artifact* — the raw signed transaction, or the signature — with the background retaining only broadcast and approval-resolution duties. The issue body says this; I am repeating it because it is the one way this change can regress security while looking like a fix. **3. `confirmTx.js:305` is the reference implementation.** The popup-side `eth_sendTransaction` path already decrypts locally and never puts the password on the wire. Match that shape rather than inventing a new one; the goal is that all three signing paths look the same afterwards. **4. Removing the four `TODO(security)` comments is part of the DoD, but only once the flaw is actually gone.** Do not delete them as a tidy-up if you end up partially implementing this. **5. Firefox verification is expected to be blocked.** #153 documents that the Firefox target is currently non-functional for exactly these approval paths (Chrome callback APIs against the promise-only `browser` namespace). Verify on Chrome, and state plainly in the PR that Firefox verification is blocked on #153 rather than claiming it works. Do not try to fix #153 here — that is a separate unit and folding it in would make this PR unreviewable. **6. Watch the logging path.** The DoD requires proving no password reaches `src/shared/log.js` output, including `debugFetch`. Note that after #149 lands, a release build has `DEBUG` false but the #145 runtime toggle can still raise the log level at runtime — so "it isn't logged because DEBUG is off" is not an acceptable answer. Check the code path, not the current flag value. Context: `make check` is green on `main` at `23aeae4`. Also note `script/lint` is still only `prettier --check` and cannot catch undefined identifiers (#152), so do not rely on it to tell you a refactor is complete — grep.
Author
Collaborator

Implementation plan

Branching from main at 23aeae4. Branch: fix/issue-157-approval-decrypt-in-popup.

What crosses the messaging boundary afterwards

  • AUTISTMASK_TX_RESPONSE carries rawSignedTx — the RLP-serialized,
    already-signed transaction (a hex string). This is exactly the value that
    would have gone out over eth_sendRawTransaction anyway; it is public the
    moment it is broadcast, so it is not a secret.
  • AUTISTMASK_SIGN_RESPONSE carries signature — the 65-byte signature hex.
    Same argument: it is the artifact the dApp receives regardless.
  • Neither the password, the decrypted recovery phrase, the xprv, nor the
    private key ever leaves the popup context.

Popup (src/popup/views/approval.js)

  1. Stash details.txParams / details.signParams alongside the existing
    pendingTxDetails when the approval view renders, so the popup signs the
    very parameters it displayed.
  2. On approve, mirror confirmTx.js:305: read the inline password, look up the
    wallet and address index owning state.activeAddress, call
    decryptWithPassword, then getSignerForAddress.
  3. Tx path: signer.connect(getProvider(state.rpcUrl)), then
    populateTransaction + signTransaction. This is the exact sequence
    ethers' own AbstractSigner.sendTransaction performs internally, so gas,
    nonce, fee and chainId population are unchanged from today.
  4. Sign path: signMessage(getBytes(sp.message)) for
    personal_sign/eth_sign, signTypedData(domain, types, message) for
    eth_signTypedData_v4/eth_signTypedData — moved verbatim from the
    background.
  5. Null out the password and the decrypted secret references immediately after
    use, with the same best-effort caveat comment confirmTx.js already
    carries (JS strings are immutable).
  6. A wrong password is now caught before anything is sent: the popup shows a
    full-sentence inline error, re-enables the button, and sends no message at
    all — so the pending approval survives and can be retried. That is strictly
    better than today, where a bad password reached the background and resolved
    (and thus destroyed) the approval.

Background (src/background/index.js)

  • AUTISTMASK_TX_RESPONSE: provider.broadcastTransaction(msg.rawSignedTx),
    then resolve the approval and sendResponse with the hash. Rejection path
    untouched, still EIP-1193 4001.
  • AUTISTMASK_SIGN_RESPONSE: resolve the approval with msg.signature.
  • Drop the now-unused decryptWithPassword / getSignerForAddress imports.

Keeping the background authoritative

Moving the signing out of the background must not turn the background into a
blind relay, so before broadcasting/resolving it verifies the artifact against
the approval it is holding, in a new src/shared/approvalVerify.js:

  • verifySignedTx(rawSignedTx, txParams, expectedFrom) — parses the raw
    transaction with ethers.Transaction.from, recovers the sender from the
    signature, and asserts the recovered from, plus to, value and data,
    match the approved parameters.
  • verifySignature(signParams, signature, expectedFrom) — recovers the signer
    with verifyMessage / verifyTypedData and asserts it is the active
    address.

Both throw full-sentence errors. This is what makes the change security-neutral
on the background side rather than a trust transfer.

Tests

tests/approvalVerify.test.js: sign real transactions and messages with a
fixed-key ethers.Wallet and assert the verifiers accept matching artifacts
and reject a tampered recipient, a tampered value, tampered call data, a
wrong signer, and a garbage payload.

Logging

Nothing in either changed path passes a password (or the decrypted secret) to
log.* or to debugFetch. After the change the password never exists in the
background context at all, and in the popup it is only ever an argument to
decryptWithPassword. This will be argued from the code path, not from the
value of the DEBUG flag, since the #145 runtime toggle can raise the level at
runtime.

Out of scope

  • #153 (Firefox target non-functional on these same approval paths). Verified
    on Chrome only; the PR will say so plainly rather than claim Firefox works.
  • The TODO(security) comments come out in the same commit, once the flaw is
    actually gone.

TODO.md gets a surgical edit in the same commit (PR #169 is open against it).

## Implementation plan Branching from `main` at `23aeae4`. Branch: `fix/issue-157-approval-decrypt-in-popup`. ### What crosses the messaging boundary afterwards - `AUTISTMASK_TX_RESPONSE` carries `rawSignedTx` — the RLP-serialized, already-signed transaction (a hex string). This is exactly the value that would have gone out over `eth_sendRawTransaction` anyway; it is public the moment it is broadcast, so it is not a secret. - `AUTISTMASK_SIGN_RESPONSE` carries `signature` — the 65-byte signature hex. Same argument: it is the artifact the dApp receives regardless. - Neither the password, the decrypted recovery phrase, the xprv, nor the private key ever leaves the popup context. ### Popup (`src/popup/views/approval.js`) 1. Stash `details.txParams` / `details.signParams` alongside the existing `pendingTxDetails` when the approval view renders, so the popup signs the very parameters it displayed. 2. On approve, mirror `confirmTx.js:305`: read the inline password, look up the wallet and address index owning `state.activeAddress`, call `decryptWithPassword`, then `getSignerForAddress`. 3. Tx path: `signer.connect(getProvider(state.rpcUrl))`, then `populateTransaction` + `signTransaction`. This is the exact sequence ethers' own `AbstractSigner.sendTransaction` performs internally, so gas, nonce, fee and chainId population are unchanged from today. 4. Sign path: `signMessage(getBytes(sp.message))` for `personal_sign`/`eth_sign`, `signTypedData(domain, types, message)` for `eth_signTypedData_v4`/`eth_signTypedData` — moved verbatim from the background. 5. Null out the password and the decrypted secret references immediately after use, with the same best-effort caveat comment `confirmTx.js` already carries (JS strings are immutable). 6. A wrong password is now caught before anything is sent: the popup shows a full-sentence inline error, re-enables the button, and sends no message at all — so the pending approval survives and can be retried. That is strictly better than today, where a bad password reached the background and resolved (and thus destroyed) the approval. ### Background (`src/background/index.js`) - `AUTISTMASK_TX_RESPONSE`: `provider.broadcastTransaction(msg.rawSignedTx)`, then resolve the approval and `sendResponse` with the hash. Rejection path untouched, still EIP-1193 4001. - `AUTISTMASK_SIGN_RESPONSE`: resolve the approval with `msg.signature`. - Drop the now-unused `decryptWithPassword` / `getSignerForAddress` imports. ### Keeping the background authoritative Moving the signing out of the background must not turn the background into a blind relay, so before broadcasting/resolving it verifies the artifact against the approval it is holding, in a new `src/shared/approvalVerify.js`: - `verifySignedTx(rawSignedTx, txParams, expectedFrom)` — parses the raw transaction with `ethers.Transaction.from`, recovers the sender from the signature, and asserts the recovered `from`, plus `to`, `value` and `data`, match the approved parameters. - `verifySignature(signParams, signature, expectedFrom)` — recovers the signer with `verifyMessage` / `verifyTypedData` and asserts it is the active address. Both throw full-sentence errors. This is what makes the change security-neutral on the background side rather than a trust transfer. ### Tests `tests/approvalVerify.test.js`: sign real transactions and messages with a fixed-key `ethers.Wallet` and assert the verifiers accept matching artifacts and reject a tampered recipient, a tampered value, tampered call data, a wrong signer, and a garbage payload. ### Logging Nothing in either changed path passes a password (or the decrypted secret) to `log.*` or to `debugFetch`. After the change the password never exists in the background context at all, and in the popup it is only ever an argument to `decryptWithPassword`. This will be argued from the code path, not from the value of the `DEBUG` flag, since the #145 runtime toggle can raise the level at runtime. ### Out of scope - #153 (Firefox target non-functional on these same approval paths). Verified on Chrome only; the PR will say so plainly rather than claim Firefox works. - The `TODO(security)` comments come out in the same commit, once the flaw is actually gone. `TODO.md` gets a surgical edit in the same commit (PR #169 is open against it).
Author
Collaborator

Landed on next via #171.

Carried forward, so it is not lost when this closes: DoD items 2-5 want an interactive Chrome pass over eth_sendTransaction, personal_sign and eth_signTypedData_v4, and that was never run — the code path is covered by unit tests and a round trip only. The browser harness that can supply it is now on next, and driving these round trips through it is #183.

Landed on `next` via https://git.eeqj.de/sneak/AutistMask/pulls/171. Carried forward, so it is not lost when this closes: DoD items 2-5 want an interactive Chrome pass over `eth_sendTransaction`, `personal_sign` and `eth_signTypedData_v4`, and that was never run — the code path is covered by unit tests and a round trip only. The browser harness that can supply it is now on `next`, and driving these round trips through it is https://git.eeqj.de/sneak/AutistMask/issues/183.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#157