security: decrypt and sign dApp approvals in the popup (closes #157) #171
Reference in New Issue
Block a user
Delete Branch "fix/issue-157-approval-decrypt-in-popup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #157.
What was wrong
The dApp-initiated transaction and signature approval paths read the user's
password in the popup and then sent it, in plaintext, to the background over
runtime.sendMessage, which did the decryption and the signing. Four standingTODO(security)markers recorded it.What crosses the boundary now, and why that is safe
AUTISTMASK_TX_RESPONSEpasswordrawSignedTxAUTISTMASK_SIGN_RESPONSEpasswordsignaturerawSignedTxis the RLP-serialized, already-signed transaction. It isexactly the value that goes out over
eth_sendRawTransactiona moment laterand is public from that point on. It is not a secret, and possessing it does
not let anyone sign anything else.
signatureis the 65-byte signature the dApp receives as the result of itsown request. Same argument.
leaves the popup context. The obvious wrong turn for this issue — swapping
the password for the decrypted key — is not what happened here; sending the
key would have been strictly worse than the status quo, because the key is
reusable and the signed artifact is not.
The popup now looks like
src/popup/views/confirmTx.js:305, which alreadydecrypted locally, so all three signing paths in the extension have the same
shape.
Changes
src/popup/views/approval.jstxParams/signParamsit renders, so it signs precisely whatit displayed. Both are repopulated by
show(), so close/reopen still works.state.activeAddress,calls
decryptWithPassword, thengetSignerForAddress.populateTransactionthensignTransaction. This is the samesequence
ethers' ownAbstractSigner.sendTransactionruns internally(populate,
delete pop.from, sign, broadcast), so nonce, gas, fee and chainid population are byte-for-byte what the background used to produce.
signMessage(getBytes(sp.message))forpersonal_sign/eth_sign,signTypedData(domain, types, message)foreth_signTypedData_v4/eth_signTypedData— moved over unchanged.finallyblocksimmediately after use, carrying the same immutability caveat comment
confirmTx.jsalready has. Neither is captured in any closure: thesendMessagecallback closes overpayloadand the view state only.src/background/index.jsAUTISTMASK_TX_RESPONSE:provider.broadcastTransaction(msg.rawSignedTx),then resolve the approval and respond with the hash.
AUTISTMASK_SIGN_RESPONSE: resolve the approval withmsg.signature.decryptWithPassword,getSignerForAddressandgetBytesimports. The stringpasswordno longer appears anywhere insrc/background/.src/shared/approvalVerify.js(new)Moving the secret out of the background must not turn the background into a
blind relay that broadcasts whatever an extension page hands it, so before it
acts it re-derives the signer from the artifact and checks it against the
approval it is holding:
verifySignedTx(rawSignedTx, txParams, expectedFrom)— parses withTransaction.from, recovers the sender from the signature, and asserts therecovered
fromplusto,valueanddatamatch the approval.verifySignature(signParams, signature, expectedFrom)— recovers withverifyMessage/verifyTypedDataand asserts it is the approved address.All crypto is delegated to
ethers, per the Crypto Policy. Every rejectionmessage is a full sentence. This is what keeps the change security-neutral on
the background side rather than a transfer of trust.
Behaviour preserved
!msg.approvedbranches andthe
windowsApi.onRemovedhandler are untouched.runtime.onConnect'sdisconnect handler still keeps
tx/signapprovals pending, andshow()re-fetches and re-stashes the params on reopen.
artifact it sends the same message with an
errorfield instead, so thebackground resolves the request with a failure exactly as it used to. Without
this the page would have hung until the window was closed.
popup before any message is sent: a full-sentence inline error
("That password is incorrect. Please try again."), the button re-enables, and
the pending approval is untouched and retryable. Previously the bad password
reached the background, which resolved and deleted the approval, so the
request was dead.
Verification
make check— green (75 tests, 5 suites; 3 suites and 62 tests were alreadythere, this adds
tests/approvalVerify.test.js).make build— green, bothdist/chrome/anddist/firefox/produced.tests/approvalVerify.test.jscovers the verifiers directly (tamperedrecipient, inflated value, substituted call data, wrong signer, unsigned
payload, malformed payload, contract creation with no recipient, absent value,
case-differing call data, non-mutation of the approved typed data) and adds a
round trip that runs the exact sequence the popup runs —
getSignerForAddress,connect,populateTransaction,delete pop.from,signTransaction—against a stub provider, then hands the artifact to the exact check the
background runs. That test also asserts the wire payload has only
{type, id, approved, rawSignedTx}and contains neither the stringpasswordnor the private key.
Grep evidence (DoD item 1)
No
runtime.sendMessagepayload anywhere carries a password field:The word does not occur in the background or content script at all any more:
And the markers are gone (DoD item 6):
Logging (DoD item 8)
Argued from the code path, not from the value of
DEBUG, since the #145runtime toggle can raise the level at runtime and
isDebug()is evaluatedlazily on every
emit():background log statement can reach it regardless of level.
letin the two click handlers, andits only use is as an argument to
decryptWithPassword.src/shared/vault.jsimports no logger and calls no
console.src/popup/views/approval.jsandsrc/shared/approvalVerify.jsimportneither
lognordebugFetch:debugFetchis reachable only fromproxyRpcand the price/balance/phishingfetchers. Its debug lines log
method,urlandopts.body. The only bodyit is ever given is the JSON-RPC envelope built in
proxyRpc, and the popupnever routes a password through any fetch. The
ethersprovider does its ownfetchand does not go throughdebugFetchat all; what it would carry isthe raw signed transaction, which is public.
entered value is never interpolated into a message.
What I could not verify, and needs a human pass
I want to be explicit rather than claim a green tick I did not earn:
target being non-functional for exactly these approval paths (Chrome callback
APIs against the promise-only
browsernamespace). Not touched here; foldingit in would make this unreviewable.
environment this was built in, so I could not load the unpacked extension and
drive a live dApp through
eth_sendTransaction,personal_signandeth_signTypedData_v4. What I did verify is that the Chrome bundle builds,that the popup signing sequence produces an artifact the background accepts
(round-trip test above), and that the message contract on both ends matches.
DoD items 2, 3, 4 and 5 want an end-to-end run, so please do the interactive
Chrome pass on review rather than taking the build as proof.
Notes for the reviewer
One deliberate, documented behaviour change beyond the wrong-password
improvement: if a dApp ever supplied an ENS name as
to,populateTransactionwould resolve it in the popup and the background's
tocomparison would thenfail closed with "The signed transaction does not go to the approved
recipient." EIP-1193 requires
toto be an address and the approval UI alreadyrenders it as one, so I judged a loud, safe failure better than a silent
unverified resolution. Say the word if you would rather that case be tolerated.
Out of scope
"Wrong password."fragment inconfirmTx.jsis not a full sentence andso diverges from the language rule in
RULES.md. Left alone as unrelated;happy to file it as its own issue.
Summary of what was built and how it was verified
Built
Five files, +712/-123.
src/popup/views/approval.js— the two dApp approval handlers now decryptwith
decryptWithPasswordand sign withgetSignerForAddressin the popup,exactly as
confirmTx.jsalready did. They keep the renderedtxParams/signParamsso they sign what was displayed, and null the password and thedecrypted secret in
finallyblocks straight after use.src/background/index.js— reduced to broadcast and approval resolution.provider.broadcastTransaction(msg.rawSignedTx)for the tx path,approval.resolve({ signature })for the sign path. The unuseddecryptWithPassword,getSignerForAddressandgetBytesimports are gone.src/shared/approvalVerify.js(new) —verifySignedTxandverifySignature, so the background re-derives the signer from the artifactand checks it against the approval it holds before acting on it. Without this
the change would have been a trust transfer rather than a fix.
tests/approvalVerify.test.js(new) — 13 unit tests plus a 3-test round trip.TODO.md— one entry at the top of Completed Steps, deliberately kept to asingle hunk because PR #169 also touches this file.
All four
TODO(security)markers removed, in the same commit as the fix.What crosses the boundary
rawSignedTx(RLP-serialized signed transaction) andsignature. Both arepublic artifacts the moment they leave the wallet, and neither can be reused to
sign anything else. The password, recovery phrase, xprv and private key all
stay in the popup context. Sending the decrypted key instead of the password
would have been strictly worse and is explicitly not what this does.
Verified
make checkgreen: 5 suites, 75 tests.make buildgreen for both targets.The pre-commit hook ran
make checkon the commit as well.getSignerForAddress,connect,populateTransaction,delete pop.from,signTransaction— andfeeds the result to the real background check, asserting nonce, chain id,
gas limit, recipient, value and call data all survive. It also asserts the
wire payload is exactly
{type, id, approved, rawSignedTx}and containsneither the string
passwordnor the private key.data, wrong signer, unsigned payload, undecodable payload.
runtime.sendMessagepayload carries a passwordfield, that
passwordno longer appears insrc/background/orsrc/content/at all, and that theTODO(security)markers are gone.DEBUGflag, sinceisDebug()is evaluated lazily on every emit and the #145 runtime toggle canraise the level at any time.
survive popup close/reopen; a popup-side signing failure still reports back
so the page gets an error instead of hanging.
Not verified — please cover on review
could not load the unpacked extension and drive a live dApp. The bundle
builds and the message contract is covered by tests, but DoD items 2 through
5 want a real end-to-end run.
Review: PR #171 — security: decrypt and sign dApp approvals in the popup
Independent adversarial review at head
ab3b452. Reviewer did not author this change.Verdict: PASS
Merge condition in item 6 below — nothing for the author to rework.
Gates
make checkfmt-checkcleanmake builddist/chrome/anddist/firefox/both producedab3b452check / check (push), "Successful in 28s"mainorigin/main(23aeae4); no conflicts(closes #157)RULES.mdTODO.md1. What crosses the boundary — verified independently
Enumerated every
runtime.sendMessagecall site insrc/and read each payload construction. The complete set of payload shapes on the approval paths is:src/popup/views/approval.js:546—{type, id, approved, rawSignedTx}or{type, id, approved, error}src/popup/views/approval.js:644—{type, id, approved, signature}or{type, id, approved, error}:455,:466,:558,:656—{type, id, approved, remember}(site/reject paths)No password, private key, recovery phrase or xprv field on any of them. The author's claim holds.
The leak path I went looking for, and why it is closed.
payload.error = e.shortMessage || e.message(approval.js:539,:637) crosses the boundary and is resolved back to the requesting dApp. That makes any ethers error raised insidegetSignerForAddress()a candidate secret-exfiltration channel to a hostile website, since that call receives the decrypted secret directly. It is closed on two independent grounds:catchthat returns before anypayloadobject exists (approval.js:505-511,:596-602), so a bad secret never reaches the error-forwarding block.assertArgumenterrors on every branchgetSignerForAddress()can take:crypto/signing-key.js:22("privateKey", "[REDACTED]"),wallet/mnemonic.js:21,26,38,42,wallet/hdwallet.js:256,258,272,293(seed and extended key),wallet/base-wallet.js:35. Verified in the installed tree, not assumed.Also confirmed:
signeranddecryptedSecretare not captured by thesendMessagecallback — it closes overpayloadand view state only — and bothpasswordanddecryptedSecretare nulled infinallyblocks. This is stricter than the reference implementation inconfirmTx.js, which nulls onlydecryptedSecretand leavespasswordaconst.2. Is
src/shared/approvalVerify.jssound?I could not construct an artifact that passes verification but differs from what the user approved in a way that matters.
verifyMessage/verifyTypedDatarecover over the approved payload, so a signature captured over anything else recovers to an unrelated address.Signature.sgetter (crypto/signature.js:46), which every recovery path touches. And even if it did not, a malleable variant recovers to the same address over the same payload — it is the approved artifact.sameAddressfallback (approvalVerify.js:507-516): thecatchthat falls back to lowercase string comparison is not a bypass.parsed.tofrom ethers is always a well-formed checksummed address or null, so the fallback can only fire on a malformedtxParams.to, and it must then lowercase-equal a valid address — which is the correct answer.delete types.EIP712Domainoperates on a freshJSON.parseresult, so the held approval is not mutated. Pinned by the test attests/approvalVerify.test.js:855-863. The popup signer and the verifier perform the identical transform, so the check is symmetric — anything the popup can sign, the verifier can verify.throwinside the background'stry, which resolves the approval with an error and never reachesbroadcastTransaction. The verify call precedesgetProvideratsrc/background/index.js:741.Non-blocking gap (finding A).
verifySignedTxcomparesfrom,to,valueanddata, but notchainId,nonce,gasLimit,maxFeePerGasormaxPriorityFeePerGas(src/shared/approvalVerify.js:534-571). A hostile fee field is the one thingto/value/datadoes not cover. It is not exploitable as shipped, for two compounding reasons:POPUP_ONLY_TYPESatsrc/background/index.js:663-674already rejects these message types from any non-extension sender (so a web page cannot forge them — the content script relays onlyAUTISTMASK_RPC), and the verifier requires the artifact to recover to the approved address, so only a party already holding the key can produce a passing artifact — and such a party can sign whatever it likes anyway. A chainId mismatch is additionally caught by the node at broadcast. Worth a follow-up issue for completeness of the "background is the authority" claim, not a merge blocker.3. ENS
tofailing closed — recommendation: keep it, no changeThe author's judgement is correct and I would not overrule it.
A dApp-initiated
eth_sendTransactioncannot legitimately carry an ENS name into: the JSON-RPC / EIP-1193 parameter is DATA, 20 bytes, and no mainstream wallet resolves ENS there. More decisively, the approval UI in this repo already treatstostrictly as an address —toAddr.toLowerCase()for the token lookup (approval.js:168),decodeCalldata(details.txParams.data, toAddr)(:180) andapprovalAddressHtml(toAddr)(:227). An ENS name would already render as an unresolvable string with no blockie and no token label.So the old behaviour was the bug, not the new one:
sendTransactionwould silently resolve the name in the background, meaning the user approved the text "name.eth" and an entirely different, unverified address got paid. Failing closed with a full-sentence error is a fix. No user flow is broken.(The analogous case — an ENS name in an
address-typed field of typed data — fails in the popup rather than at the verifier, because the sign-path signer is unconnected andresolveNameshas no provider. That is unchanged from the old background code, which also used an unconnected signer.)4. Preserved behaviour — each verified in code
src/background/index.js:718-722and:776-780, both untouched by the diff. ThewindowsApi.onRemovedhandler is likewise untouched.show()re-fetches viaAUTISTMASK_GET_APPROVAL, andshowTxApproval/showSignApprovalre-stash the params atapproval.js:166and:348. The background's disconnect handler still keepstx/signapprovals pending.approval.js:505-511/:596-602, full-sentence inline error, button re-enabled, and no message is sent at all — sopendingApprovals[msg.id]is never reached, let alone deleted. This exceeds the DoD; previously the bad password reached the background, which resolved and deleted the approval, killing the request.5.
TODO(security)markersgrep -rn "TODO(security)" src/returns nothing, and the flaw is genuinely fixed rather than the markers merely deleted. Both halves satisfied.6. Unverified interactive Chrome pass — not blocking, but gate the merge
Honest assessment, judged on risk rather than on the author's candour.
The automated coverage is a reasonable substitute for review purposes. The round-trip suite runs the real popup sequence (
getSignerForAddress,connect,populateTransaction,delete pop.from,signTransaction) and feeds the result to the real background verifier, and the message contract is symmetric and covered on both ends. The two risks unit tests structurally cannot reach are both already mitigated in-tree:confirmTx.js:322-329already doesgetProvider(state.rpcUrl)and broadcasts from the popup, so popup RPC access under the extension CSP and host permissions is proven by shipping code.async— thesendMessagecallback form is retained on both paths, so MV3 messaging semantics are unchanged.Given both, I do not consider this blocking, and there is nothing here the author could rework — they have no browser. But this is a wallet's signing path and DoD items 2-5 explicitly demand an end-to-end run. Recommend merging only after
sneakperforms the interactive Chrome pass acrosseth_sendTransaction,personal_signandeth_signTypedData_v4, exactly as the author requested. Firefox being blocked on #153 is stated plainly in the PR, which is what manager note 5 asked for.Non-blocking findings
src/shared/approvalVerify.js:534-571—verifySignedTxdoes not comparechainId,nonce,gasLimitor the fee fields. Not exploitable as shipped (see item 2). Suggest a follow-up issue.src/popup/views/approval.js:644-651— on a popup-side signing failure the button is re-enabled viasetSignButtonBusy(false), but the background already deleted the approval atsrc/background/index.js:772, so a retry can never succeed. Pre-existing shape, not a regression, but it sits right next to the wrong-password DoD item and deserves its own issue.src/popup/views/approval.js:505-511,:596-602— everydecryptWithPasswordfailure is reported as "That password is incorrect", including a corrupt vault (sodium.from_base64throwing) or sodium failing to initialise. MatchesconfirmTx.js. Cosmetic.src/popup/views/approval.js:435-445—findActiveWallet()matchesstate.activeAddressexactly, whereas the background'sgetActiveAddress()(src/background/index.js:54-62) falls back towallets[0].addresses[0]when it is null. In that state the popup now fails closed where the old code signed with wallet 0. Recorded as an improvement, not a defect — the approval UI renders a blank "From" in that state, so refusing to sign is right.src/shared/approvalVerify.js:608—sameAddressis exported solely for the test file. Minor.TODO.md:11-22— the Status and Next Step blocks are stale, still describing the already-mergedfeat/issue-144-settings-aboutas in flight. Pre-existing onmainand not introduced here; manager note 1 explicitly asked for a surgicalTODO.mdedit while #169 is open, so this is correct behaviour under the instructions given.Things I checked that came back clean
await loadState()from the sign path is safe: that path now uses onlygetActiveAddress(), which reads storage itself viagetState()(src/background/index.js:41-52). The tx path retainsloadState()because it readsstate.rpcUrl.getBytes,decryptWithPassword,getSignerForAddressandmsg.passwordinsrc/background/, all absent. Done by hand becausescript/lintis prettier-only per #152 and cannot catch this.populateTransaction+delete pop.from+signTransactionreproduces ethers' ownAbstractSigner.sendTransactionsequence, so nonce, gas, fee and chain-id population are unchanged from the background implementation. The dApp-suppliedgaskey was ignored bycopyRequestbefore this change and still is — no regression.Manager note (the review verdict is in its own comment above).
Independent adversarial review passed. The reviewer did not author this change
and went after the one way this PR could have been a fake fix — swapping the
password on the wire for something worse. It enumerated every
runtime.sendMessagepayload construction and confirmed only{type, id, approved}plusrawSignedTx|signature|errorcrosses.The interesting part is the leak channel it found and then closed:
payload.error(
approval.js:539,637) is forwarded all the way back to the requesting dApp andsits downstream of the call that handles the decrypted secret. It is safe twice
over — the decrypt failure returns from its own
catchbefore anypayloadexists, and ethers 6.16.0 redacts
privateKey/mnemonic/seed/extendedKeyin
assertArgumenterrors on every branchgetSignerForAddress()can take.The reviewer verified that redaction in the installed dependency tree rather
than assuming it. That is the check I most wanted made.
It also tried and failed to construct an artifact that passes
approvalVerifybut differs from what the user approved, and specifically ruled out signature
malleability and cross-payload replay.
On the ENS behaviour change: no overrule — keeping the fail-closed is
correct, and it turns out to be a security fix rather than a regression. The
old path had
sendTransactionsilently resolving the name in the background,so the user approved the literal string
name.ethon screen while a different,unverified address actually got paid. The approval UI never rendered an ENS
name correctly to begin with (
toAddr.toLowerCase(),decodeCalldata,approvalAddressHtmlall treattostrictly as an address). So this PR closesa second, unrelated hole incidentally. Worth knowing when reading the diff.
Wrong-password handling now exceeds the DoD: it is caught in the popup and no
message is sent at all, so the pending approval is never touched.
Marking
merge-readyand assigning to @sneak.Merge condition — please read before merging
DoD items 2-5 (live dApp
eth_sendTransaction,personal_sign,eth_signTypedData_v4round trips) are unproven. There is no browser inthe agent environment, the author said so plainly rather than claiming the
build as proof, and the reviewer judged the risk rather than the candour —
there is nothing the author could rework to close it.
Both things unit tests structurally cannot reach are already mitigated in-tree:
popup-side RPC access is proven by shipping code at
confirmTx.js:322-329, andthe callback form of
sendMessageis retained so MV3 semantics are unchanged.Even so — this is a wallet's signing path. Please do an interactive Chrome
pass across those three flows before merging. This is the first PR where the
gap bites, and I have raised the general problem as #173 (assigned to you) with
options; this PR is the concrete instance to decide on.
Also note #169 (#149) is still open and also touches
TODO.md. Merging itfirst keeps this one's rebase trivial.
Non-blocking findings and dispositions:
approvalVerify.jsdoes not comparechainId,nonce,gasLimitor fee fields. Not exploitable (
POPUP_ONLY_TYPESatbackground/index.js:663-674restricts the sender, and only a key-holder canproduce a passing artifact), but a hostile fee field is the one gap that
to/value/datado not cover. Filed as #174 against 1.0.0.background already deleted the approval (
background/index.js:772), so aretry cannot succeed. Pre-existing shape, not a regression. Folding into #174
as a second item since it is the same file and the same review.
Rolling into #172, which is already the password-message consistency issue.
sameAddressexported only for tests. Cosmetic, not acting.TODO.mdStatus/Next Step. Pre-existing onmain; managernote 1 explicitly asked for a surgical edit here while #169 is open. I will
refresh it in the next unit once #169 has landed.
clawbot referenced this pull request2026-08-09 07:08:12 +02:00
Manager note on the outstanding caveat on this PR.
This PR is
merge-readyand assigned to you carrying an explicit "Chrome notverified interactively" caveat, on the grounds that no agent could drive a
browser. That premise was wrong, and I have now measured it rather than
inferred it - see
#173 (comment).
A containerized Chrome loads the unpacked MV3 build, runs the popup, and is
fully scriptable. It already caught a live crash on
main(#150) thatmake checkpasses straight through.So the caveat on this PR is dischargeable by machine rather than by your time.
The approval signing path can be driven end to end - a local test page speaking
EIP-1193 to
window.ethereum, through the real content script, the realbackground worker and the real approval popup, with the RPC stubbed - covering
the
eth_sendTransaction,personal_signandeth_signTypedData_v4roundtrips that DoD items 2-5 of #157 call for.
The harness is #181 and is in implementation now. Once it lands I will add the
approval-flow coverage and report the result here.
This does not block merging this PR. Its unit coverage is unchanged and the
change is sound; the harness converts an open caveat into a verified assertion
after the fact. If you would rather hold it until the coverage exists, say so
and I will pull the
merge-readylabel. Otherwise it stays as-is and I willfollow up here with the harness result either way.
The one thing the harness will not discharge is a real dApp with real funds.
That stays with you before the 1.0.0 tag.
security: decrypt and sign dApp approvals in the popup (closes #157)to WIP: security: decrypt and sign dApp approvals in the popup (closes #157)WIP: security: decrypt and sign dApp approvals in the popup (closes #157)to security: decrypt and sign dApp approvals in the popup (closes #157)clawbot referenced this pull request2026-08-10 15:49:44 +02:00
ab3b452924tof37d52e04aClosing out the "Chrome not verified interactively" caveat recorded here.
#273 has landed and drives all four EIP-1193 round trips through the real content script, background worker and approval popup, approved and rejected:
eth_requestAccounts,personal_sign,eth_signTypedData_v4,eth_sendTransaction. Every signature is recovered and compared to the approved address, and the broadcast transaction is parsed from the bytes captured ateth_sendRawTransaction.Directly relevant to this PR's change: the password is now asserted absent from every message crossing the extension boundary, verified by observing the messages rather than by reading the code. That gives this fix a permanent floor instead of a one-time review.
Not covered, and still a human pass before the 1.0.0 tag: a real dApp with real funds against mainnet.