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 standing TODO(security) markers recorded it.
What crosses the boundary now, and why that is safe
message
before
after
AUTISTMASK_TX_RESPONSE
password
rawSignedTx
AUTISTMASK_SIGN_RESPONSE
password
signature
rawSignedTx is the RLP-serialized, already-signed transaction. It is
exactly the value that goes out over eth_sendRawTransaction a moment later
and is public from that point on. It is not a secret, and possessing it does
not let anyone sign anything else.
signature is the 65-byte signature the dApp receives as the result of its
own request. Same argument.
Neither the password, the recovery phrase, the xprv, nor the private key
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 already
decrypted locally, so all three signing paths in the extension have the same
shape.
Changes
src/popup/views/approval.js
Stashes the txParams / signParams it renders, so it signs precisely what
it displayed. Both are repopulated by show(), so close/reopen still works.
On approve: finds the wallet and address index owning state.activeAddress,
calls decryptWithPassword, then getSignerForAddress.
Tx path: populateTransaction then signTransaction. This is the same
sequence ethers' own AbstractSigner.sendTransaction runs internally
(populate, delete pop.from, sign, broadcast), so nonce, gas, fee and chain
id population are byte-for-byte what the background used to produce.
Sign path: signMessage(getBytes(sp.message)) for personal_sign/eth_sign, signTypedData(domain, types, message) for eth_signTypedData_v4/eth_signTypedData — moved over unchanged.
Password and decrypted secret references are nulled in finally blocks
immediately after use, carrying the same immutability caveat comment confirmTx.js already has. Neither is captured in any closure: the sendMessage callback closes over payload and the view state only.
src/background/index.js
AUTISTMASK_TX_RESPONSE: provider.broadcastTransaction(msg.rawSignedTx),
then resolve the approval and respond with the hash.
AUTISTMASK_SIGN_RESPONSE: resolve the approval with msg.signature.
Drops the now-unused decryptWithPassword, getSignerForAddress and getBytes imports. The string password no longer appears anywhere in src/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 with Transaction.from, recovers the sender from the signature, and asserts the
recovered from plus to, value and data match the approval.
verifySignature(signParams, signature, expectedFrom) — recovers with verifyMessage / verifyTypedData and asserts it is the approved address.
All crypto is delegated to ethers, per the Crypto Policy. Every rejection
message is a full sentence. This is what keeps the change security-neutral on
the background side rather than a transfer of trust.
Behaviour preserved
Rejection still returns EIP-1193 4001. The !msg.approved branches and
the windowsApi.onRemoved handler are untouched.
Approvals still survive popup close/reopen.runtime.onConnect's
disconnect handler still keeps tx/sign approvals pending, and show()
re-fetches and re-stashes the params on reopen.
Signing failures still reach the dApp. When the popup cannot produce an
artifact it sends the same message with an error field instead, so the
background resolves the request with a failure exactly as it used to. Without
this the page would have hung until the window was closed.
Wrong password now fails better than the DoD asks. It is caught in the
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 already
there, this adds tests/approvalVerify.test.js). make build — green, both dist/chrome/ and dist/firefox/ produced.
tests/approvalVerify.test.js covers the verifiers directly (tampered
recipient, 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 string password
nor the private key.
Grep evidence (DoD item 1)
No runtime.sendMessage payload anywhere carries a password field:
Argued from the code path, not from the value of DEBUG, since the #145
runtime toggle can raise the level at runtime and isDebug() is evaluated
lazily on every emit():
The password no longer exists in the background context at all, so no
background log statement can reach it regardless of level.
In the popup it exists only as a local let in the two click handlers, and
its only use is as an argument to decryptWithPassword. src/shared/vault.js
imports no logger and calls no console.
src/popup/views/approval.js and src/shared/approvalVerify.js import
neither log nor debugFetch:
debugFetch is reachable only from proxyRpc and the price/balance/phishing
fetchers. Its debug lines log method, url and opts.body. The only body
it is ever given is the JSON-RPC envelope built in proxyRpc, and the popup
never routes a password through any fetch. The ethers provider does its own fetch and does not go through debugFetch at all; what it would carry is
the raw signed transaction, which is public.
The error strings surfaced on the wrong-password path are fixed literals; the
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:
Firefox: blocked on #153, as expected. That issue documents the Firefox
target being non-functional for exactly these approval paths (Chrome callback
APIs against the promise-only browser namespace). Not touched here; folding
it in would make this unreviewable.
Chrome: not verified interactively. There is no browser in the
environment this was built in, so I could not load the unpacked extension and
drive a live dApp through eth_sendTransaction, personal_sign and eth_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, populateTransaction
would resolve it in the popup and the background's to comparison would then
fail closed with "The signed transaction does not go to the approved
recipient." EIP-1193 requires to to be an address and the approval UI already
renders 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.
The "Wrong password." fragment in confirmTx.js is not a full sentence and
so diverges from the language rule in RULES.md. Left alone as unrelated;
happy to file it as its own issue.
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 standing
`TODO(security)` markers recorded it.
## What crosses the boundary now, and why that is safe
| message | before | after |
| --- | --- | --- |
| `AUTISTMASK_TX_RESPONSE` | `password` | `rawSignedTx` |
| `AUTISTMASK_SIGN_RESPONSE` | `password` | `signature` |
- `rawSignedTx` is the RLP-serialized, already-signed transaction. It is
exactly the value that goes out over `eth_sendRawTransaction` a moment later
and is public from that point on. It is not a secret, and possessing it does
not let anyone sign anything else.
- `signature` is the 65-byte signature the dApp receives as the result of its
own request. Same argument.
- Neither the password, the recovery phrase, the xprv, nor the private key
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 already
decrypted locally, so all three signing paths in the extension have the same
shape.
## Changes
**`src/popup/views/approval.js`**
- Stashes the `txParams` / `signParams` it renders, so it signs precisely what
it displayed. Both are repopulated by `show()`, so close/reopen still works.
- On approve: finds the wallet and address index owning `state.activeAddress`,
calls `decryptWithPassword`, then `getSignerForAddress`.
- Tx path: `populateTransaction` then `signTransaction`. This is the same
sequence `ethers`' own `AbstractSigner.sendTransaction` runs internally
(populate, `delete pop.from`, sign, broadcast), so nonce, gas, fee and chain
id population are byte-for-byte what the background used to produce.
- Sign path: `signMessage(getBytes(sp.message))` for
`personal_sign`/`eth_sign`, `signTypedData(domain, types, message)` for
`eth_signTypedData_v4`/`eth_signTypedData` — moved over unchanged.
- Password and decrypted secret references are nulled in `finally` blocks
immediately after use, carrying the same immutability caveat comment
`confirmTx.js` already has. Neither is captured in any closure: the
`sendMessage` callback closes over `payload` and the view state only.
**`src/background/index.js`**
- `AUTISTMASK_TX_RESPONSE`: `provider.broadcastTransaction(msg.rawSignedTx)`,
then resolve the approval and respond with the hash.
- `AUTISTMASK_SIGN_RESPONSE`: resolve the approval with `msg.signature`.
- Drops the now-unused `decryptWithPassword`, `getSignerForAddress` and
`getBytes` imports. The string `password` no longer appears anywhere in
`src/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 with
`Transaction.from`, recovers the sender from the signature, and asserts the
recovered `from` plus `to`, `value` and `data` match the approval.
- `verifySignature(signParams, signature, expectedFrom)` — recovers with
`verifyMessage` / `verifyTypedData` and asserts it is the approved address.
All crypto is delegated to `ethers`, per the Crypto Policy. Every rejection
message is a full sentence. This is what keeps the change security-neutral on
the background side rather than a transfer of trust.
## Behaviour preserved
- **Rejection still returns EIP-1193 4001.** The `!msg.approved` branches and
the `windowsApi.onRemoved` handler are untouched.
- **Approvals still survive popup close/reopen.** `runtime.onConnect`'s
disconnect handler still keeps `tx`/`sign` approvals pending, and `show()`
re-fetches and re-stashes the params on reopen.
- **Signing failures still reach the dApp.** When the popup cannot produce an
artifact it sends the same message with an `error` field instead, so the
background resolves the request with a failure exactly as it used to. Without
this the page would have hung until the window was closed.
- **Wrong password now fails better than the DoD asks.** It is caught in the
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 already
there, this adds `tests/approvalVerify.test.js`). `make build` — green, both
`dist/chrome/` and `dist/firefox/` produced.
`tests/approvalVerify.test.js` covers the verifiers directly (tampered
recipient, 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 string `password`
nor the private key.
### Grep evidence (DoD item 1)
No `runtime.sendMessage` payload anywhere carries a password field:
```
$ grep -rn -A8 "runtime.sendMessage" src/ --include='*.js' | grep -i password
$ echo $?
1
```
The word does not occur in the background or content script at all any more:
```
$ grep -rn "password" src/background/ src/content/
$ echo $?
1
```
And the markers are gone (DoD item 6):
```
$ grep -rn "TODO(security)" src/
$ echo $?
1
```
### Logging (DoD item 8)
Argued from the code path, not from the value of `DEBUG`, since the #145
runtime toggle can raise the level at runtime and `isDebug()` is evaluated
lazily on every `emit()`:
- The password no longer exists in the background context at all, so no
background log statement can reach it regardless of level.
- In the popup it exists only as a local `let` in the two click handlers, and
its only use is as an argument to `decryptWithPassword`. `src/shared/vault.js`
imports no logger and calls no `console`.
- `src/popup/views/approval.js` and `src/shared/approvalVerify.js` import
neither `log` nor `debugFetch`:
```
$ grep -rn "log\.\|debugFetch" src/popup/views/approval.js src/shared/vault.js src/shared/approvalVerify.js
$ echo $?
1
```
- `debugFetch` is reachable only from `proxyRpc` and the price/balance/phishing
fetchers. Its debug lines log `method`, `url` and `opts.body`. The only body
it is ever given is the JSON-RPC envelope built in `proxyRpc`, and the popup
never routes a password through any fetch. The `ethers` provider does its own
`fetch` and does not go through `debugFetch` at all; what it would carry is
the raw signed transaction, which is public.
- The error strings surfaced on the wrong-password path are fixed literals; the
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:
- **Firefox: blocked on #153**, as expected. That issue documents the Firefox
target being non-functional for exactly these approval paths (Chrome callback
APIs against the promise-only `browser` namespace). Not touched here; folding
it in would make this unreviewable.
- **Chrome: not verified interactively.** There is no browser in the
environment this was built in, so I could not load the unpacked extension and
drive a live dApp through `eth_sendTransaction`, `personal_sign` and
`eth_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`, `populateTransaction`
would resolve it in the popup and the background's `to` comparison would then
fail closed with "The signed transaction does not go to the approved
recipient." EIP-1193 requires `to` to be an address and the approval UI already
renders 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
- #153 (Firefox), per the above.
- The `"Wrong password."` fragment in `confirmTx.js` is not a full sentence and
so diverges from the language rule in `RULES.md`. Left alone as unrelated;
happy to file it as its own issue.
src/popup/views/approval.js — the two dApp approval handlers now decrypt
with decryptWithPassword and sign with getSignerForAddress in the popup,
exactly as confirmTx.js already did. They keep the rendered txParams / signParams so they sign what was displayed, and null the password and the
decrypted secret in finally blocks 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 unused decryptWithPassword, getSignerForAddress and getBytes imports are gone.
src/shared/approvalVerify.js (new) — verifySignedTx and verifySignature, so the background re-derives the signer from the artifact
and 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 a
single 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) and signature. Both are
public 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 check green: 5 suites, 75 tests. make build green for both targets.
The pre-commit hook ran make check on the commit as well.
Round-trip test runs the real popup sequence — getSignerForAddress, connect, populateTransaction, delete pop.from, signTransaction — and
feeds 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 contains
neither the string password nor the private key.
Greps in the PR body show no runtime.sendMessage payload carries a password
field, that password no longer appears in src/background/ or src/content/ at all, and that the TODO(security) markers are gone.
Logging argued from the code path rather than the DEBUG flag, since isDebug() is evaluated lazily on every emit and the #145 runtime toggle can
raise the level at any time.
Behaviour preserved: rejection still resolves EIP-1193 4001; approvals still
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
Chrome, interactively. No browser exists in the build environment, so I
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.
Firefox. Blocked on #153 as expected, and deliberately not touched here.
## 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 decrypt
with `decryptWithPassword` and sign with `getSignerForAddress` in the popup,
exactly as `confirmTx.js` already did. They keep the rendered `txParams` /
`signParams` so they sign what was displayed, and null the password and the
decrypted secret in `finally` blocks 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 unused
`decryptWithPassword`, `getSignerForAddress` and `getBytes` imports are gone.
- `src/shared/approvalVerify.js` (new) — `verifySignedTx` and
`verifySignature`, so the background re-derives the signer from the artifact
and 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 a
single 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) and `signature`. Both are
public 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 check` green: 5 suites, 75 tests. `make build` green for both targets.
The pre-commit hook ran `make check` on the commit as well.
- Round-trip test runs the real popup sequence — `getSignerForAddress`,
`connect`, `populateTransaction`, `delete pop.from`, `signTransaction` — and
feeds 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 contains
neither the string `password` nor the private key.
- Tamper cases rejected: swapped recipient, inflated value, substituted call
data, wrong signer, unsigned payload, undecodable payload.
- Greps in the PR body show no `runtime.sendMessage` payload carries a password
field, that `password` no longer appears in `src/background/` or
`src/content/` at all, and that the `TODO(security)` markers are gone.
- Logging argued from the code path rather than the `DEBUG` flag, since
`isDebug()` is evaluated lazily on every emit and the #145 runtime toggle can
raise the level at any time.
- Behaviour preserved: rejection still resolves EIP-1193 4001; approvals still
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
- **Chrome, interactively.** No browser exists in the build environment, so I
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.
- **Firefox.** Blocked on #153 as expected, and deliberately not touched here.
1. What crosses the boundary — verified independently
Enumerated every runtime.sendMessage call site in src/ and read each payload construction. The complete set of payload shapes on the approval paths is:
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 inside getSignerForAddress() a candidate secret-exfiltration channel to a hostile website, since that call receives the decrypted secret directly. It is closed on two independent grounds:
The decrypt failure is handled in its own catch that returns before any payload object exists (approval.js:505-511, :596-602), so a bad secret never reaches the error-forwarding block.
ethers 6.16.0 redacts secret arguments in assertArgument errors on every branch getSignerForAddress() 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: signer and decryptedSecret are not captured by the sendMessage callback — it closes over payload and view state only — and both password and decryptedSecret are nulled in finally blocks. This is stricter than the reference implementation in confirmTx.js, which nulls only decryptedSecret and leaves password a const.
2. Is src/shared/approvalVerify.js sound?
I could not construct an artifact that passes verification but differs from what the user approved in a way that matters.
Cross-payload replay of a signature: fails closed. verifyMessage / verifyTypedData recover over the approved payload, so a signature captured over anything else recovers to an unrelated address.
Malleability: not exploitable, on two grounds. ethers rejects non-canonical (high-s) signatures via the assertion in the Signature.s getter (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.
sameAddress fallback (approvalVerify.js:507-516): the catch that falls back to lowercase string comparison is not a bypass. parsed.to from ethers is always a well-formed checksummed address or null, so the fallback can only fire on a malformed txParams.to, and it must then lowercase-equal a valid address — which is the correct answer.
Typed data: delete types.EIP712Domain operates on a fresh JSON.parse result, so the held approval is not mutated. Pinned by the test at tests/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.
Mismatch genuinely fails closed: every rejection is a throw inside the background's try, which resolves the approval with an error and never reaches broadcastTransaction. The verify call precedes getProvider at src/background/index.js:741.
Non-blocking gap (finding A).verifySignedTx compares from, to, value and data, but not chainId, nonce, gasLimit, maxFeePerGas or maxPriorityFeePerGas (src/shared/approvalVerify.js:534-571). A hostile fee field is the one thing to/value/data does not cover. It is not exploitable as shipped, for two compounding reasons: POPUP_ONLY_TYPES at src/background/index.js:663-674 already rejects these message types from any non-extension sender (so a web page cannot forge them — the content script relays only AUTISTMASK_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 to failing closed — recommendation: keep it, no change
The author's judgement is correct and I would not overrule it.
A dApp-initiated eth_sendTransaction cannot legitimately carry an ENS name in to: 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 treats to strictly as an address — toAddr.toLowerCase() for the token lookup (approval.js:168), decodeCalldata(details.txParams.data, toAddr) (:180) and approvalAddressHtml(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: sendTransaction would 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 and resolveNames has no provider. That is unchanged from the old background code, which also used an unconnected signer.)
4. Preserved behaviour — each verified in code
EIP-1193 4001 on rejection: src/background/index.js:718-722 and :776-780, both untouched by the diff. The windowsApi.onRemoved handler is likewise untouched.
Survives popup close/reopen: show() re-fetches via AUTISTMASK_GET_APPROVAL, and showTxApproval / showSignApproval re-stash the params at approval.js:166 and :348. The background's disconnect handler still keeps tx/sign approvals pending.
Wrong password fails cleanly and does not resolve the approval: caught at approval.js:505-511 / :596-602, full-sentence inline error, button re-enabled, and no message is sent at all — so pendingApprovals[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) markers
grep -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:
Popup provider access at approval time — confirmTx.js:322-329 already does getProvider(state.rpcUrl) and broadcasts from the popup, so popup RPC access under the extension CSP and host permissions is proven by shipping code.
The click handler becoming async — the sendMessage callback 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 sneak performs the interactive Chrome pass across eth_sendTransaction, personal_sign and eth_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
A.src/shared/approvalVerify.js:534-571 — verifySignedTx does not compare chainId, nonce, gasLimit or the fee fields. Not exploitable as shipped (see item 2). Suggest a follow-up issue.
B.src/popup/views/approval.js:644-651 — on a popup-side signing failure the button is re-enabled via setSignButtonBusy(false), but the background already deleted the approval at src/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.
C.src/popup/views/approval.js:505-511, :596-602 — every decryptWithPassword failure is reported as "That password is incorrect", including a corrupt vault (sodium.from_base64 throwing) or sodium failing to initialise. Matches confirmTx.js. Cosmetic.
D.src/popup/views/approval.js:435-445 — findActiveWallet() matches state.activeAddress exactly, whereas the background's getActiveAddress() (src/background/index.js:54-62) falls back to wallets[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.
E.src/shared/approvalVerify.js:608 — sameAddress is exported solely for the test file. Minor.
F.TODO.md:11-22 — the Status and Next Step blocks are stale, still describing the already-merged feat/issue-144-settings-about as in flight. Pre-existing on main and not introduced here; manager note 1 explicitly asked for a surgical TODO.md edit while #169 is open, so this is correct behaviour under the instructions given.
Things I checked that came back clean
Dropping await loadState() from the sign path is safe: that path now uses only getActiveAddress(), which reads storage itself via getState() (src/background/index.js:41-52). The tx path retains loadState() because it reads state.rpcUrl.
No dangling identifiers after the import removals — checked by grep for getBytes, decryptWithPassword, getSignerForAddress and msg.password in src/background/, all absent. Done by hand because script/lint is prettier-only per #152 and cannot catch this.
Every ethers import in both new and changed modules is used; none left over.
populateTransaction + delete pop.from + signTransaction reproduces ethers' own AbstractSigner.sendTransaction sequence, so nonce, gas, fee and chain-id population are unchanged from the background implementation. The dApp-supplied gas key was ignored by copyRequest before this change and still is — no regression.
Tests are meaningful, not vacuous: they assert on specific rejection messages, and cover contract creation with no recipient, absent value, case-differing call data, non-mutation of held approval state, and the exact wire payload key set.
# 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
| Gate | Result |
| --- | --- |
| `make check` | green — 5 suites, 75 tests, prettier clean, `fmt-check` clean |
| `make build` | green — `dist/chrome/` and `dist/firefox/` both produced |
| CI on `ab3b452` | green — `check / check (push)`, "Successful in 28s" |
| Mergeable vs `main` | yes — branch contains `origin/main` (`23aeae4`); no conflicts |
| Commit title | ends with ` (closes #157)` |
| Attribution trailers | none |
| `RULES.md` | unmodified |
| `TODO.md` | updated in the same commit |
| Scope creep | none; #153 untouched |
| Inclusive terminology | clean on all changed files |
---
## 1. What crosses the boundary — verified independently
Enumerated every `runtime.sendMessage` call site in `src/` 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 inside `getSignerForAddress()` a candidate secret-exfiltration channel to a hostile website, since that call receives the decrypted secret directly. It is closed on two independent grounds:
- The decrypt failure is handled in its own `catch` that returns before any `payload` object exists (`approval.js:505-511`, `:596-602`), so a bad secret never reaches the error-forwarding block.
- ethers 6.16.0 redacts secret arguments in `assertArgument` errors on every branch `getSignerForAddress()` 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: `signer` and `decryptedSecret` are not captured by the `sendMessage` callback — it closes over `payload` and view state only — and both `password` and `decryptedSecret` are nulled in `finally` blocks. This is stricter than the reference implementation in `confirmTx.js`, which nulls only `decryptedSecret` and leaves `password` a `const`.
## 2. Is `src/shared/approvalVerify.js` sound?
I could not construct an artifact that passes verification but differs from what the user approved in a way that matters.
- **Cross-payload replay of a signature**: fails closed. `verifyMessage` / `verifyTypedData` recover over the *approved* payload, so a signature captured over anything else recovers to an unrelated address.
- **Malleability**: not exploitable, on two grounds. ethers rejects non-canonical (high-s) signatures via the assertion in the `Signature.s` getter (`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.
- **`sameAddress` fallback** (`approvalVerify.js:507-516`): the `catch` that falls back to lowercase string comparison is not a bypass. `parsed.to` from ethers is always a well-formed checksummed address or null, so the fallback can only fire on a malformed `txParams.to`, and it must then lowercase-equal a valid address — which is the correct answer.
- **Typed data**: `delete types.EIP712Domain` operates on a fresh `JSON.parse` result, so the held approval is not mutated. Pinned by the test at `tests/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.
- **Mismatch genuinely fails closed**: every rejection is a `throw` inside the background's `try`, which resolves the approval with an error and never reaches `broadcastTransaction`. The verify call precedes `getProvider` at `src/background/index.js:741`.
**Non-blocking gap (finding A).** `verifySignedTx` compares `from`, `to`, `value` and `data`, but not `chainId`, `nonce`, `gasLimit`, `maxFeePerGas` or `maxPriorityFeePerGas` (`src/shared/approvalVerify.js:534-571`). A hostile fee field is the one thing `to`/`value`/`data` does not cover. It is not exploitable as shipped, for two compounding reasons: `POPUP_ONLY_TYPES` at `src/background/index.js:663-674` already rejects these message types from any non-extension sender (so a web page cannot forge them — the content script relays only `AUTISTMASK_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 `to` failing closed — recommendation: keep it, no change
The author's judgement is correct and I would not overrule it.
A dApp-initiated `eth_sendTransaction` cannot legitimately carry an ENS name in `to`: 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 treats `to` strictly as an address — `toAddr.toLowerCase()` for the token lookup (`approval.js:168`), `decodeCalldata(details.txParams.data, toAddr)` (`:180`) and `approvalAddressHtml(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: `sendTransaction` would 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 and `resolveNames` has no provider. That is unchanged from the old background code, which also used an unconnected signer.)
## 4. Preserved behaviour — each verified in code
- **EIP-1193 4001 on rejection**: `src/background/index.js:718-722` and `:776-780`, both untouched by the diff. The `windowsApi.onRemoved` handler is likewise untouched.
- **Survives popup close/reopen**: `show()` re-fetches via `AUTISTMASK_GET_APPROVAL`, and `showTxApproval` / `showSignApproval` re-stash the params at `approval.js:166` and `:348`. The background's disconnect handler still keeps `tx`/`sign` approvals pending.
- **Wrong password fails cleanly and does not resolve the approval**: caught at `approval.js:505-511` / `:596-602`, full-sentence inline error, button re-enabled, and **no message is sent at all** — so `pendingApprovals[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)` markers
`grep -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:
- *Popup provider access at approval time* — `confirmTx.js:322-329` already does `getProvider(state.rpcUrl)` and broadcasts from the popup, so popup RPC access under the extension CSP and host permissions is proven by shipping code.
- *The click handler becoming `async`* — the `sendMessage` callback 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 `sneak` performs the interactive Chrome pass** across `eth_sendTransaction`, `personal_sign` and `eth_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
- **A.** `src/shared/approvalVerify.js:534-571` — `verifySignedTx` does not compare `chainId`, `nonce`, `gasLimit` or the fee fields. Not exploitable as shipped (see item 2). Suggest a follow-up issue.
- **B.** `src/popup/views/approval.js:644-651` — on a popup-side *signing* failure the button is re-enabled via `setSignButtonBusy(false)`, but the background already deleted the approval at `src/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.
- **C.** `src/popup/views/approval.js:505-511`, `:596-602` — every `decryptWithPassword` failure is reported as "That password is incorrect", including a corrupt vault (`sodium.from_base64` throwing) or sodium failing to initialise. Matches `confirmTx.js`. Cosmetic.
- **D.** `src/popup/views/approval.js:435-445` — `findActiveWallet()` matches `state.activeAddress` exactly, whereas the background's `getActiveAddress()` (`src/background/index.js:54-62`) falls back to `wallets[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.
- **E.** `src/shared/approvalVerify.js:608` — `sameAddress` is exported solely for the test file. Minor.
- **F.** `TODO.md:11-22` — the Status and Next Step blocks are stale, still describing the already-merged `feat/issue-144-settings-about` as in flight. Pre-existing on `main` and not introduced here; manager note 1 explicitly asked for a surgical `TODO.md` edit while #169 is open, so this is correct behaviour under the instructions given.
## Things I checked that came back clean
- Dropping `await loadState()` from the sign path is safe: that path now uses only `getActiveAddress()`, which reads storage itself via `getState()` (`src/background/index.js:41-52`). The tx path retains `loadState()` because it reads `state.rpcUrl`.
- No dangling identifiers after the import removals — checked by grep for `getBytes`, `decryptWithPassword`, `getSignerForAddress` and `msg.password` in `src/background/`, all absent. Done by hand because `script/lint` is prettier-only per #152 and cannot catch this.
- Every ethers import in both new and changed modules is used; none left over.
- `populateTransaction` + `delete pop.from` + `signTransaction` reproduces ethers' own `AbstractSigner.sendTransaction` sequence, so nonce, gas, fee and chain-id population are unchanged from the background implementation. The dApp-supplied `gas` key was ignored by `copyRequest` before this change and still is — no regression.
- Tests are meaningful, not vacuous: they assert on specific rejection messages, and cover contract creation with no recipient, absent value, case-differing call data, non-mutation of held approval state, and the exact wire payload key set.
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.sendMessage payload construction and confirmed only {type, id, approved} plus rawSignedTx | signature | error crosses.
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 and
sits downstream of the call that handles the decrypted secret. It is safe twice
over — the decrypt failure returns from its own catch before any payload
exists, and ethers 6.16.0 redacts privateKey/mnemonic/seed/extendedKey
in assertArgument errors on every branch getSignerForAddress() 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 approvalVerify
but 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 sendTransaction silently resolving the name in the background,
so the user approved the literal string name.eth on screen while a different,
unverified address actually got paid. The approval UI never rendered an ENS
name correctly to begin with (toAddr.toLowerCase(), decodeCalldata, approvalAddressHtml all treat to strictly as an address). So this PR closes
a 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.
DoD items 2-5 (live dApp eth_sendTransaction, personal_sign, eth_signTypedData_v4 round trips) are unproven. There is no browser in
the 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, and
the callback form of sendMessage is 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 it
first keeps this one's rebase trivial.
Non-blocking findings and dispositions:
A — approvalVerify.js does not compare chainId, nonce, gasLimit
or fee fields. Not exploitable (POPUP_ONLY_TYPES at background/index.js:663-674 restricts the sender, and only a key-holder can
produce a passing artifact), but a hostile fee field is the one gap that to/value/data do not cover. Filed as #174 against 1.0.0.
B — after a popup-side signing failure the button re-enables, but the
background already deleted the approval (background/index.js:772), so a
retry 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.
C — a corrupt vault reports "password is incorrect", which is misleading.
Rolling into #172, which is already the password-message consistency issue.
E — sameAddress exported only for tests. Cosmetic, not acting.
F — stale TODO.md Status/Next Step. Pre-existing on main; manager
note 1 explicitly asked for a surgical edit here while #169 is open. I will
refresh it in the next unit once #169 has landed.
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.sendMessage` payload construction and confirmed only
`{type, id, approved}` plus `rawSignedTx` | `signature` | `error` crosses.
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 and
sits downstream of the call that handles the decrypted secret. It is safe twice
over — the decrypt failure returns from its own `catch` before any `payload`
exists, and ethers 6.16.0 redacts `privateKey`/`mnemonic`/`seed`/`extendedKey`
in `assertArgument` errors on every branch `getSignerForAddress()` 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 `approvalVerify`
but 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 `sendTransaction` silently resolving the name in the background,
so the user approved the literal string `name.eth` on screen while a different,
unverified address actually got paid. The approval UI never rendered an ENS
name correctly to begin with (`toAddr.toLowerCase()`, `decodeCalldata`,
`approvalAddressHtml` all treat `to` strictly as an address). So this PR closes
a 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-ready` and assigning to @sneak.
## Merge condition — please read before merging
DoD items 2-5 (live dApp `eth_sendTransaction`, `personal_sign`,
`eth_signTypedData_v4` round trips) are **unproven**. There is no browser in
the 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`, and
the callback form of `sendMessage` is 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 it
first keeps this one's rebase trivial.
Non-blocking findings and dispositions:
- **A** — `approvalVerify.js` does not compare `chainId`, `nonce`, `gasLimit`
or fee fields. Not exploitable (`POPUP_ONLY_TYPES` at
`background/index.js:663-674` restricts the sender, and only a key-holder can
produce a passing artifact), but a hostile fee field is the one gap that
`to`/`value`/`data` do not cover. Filed as #174 against 1.0.0.
- **B** — after a popup-side signing failure the button re-enables, but the
background already deleted the approval (`background/index.js:772`), so a
retry 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.
- **C** — a corrupt vault reports "password is incorrect", which is misleading.
Rolling into #172, which is already the password-message consistency issue.
- **E** — `sameAddress` exported only for tests. Cosmetic, not acting.
- **F** — stale `TODO.md` Status/Next Step. Pre-existing on `main`; manager
note 1 explicitly asked for a surgical edit here while #169 is open. I will
refresh it in the next unit once #169 has landed.
Manager note on the outstanding caveat on this PR.
This PR is merge-ready and assigned to you carrying an explicit "Chrome not
verified 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) that make check passes 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 real
background worker and the real approval popup, with the RPC stubbed - covering
the eth_sendTransaction, personal_sign and eth_signTypedData_v4 round
trips 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-ready label. Otherwise it stays as-is and I will
follow 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.
Manager note on the outstanding caveat on this PR.
This PR is `merge-ready` and assigned to you carrying an explicit "Chrome not
verified 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
https://git.eeqj.de/sneak/AutistMask/issues/173#issuecomment-49609.
A containerized Chrome loads the unpacked MV3 build, runs the popup, and is
fully scriptable. It already caught a live crash on `main` (#150) that
`make check` passes 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 real
background worker and the real approval popup, with the RPC stubbed - covering
the `eth_sendTransaction`, `personal_sign` and `eth_signTypedData_v4` round
trips 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-ready` label. Otherwise it stays as-is and I will
follow 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.
The dApp transaction and signature approval paths sent the user's plaintext
password to the background over runtime.sendMessage and decrypted there. Both
now decrypt in the popup, where the password is typed, and put only the signed
artifact on the wire: the raw signed transaction, or the signature. Neither the
password, the recovery phrase, the xprv nor the private key crosses the
messaging boundary any more. This matches what the popup-side eth_sendTransaction
path in confirmTx.js already did.
The popup runs the same sequence ethers' own sendTransaction() runs internally
(populateTransaction, then signTransaction), so nonce, gas, fee and chain id
population are unchanged. The background keeps broadcast and approval
resolution, and when the popup cannot produce an artifact it reports the error
over the same message so the requesting page still gets a failure rather than
hanging.
Moving the secret out of the background must not turn the background into a
blind relay, so it re-derives the signer from the artifact and checks it
against the approval it is holding before acting: shared/approvalVerify.js
asserts that a raw transaction is the approved transaction signed by the
approved address, and that a signature covers the approved payload and recovers
to the approved address.
A wrong password is now caught in the popup before anything is sent, so it
fails with an inline full-sentence error and leaves the pending approval alive
to retry; previously it reached the background and destroyed the approval.
Rejection still resolves with EIP-1193 code 4001, and approvals still survive
popup close and reopen.
Removes the four standing TODO(security) markers, now that the flaw is gone.
Closing 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 at eth_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.
Closing out the "Chrome not verified interactively" caveat recorded here.
https://git.eeqj.de/sneak/AutistMask/pulls/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 at `eth_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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
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.