Compare commits
1 Commits
ab3b452924
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f141a757 |
9
Makefile
9
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build clean dev
|
.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build build-debug clean dev
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -38,6 +38,13 @@ build:
|
|||||||
@echo "Building extension..."
|
@echo "Building extension..."
|
||||||
@yarn run build 2>&1
|
@yarn run build 2>&1
|
||||||
|
|
||||||
|
# Development-only build: enables the red DEBUG / INSECURE banner and makes
|
||||||
|
# the hardcoded test recovery phrase the output of wallet creation. Never
|
||||||
|
# distribute the artifacts this produces.
|
||||||
|
build-debug:
|
||||||
|
@echo "Building extension (DEBUG)..."
|
||||||
|
@AUTISTMASK_DEBUG=1 yarn run build 2>&1
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@rm -rf dist/
|
@rm -rf dist/
|
||||||
|
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -42,6 +42,23 @@ Load the extension:
|
|||||||
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
|
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
|
||||||
Temporary Add-on", and select `dist/firefox/manifest.json`.
|
Temporary Add-on", and select `dist/firefox/manifest.json`.
|
||||||
|
|
||||||
|
### Debug Builds
|
||||||
|
|
||||||
|
`make build` always produces a release build: the build-time `DEBUG` constant is
|
||||||
|
`false`, so wallet creation uses real entropy and the red banner is off. To
|
||||||
|
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-debug # or: AUTISTMASK_DEBUG=1 make build
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the exact value `1` enables it; any other value (including unset, empty, or
|
||||||
|
`true`) yields a release build, so a typo cannot accidentally ship the debug
|
||||||
|
behavior. The build prints which mode it used. See the
|
||||||
|
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
|
||||||
|
distribute a debug build** — every wallet it creates gets the same publicly
|
||||||
|
known test recovery phrase.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
@@ -668,6 +685,19 @@ flows, or alter program behavior beyond the banner and the hardcoded mnemonic.
|
|||||||
Adding new DEBUG-conditional branches requires explicit approval from the
|
Adding new DEBUG-conditional branches requires explicit approval from the
|
||||||
project owner.
|
project owner.
|
||||||
|
|
||||||
|
`DEBUG` is a build-time constant, not a runtime setting. `build.js` injects it
|
||||||
|
into the bundle as the `__BUILD_DEBUG__` define — `false` unless the build was
|
||||||
|
run with `AUTISTMASK_DEBUG=1` (see [Debug Builds](#debug-builds)) — and
|
||||||
|
`src/shared/constants.js` reads it. It cannot be changed after the bundle is
|
||||||
|
produced.
|
||||||
|
|
||||||
|
The debug-mode toggle in settings is a separate, runtime-only flag. It raises
|
||||||
|
the log level and turns the banner on, and that is all it may ever do: it feeds
|
||||||
|
`isDebug()` in `src/shared/log.js`, which is deliberately not what
|
||||||
|
`generateMnemonic()` consults. Mnemonic generation reads the build-time `DEBUG`
|
||||||
|
constant directly, so no runtime toggle in a release build can reach the
|
||||||
|
hardcoded test phrase.
|
||||||
|
|
||||||
### Key Decisions
|
### Key Decisions
|
||||||
|
|
||||||
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
||||||
|
|||||||
69
TODO.md
69
TODO.md
@@ -10,28 +10,31 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. Tagged v0.1.0 on 2026-02-27. Active development on branch
|
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. No
|
||||||
feat/issue-144-settings-about (another agent working as of 2026-07-06). Full
|
other branch is in flight: the settings About well landed as #145 on 2026-07-26
|
||||||
policy file set present; make check on main not verified.
|
and scripts-to-rule-them-all landed as #148, so the `scripts/` directory
|
||||||
|
question is resolved. Full policy file set present. `make check` verified
|
||||||
|
passing on `main` at `23aeae4` on 2026-08-09. The 1.0.0 backlog is filed as
|
||||||
|
#149-#168.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Land feat/issue-144-settings-about: finish the settings About well (build info,
|
Land #149: make `DEBUG` a build-time constant that defaults to off, injected as
|
||||||
app name and repo link, release date, version click easter egg, git info derived
|
the `__BUILD_DEBUG__` esbuild define from `AUTISTMASK_DEBUG=1`, so a plain
|
||||||
inside Docker), resolve the untracked scripts/ directory (commit or gitignore),
|
`make build` stops handing every newly created wallet the publicly committed
|
||||||
get review, merge to main.
|
test recovery phrase. Branch `fix/issue-149-debug-build-flag`; PR open, awaiting
|
||||||
|
review.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-09: dApp approval signing moved into the popup — the password no
|
- 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog
|
||||||
longer crosses the extension messaging boundary; the background broadcasts and
|
(#149-#168).
|
||||||
resolves approvals only, and verifies the signed artifact against the approval
|
- 2026-07-26: About well in settings with build info, repo link and the version
|
||||||
it holds (#157).
|
click easter egg (#145); proper view navigation stack (#146).
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
shims, README Entrypoints section
|
shims, README Entrypoints section (#148)
|
||||||
- 2026-03-01: About well in settings with build info and easter egg (in flight
|
- 2026-03-01: USD display suppressed on testnets (#142); estimated USD for ETH
|
||||||
on feature branch); USD display suppressed on testnets (#142); estimated USD
|
in approve-tx view (#141).
|
||||||
for ETH in approve-tx view (#141).
|
|
||||||
- Sepolia testnet support (#137); etherscan links go to token-specific URLs
|
- Sepolia testnet support (#137); etherscan links go to token-specific URLs
|
||||||
(#136).
|
(#136).
|
||||||
- Transaction detail improvements: Type field and on-chain details (#130),
|
- Transaction detail improvements: Type field and on-chain details (#130),
|
||||||
@@ -49,12 +52,32 @@ get review, merge to main.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Verify main passes make check after the feature branch merges (not verified
|
- Fix the two `ReferenceError` crashes that make whole screens unreachable:
|
||||||
2026-07-06 because an agent was active in the tree); fix anything red. main
|
AddToken (#150) and TransactionDetail for every ERC-20 transfer (#151).
|
||||||
must always be green.
|
- Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only
|
||||||
- Prune stale branches: dozens of merged local and remote feature branches
|
and cannot catch undefined identifiers, which is how #150 and #151 shipped.
|
||||||
remain (fix/_, feature/_, tx-\*); delete merged ones locally and on origin.
|
- Make the Firefox target functional: Chrome callback APIs are used against the
|
||||||
- Continue the issue backlog toward a feature-complete wallet, then cut further
|
promise-only `browser` namespace (#153).
|
||||||
tags as milestones land.
|
- Send and transaction-flow correctness: gas fee excluded from the
|
||||||
|
insufficient-balance check (#154), WaitTx 60s timeout overwriting a rendered
|
||||||
|
success screen (#155), last-wallet deletion leaving inconsistent state (#156).
|
||||||
|
- Security: plaintext password crossing the extension messaging boundary during
|
||||||
|
dApp approvals (#157); MV3 service worker termination killing the background
|
||||||
|
refresh and the 24h phishing list update (#158).
|
||||||
|
- Test the crypto core — `wallet.js` derivation and `vault.js` encryption (#159)
|
||||||
|
— and the address-poisoning defense in `transactions.js` (#160).
|
||||||
|
- Wallet features for 1.0: show a wallet's recovery phrase behind the password
|
||||||
|
(#161), delete an address from an HD wallet (#162).
|
||||||
|
- Docs: `docs/README.md` contradicts the code on external services and names
|
||||||
|
competitors (#163); README Screen Map omits three shipped screens (#164).
|
||||||
|
- Owner decisions: Sepolia support versus "Non-Goals for 1.0", and `isMetaMask`
|
||||||
|
naming a competitor in shipped code (#165).
|
||||||
|
- Repo policy compliance sweep: test rerun pattern, `yarn`/`npx`, frozen
|
||||||
|
lockfile, undocumented Makefile targets (#166).
|
||||||
|
- Prune the 24 stale remote feature branches (#167).
|
||||||
|
- Remove dead exports and de-duplicate copy-pasted view helpers (#168).
|
||||||
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
||||||
input validation) before any 1.0rc tag.
|
input validation) before any 1.0rc tag; #149 and #157 are parts of it, but the
|
||||||
|
review is broader than either.
|
||||||
|
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
||||||
|
land.
|
||||||
|
|||||||
16
build.js
16
build.js
@@ -11,6 +11,14 @@ function ensureDir(dir) {
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DEBUG is a build-time flag, off unless explicitly requested. It is the only
|
||||||
|
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
|
||||||
|
// exact: anything other than the literal "1" (unset, empty, "true", a typo)
|
||||||
|
// produces a release build. Failing towards the safe mode is deliberate.
|
||||||
|
function isDebugBuild() {
|
||||||
|
return process.env.AUTISTMASK_DEBUG === "1";
|
||||||
|
}
|
||||||
|
|
||||||
function getBuildInfo() {
|
function getBuildInfo() {
|
||||||
const pkg = JSON.parse(
|
const pkg = JSON.parse(
|
||||||
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
|
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
|
||||||
@@ -47,7 +55,15 @@ async function build() {
|
|||||||
const buildInfo = getBuildInfo();
|
const buildInfo = getBuildInfo();
|
||||||
console.log("Build info:", buildInfo);
|
console.log("Build info:", buildInfo);
|
||||||
|
|
||||||
|
const debugBuild = isDebugBuild();
|
||||||
|
console.log(
|
||||||
|
debugBuild
|
||||||
|
? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)"
|
||||||
|
: "Build mode: release (DEBUG off)",
|
||||||
|
);
|
||||||
|
|
||||||
const define = {
|
const define = {
|
||||||
|
__BUILD_DEBUG__: JSON.stringify(debugBuild),
|
||||||
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
|
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
|
||||||
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
|
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
|
||||||
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
|
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
const { DEFAULT_RPC_URL } = require("../shared/constants");
|
const { DEFAULT_RPC_URL } = require("../shared/constants");
|
||||||
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
|
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
|
||||||
const { onChainSwitch } = require("../shared/chainSwitch");
|
const { onChainSwitch } = require("../shared/chainSwitch");
|
||||||
|
const { getBytes } = require("ethers");
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
loadState,
|
loadState,
|
||||||
@@ -13,7 +14,8 @@ const {
|
|||||||
} = require("../shared/state");
|
} = require("../shared/state");
|
||||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||||
const { debugFetch } = require("../shared/log");
|
const { debugFetch } = require("../shared/log");
|
||||||
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
|
const { decryptWithPassword } = require("../shared/vault");
|
||||||
|
const { getSignerForAddress } = require("../shared/wallet");
|
||||||
const {
|
const {
|
||||||
isPhishingDomain,
|
isPhishingDomain,
|
||||||
updatePhishingList,
|
updatePhishingList,
|
||||||
@@ -723,28 +725,39 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Fail the
|
|
||||||
// request the same way this handler used to when it did the signing.
|
|
||||||
if (msg.error) {
|
|
||||||
approval.resolve({ error: { message: msg.error } });
|
|
||||||
sendResponse({ error: msg.error });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await loadState();
|
await loadState();
|
||||||
const activeAddress = await getActiveAddress();
|
const activeAddress = await getActiveAddress();
|
||||||
// The popup holds the secret, but the background stays the
|
let wallet, addrIndex;
|
||||||
// authority on what is broadcast: the raw transaction must be
|
for (const w of state.wallets) {
|
||||||
// the approved one, signed by the approved address.
|
for (let i = 0; i < w.addresses.length; i++) {
|
||||||
verifySignedTx(
|
if (w.addresses[i].address === activeAddress) {
|
||||||
msg.rawSignedTx,
|
wallet = w;
|
||||||
approval.txParams,
|
addrIndex = i;
|
||||||
activeAddress,
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wallet) break;
|
||||||
|
}
|
||||||
|
if (!wallet) throw new Error("Wallet not found");
|
||||||
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
|
let decrypted = await decryptWithPassword(
|
||||||
|
wallet.encryptedSecret,
|
||||||
|
msg.password,
|
||||||
);
|
);
|
||||||
|
const signer = getSignerForAddress(
|
||||||
|
wallet,
|
||||||
|
addrIndex,
|
||||||
|
decrypted,
|
||||||
|
);
|
||||||
|
// Best-effort: clear decrypted secret after use.
|
||||||
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
|
// the original string may persist in memory until GC.
|
||||||
|
decrypted = null;
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
const connected = signer.connect(provider);
|
||||||
|
const tx = await connected.sendTransaction(approval.txParams);
|
||||||
approval.resolve({ txHash: tx.hash });
|
approval.resolve({ txHash: tx.hash });
|
||||||
sendResponse({ txHash: tx.hash });
|
sendResponse({ txHash: tx.hash });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -771,23 +784,55 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Fail the
|
|
||||||
// request the same way this handler used to when it did the signing.
|
|
||||||
if (msg.error) {
|
|
||||||
approval.resolve({ error: { message: msg.error } });
|
|
||||||
sendResponse({ error: msg.error });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
await loadState();
|
||||||
const activeAddress = await getActiveAddress();
|
const activeAddress = await getActiveAddress();
|
||||||
// The popup holds the secret, but the background stays the
|
let wallet, addrIndex;
|
||||||
// authority on what is handed back to the page: the signature
|
for (const w of state.wallets) {
|
||||||
// must cover the approved payload and recover to the approved
|
for (let i = 0; i < w.addresses.length; i++) {
|
||||||
// address.
|
if (w.addresses[i].address === activeAddress) {
|
||||||
const signature = msg.signature;
|
wallet = w;
|
||||||
verifySignature(approval.signParams, signature, activeAddress);
|
addrIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wallet) break;
|
||||||
|
}
|
||||||
|
if (!wallet) throw new Error("Wallet not found");
|
||||||
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
|
let decrypted = await decryptWithPassword(
|
||||||
|
wallet.encryptedSecret,
|
||||||
|
msg.password,
|
||||||
|
);
|
||||||
|
const signer = getSignerForAddress(
|
||||||
|
wallet,
|
||||||
|
addrIndex,
|
||||||
|
decrypted,
|
||||||
|
);
|
||||||
|
// Best-effort: clear decrypted secret after use.
|
||||||
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
|
// the original string may persist in memory until GC.
|
||||||
|
decrypted = null;
|
||||||
|
|
||||||
|
const sp = approval.signParams;
|
||||||
|
let signature;
|
||||||
|
|
||||||
|
if (sp.method === "personal_sign" || sp.method === "eth_sign") {
|
||||||
|
signature = await signer.signMessage(getBytes(sp.message));
|
||||||
|
} else {
|
||||||
|
// eth_signTypedData_v4 / eth_signTypedData
|
||||||
|
const typedData = JSON.parse(sp.typedData);
|
||||||
|
const { domain, types, message } = typedData;
|
||||||
|
// ethers handles EIP712Domain internally
|
||||||
|
delete types.EIP712Domain;
|
||||||
|
signature = await signer.signTypedData(
|
||||||
|
domain,
|
||||||
|
types,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
approval.resolve({ signature });
|
approval.resolve({ signature });
|
||||||
sendResponse({ signature });
|
sendResponse({ signature });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -9,19 +9,10 @@ const {
|
|||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const {
|
const { formatEther, formatUnits, Interface, toUtf8String } = require("ethers");
|
||||||
formatEther,
|
|
||||||
formatUnits,
|
|
||||||
getBytes,
|
|
||||||
Interface,
|
|
||||||
toUtf8String,
|
|
||||||
} = require("ethers");
|
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
|
||||||
const { getProvider } = require("../../shared/balances");
|
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
const uniswap = require("../../shared/uniswap");
|
const uniswap = require("../../shared/uniswap");
|
||||||
const runtime =
|
const runtime =
|
||||||
@@ -162,8 +153,6 @@ function showTxApproval(details) {
|
|||||||
details.isPhishingDomain,
|
details.isPhishingDomain,
|
||||||
);
|
);
|
||||||
|
|
||||||
pendingTxParams = details.txParams;
|
|
||||||
|
|
||||||
const toAddr = details.txParams.to;
|
const toAddr = details.txParams.to;
|
||||||
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
|
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
|
||||||
const ethValue = formatEther(details.txParams.value || "0");
|
const ethValue = formatEther(details.txParams.value || "0");
|
||||||
@@ -337,7 +326,6 @@ function showSignApproval(details) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sp = details.signParams;
|
const sp = details.signParams;
|
||||||
pendingSignParams = sp;
|
|
||||||
|
|
||||||
$("approve-sign-hostname").textContent = details.hostname;
|
$("approve-sign-hostname").textContent = details.hostname;
|
||||||
$("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
|
$("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
|
||||||
@@ -413,36 +401,6 @@ function show(id) {
|
|||||||
|
|
||||||
let approvalId = null;
|
let approvalId = null;
|
||||||
let pendingTxDetails = null;
|
let pendingTxDetails = null;
|
||||||
// The exact parameters shown to the user, kept so the popup signs what it
|
|
||||||
// displayed rather than re-fetching anything at approval time. Both are
|
|
||||||
// repopulated by show() when the popup is closed and reopened.
|
|
||||||
let pendingTxParams = null;
|
|
||||||
let pendingSignParams = null;
|
|
||||||
|
|
||||||
// Approve buttons stay disabled and muted while the popup derives the key and
|
|
||||||
// signs, which is slow enough (Argon2id) that a double click is likely.
|
|
||||||
function setTxButtonBusy(busy) {
|
|
||||||
$("btn-approve-tx").disabled = busy;
|
|
||||||
$("btn-approve-tx").classList.toggle("text-muted", busy);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSignButtonBusy(busy) {
|
|
||||||
$("btn-approve-sign").disabled = busy;
|
|
||||||
$("btn-approve-sign").classList.toggle("text-muted", busy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate the wallet and the address index owning the currently active
|
|
||||||
// address. Returns null when no wallet holds it.
|
|
||||||
function findActiveWallet() {
|
|
||||||
for (const wallet of state.wallets) {
|
|
||||||
for (let i = 0; i < wallet.addresses.length; i++) {
|
|
||||||
if (wallet.addresses[i].address === state.activeAddress) {
|
|
||||||
return { wallet, addrIndex: i };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
$("approve-remember").addEventListener("change", async () => {
|
$("approve-remember").addEventListener("change", async () => {
|
||||||
@@ -472,86 +430,34 @@ function init(ctx) {
|
|||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-approve-tx").addEventListener("click", async () => {
|
$("btn-approve-tx").addEventListener("click", () => {
|
||||||
let password = $("approve-tx-password").value;
|
const password = $("approve-tx-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError("approve-tx-error", "Please enter your password.");
|
showError("approve-tx-error", "Please enter your password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hideError("approve-tx-error");
|
hideError("approve-tx-error");
|
||||||
setTxButtonBusy(true);
|
$("btn-approve-tx").disabled = true;
|
||||||
|
$("btn-approve-tx").classList.add("text-muted");
|
||||||
|
|
||||||
const active = findActiveWallet();
|
runtime.sendMessage(
|
||||||
if (!active) {
|
{
|
||||||
password = null;
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
showError(
|
id: approvalId,
|
||||||
"approve-tx-error",
|
approved: true,
|
||||||
"No wallet was found for the active address.",
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
);
|
password: password,
|
||||||
setTxButtonBusy(false);
|
},
|
||||||
return;
|
(response) => {
|
||||||
}
|
if (response && response.txHash) {
|
||||||
|
txStatus.showWait(pendingTxDetails, response.txHash);
|
||||||
// Decrypt here, in the popup. The password must never cross the
|
} else {
|
||||||
// extension messaging boundary; only the signed transaction does.
|
const msg =
|
||||||
let decryptedSecret;
|
(response && response.error) || "Transaction failed.";
|
||||||
try {
|
txStatus.showError(pendingTxDetails, null, msg);
|
||||||
decryptedSecret = await decryptWithPassword(
|
}
|
||||||
active.wallet.encryptedSecret,
|
},
|
||||||
password,
|
);
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
showError(
|
|
||||||
"approve-tx-error",
|
|
||||||
"That password is incorrect. Please try again.",
|
|
||||||
);
|
|
||||||
setTxButtonBusy(false);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
// Best-effort: drop the password as soon as the key derivation
|
|
||||||
// is done. Note that JS strings are immutable; this clears the
|
|
||||||
// reference but the original string may persist until GC.
|
|
||||||
password = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: approvalId,
|
|
||||||
approved: true,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const signer = getSignerForAddress(
|
|
||||||
active.wallet,
|
|
||||||
active.addrIndex,
|
|
||||||
decryptedSecret,
|
|
||||||
);
|
|
||||||
const provider = getProvider(state.rpcUrl);
|
|
||||||
const connected = signer.connect(provider);
|
|
||||||
// This is the sequence ethers' own sendTransaction() runs
|
|
||||||
// internally, so nonce, gas, fee and chain id population are
|
|
||||||
// identical to when the background did the signing.
|
|
||||||
const populated =
|
|
||||||
await connected.populateTransaction(pendingTxParams);
|
|
||||||
delete populated.from;
|
|
||||||
payload.rawSignedTx = await connected.signTransaction(populated);
|
|
||||||
} catch (e) {
|
|
||||||
payload.error =
|
|
||||||
e.shortMessage || e.message || "Transaction signing failed.";
|
|
||||||
} finally {
|
|
||||||
// Best-effort: clear the decrypted secret after use, with the
|
|
||||||
// same immutability caveat as the password above.
|
|
||||||
decryptedSecret = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.sendMessage(payload, (response) => {
|
|
||||||
if (response && response.txHash) {
|
|
||||||
txStatus.showWait(pendingTxDetails, response.txHash);
|
|
||||||
} else {
|
|
||||||
const msg =
|
|
||||||
(response && response.error) || "Transaction failed.";
|
|
||||||
txStatus.showError(pendingTxDetails, null, msg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-reject-tx").addEventListener("click", () => {
|
$("btn-reject-tx").addEventListener("click", () => {
|
||||||
@@ -563,93 +469,36 @@ function init(ctx) {
|
|||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-approve-sign").addEventListener("click", async () => {
|
$("btn-approve-sign").addEventListener("click", () => {
|
||||||
let password = $("approve-sign-password").value;
|
const password = $("approve-sign-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError("approve-sign-error", "Please enter your password.");
|
showError("approve-sign-error", "Please enter your password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hideError("approve-sign-error");
|
hideError("approve-sign-error");
|
||||||
setSignButtonBusy(true);
|
$("btn-approve-sign").disabled = true;
|
||||||
|
$("btn-approve-sign").classList.add("text-muted");
|
||||||
|
|
||||||
const active = findActiveWallet();
|
runtime.sendMessage(
|
||||||
if (!active) {
|
{
|
||||||
password = null;
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
showError(
|
id: approvalId,
|
||||||
"approve-sign-error",
|
approved: true,
|
||||||
"No wallet was found for the active address.",
|
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
|
||||||
);
|
password: password,
|
||||||
setSignButtonBusy(false);
|
},
|
||||||
return;
|
(response) => {
|
||||||
}
|
if (response && response.signature) {
|
||||||
|
window.close();
|
||||||
// Decrypt here, in the popup. The password must never cross the
|
} else {
|
||||||
// extension messaging boundary; only the signature does.
|
const msg =
|
||||||
let decryptedSecret;
|
(response && response.error) || "Signing failed.";
|
||||||
try {
|
showError("approve-sign-error", msg);
|
||||||
decryptedSecret = await decryptWithPassword(
|
$("btn-approve-sign").disabled = false;
|
||||||
active.wallet.encryptedSecret,
|
$("btn-approve-sign").classList.remove("text-muted");
|
||||||
password,
|
}
|
||||||
);
|
},
|
||||||
} catch {
|
);
|
||||||
showError(
|
|
||||||
"approve-sign-error",
|
|
||||||
"That password is incorrect. Please try again.",
|
|
||||||
);
|
|
||||||
setSignButtonBusy(false);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
// Best-effort: drop the password as soon as the key derivation
|
|
||||||
// is done. Note that JS strings are immutable; this clears the
|
|
||||||
// reference but the original string may persist until GC.
|
|
||||||
password = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
||||||
id: approvalId,
|
|
||||||
approved: true,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const signer = getSignerForAddress(
|
|
||||||
active.wallet,
|
|
||||||
active.addrIndex,
|
|
||||||
decryptedSecret,
|
|
||||||
);
|
|
||||||
const sp = pendingSignParams;
|
|
||||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") {
|
|
||||||
payload.signature = await signer.signMessage(
|
|
||||||
getBytes(sp.message),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// eth_signTypedData_v4 / eth_signTypedData
|
|
||||||
const typedData = JSON.parse(sp.typedData);
|
|
||||||
const { domain, types, message } = typedData;
|
|
||||||
// ethers handles EIP712Domain internally
|
|
||||||
delete types.EIP712Domain;
|
|
||||||
payload.signature = await signer.signTypedData(
|
|
||||||
domain,
|
|
||||||
types,
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
payload.error = e.shortMessage || e.message || "Signing failed.";
|
|
||||||
} finally {
|
|
||||||
// Best-effort: clear the decrypted secret after use, with the
|
|
||||||
// same immutability caveat as the password above.
|
|
||||||
decryptedSecret = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.sendMessage(payload, (response) => {
|
|
||||||
if (response && response.signature) {
|
|
||||||
window.close();
|
|
||||||
} else {
|
|
||||||
const msg = (response && response.error) || "Signing failed.";
|
|
||||||
showError("approve-sign-error", msg);
|
|
||||||
setSignButtonBusy(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-reject-sign").addEventListener("click", () => {
|
$("btn-reject-sign").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
// Verification of the signed artifacts produced by the approval popup.
|
|
||||||
//
|
|
||||||
// Signing happens in the popup, where the password is entered; the background
|
|
||||||
// only broadcasts the raw transaction and resolves the pending approval back
|
|
||||||
// to the requesting page. So that moving the signing out of the background
|
|
||||||
// does not turn the background into a blind relay, the background re-derives
|
|
||||||
// the signer from the artifact and checks it against the approval it is
|
|
||||||
// holding before acting on it. All recovery is delegated to ethers.
|
|
||||||
//
|
|
||||||
// Every failure message is a full sentence, because these strings are shown to
|
|
||||||
// the user and returned to the dApp.
|
|
||||||
|
|
||||||
const {
|
|
||||||
Transaction,
|
|
||||||
getAddress,
|
|
||||||
getBytes,
|
|
||||||
verifyMessage,
|
|
||||||
verifyTypedData,
|
|
||||||
} = require("ethers");
|
|
||||||
|
|
||||||
// Case-insensitive address comparison that tolerates absent values on either
|
|
||||||
// side. Two absent addresses compare equal (contract creation has no `to`).
|
|
||||||
function sameAddress(a, b) {
|
|
||||||
const aMissing = a === null || a === undefined || a === "";
|
|
||||||
const bMissing = b === null || b === undefined || b === "";
|
|
||||||
if (aMissing || bMissing) return aMissing && bMissing;
|
|
||||||
try {
|
|
||||||
return getAddress(a) === getAddress(b);
|
|
||||||
} catch {
|
|
||||||
return String(a).toLowerCase() === String(b).toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize a transaction value (hex string, decimal string, number or
|
|
||||||
// bigint) to a bigint. An absent value is zero, matching ethers.
|
|
||||||
function normalizeValue(v) {
|
|
||||||
if (v === null || v === undefined || v === "") return 0n;
|
|
||||||
return BigInt(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize call data to a lowercase hex string. Absent data is "0x".
|
|
||||||
function normalizeData(v) {
|
|
||||||
if (v === null || v === undefined || v === "" || v === "0x") return "0x";
|
|
||||||
return String(v).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert that a raw signed transaction is the transaction the user approved,
|
|
||||||
// signed by the address the approval was raised for. Returns the parsed
|
|
||||||
// ethers Transaction on success, throws otherwise.
|
|
||||||
function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
|
|
||||||
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
|
|
||||||
throw new Error("The signed transaction is missing or malformed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = Transaction.from(rawSignedTx);
|
|
||||||
} catch {
|
|
||||||
throw new Error("The signed transaction could not be decoded.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed.from) {
|
|
||||||
throw new Error("The signed transaction carries no valid signature.");
|
|
||||||
}
|
|
||||||
if (!sameAddress(parsed.from, expectedFrom)) {
|
|
||||||
throw new Error(
|
|
||||||
"The signed transaction was signed by a different address than the one that was approved.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!sameAddress(parsed.to, txParams.to)) {
|
|
||||||
throw new Error(
|
|
||||||
"The signed transaction does not go to the approved recipient.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
|
|
||||||
throw new Error(
|
|
||||||
"The signed transaction does not carry the approved value.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
|
|
||||||
throw new Error(
|
|
||||||
"The signed transaction does not carry the approved call data.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert that a signature over the approved message or typed data was
|
|
||||||
// produced by the address the approval was raised for. Returns the recovered
|
|
||||||
// address on success, throws otherwise.
|
|
||||||
function verifySignature(signParams, signature, expectedFrom) {
|
|
||||||
if (typeof signature !== "string" || !signature.startsWith("0x")) {
|
|
||||||
throw new Error("The signature is missing or malformed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let recovered;
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
signParams.method === "personal_sign" ||
|
|
||||||
signParams.method === "eth_sign"
|
|
||||||
) {
|
|
||||||
recovered = verifyMessage(getBytes(signParams.message), signature);
|
|
||||||
} else {
|
|
||||||
const typedData = JSON.parse(signParams.typedData);
|
|
||||||
const { domain, types, message } = typedData;
|
|
||||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
|
||||||
delete types.EIP712Domain;
|
|
||||||
recovered = verifyTypedData(domain, types, message, signature);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new Error("The signature could not be verified.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sameAddress(recovered, expectedFrom)) {
|
|
||||||
throw new Error(
|
|
||||||
"The signature was produced by a different address than the one that was approved.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return recovered;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { verifySignedTx, verifySignature, sameAddress };
|
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
const DEBUG = true;
|
// DEBUG is a build-time constant injected by esbuild's define in build.js
|
||||||
|
// (see src/shared/buildInfo.js for the same pattern). It is false unless the
|
||||||
|
// bundle was produced with AUTISTMASK_DEBUG=1, and it is false whenever the
|
||||||
|
// module is loaded outside a bundle (tests, plain require). It must never be
|
||||||
|
// derived from anything the user can change at runtime: it is what gates the
|
||||||
|
// hardcoded test mnemonic below.
|
||||||
|
/* global __BUILD_DEBUG__ */
|
||||||
|
const DEBUG = typeof __BUILD_DEBUG__ !== "undefined" ? __BUILD_DEBUG__ : false;
|
||||||
|
|
||||||
const DEBUG_MNEMONIC =
|
const DEBUG_MNEMONIC =
|
||||||
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
|
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
|
|||||||
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
|
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
|
||||||
|
|
||||||
function generateMnemonic() {
|
function generateMnemonic() {
|
||||||
|
// This must stay the compile-time DEBUG constant. Do NOT switch it to
|
||||||
|
// isDebug() from log.js: that also ORs in the runtime debugMode flag the
|
||||||
|
// settings toggle drives, which would let a user of a release build turn
|
||||||
|
// the hardcoded, publicly known test phrase back on for real wallets.
|
||||||
if (DEBUG) return DEBUG_MNEMONIC;
|
if (DEBUG) return DEBUG_MNEMONIC;
|
||||||
const m = Mnemonic.fromEntropy(
|
const m = Mnemonic.fromEntropy(
|
||||||
globalThis.crypto.getRandomValues(new Uint8Array(16)),
|
globalThis.crypto.getRandomValues(new Uint8Array(16)),
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
const { Network, Transaction, Wallet } = require("ethers");
|
|
||||||
const {
|
|
||||||
verifySignedTx,
|
|
||||||
verifySignature,
|
|
||||||
sameAddress,
|
|
||||||
} = require("../src/shared/approvalVerify");
|
|
||||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
|
||||||
|
|
||||||
// Fixed test keys — never used for anything but these tests.
|
|
||||||
const SIGNER_KEY =
|
|
||||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
||||||
const OTHER_KEY =
|
|
||||||
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
|
|
||||||
|
|
||||||
const signer = new Wallet(SIGNER_KEY);
|
|
||||||
const other = new Wallet(OTHER_KEY);
|
|
||||||
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
||||||
|
|
||||||
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
|
||||||
const TX_PARAMS = {
|
|
||||||
from: signer.address,
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0x2386f26fc10000",
|
|
||||||
data: "0xdeadbeef",
|
|
||||||
gas: "0x5208",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build a signable transaction from approved params. The popup does the same
|
|
||||||
// thing through populateTransaction(); here the fields are fixed so the test
|
|
||||||
// needs no provider.
|
|
||||||
function txFor(params) {
|
|
||||||
return {
|
|
||||||
chainId: 1,
|
|
||||||
nonce: 7,
|
|
||||||
gasLimit: 100000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
type: 2,
|
|
||||||
to: params.to,
|
|
||||||
value: params.value === undefined ? 0n : BigInt(params.value),
|
|
||||||
data: params.data || "0x",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signedFor(params, withWallet) {
|
|
||||||
return (withWallet || signer).signTransaction(txFor(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("sameAddress", () => {
|
|
||||||
test("compares checksummed and lowercase forms as equal", () => {
|
|
||||||
expect(sameAddress(RECIPIENT, RECIPIENT.toLowerCase())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats two absent addresses as equal (contract creation)", () => {
|
|
||||||
expect(sameAddress(null, undefined)).toBe(true);
|
|
||||||
expect(sameAddress("", null)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats one absent address as unequal", () => {
|
|
||||||
expect(sameAddress(RECIPIENT, null)).toBe(false);
|
|
||||||
expect(sameAddress(null, RECIPIENT)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not throw on values that are not addresses", () => {
|
|
||||||
expect(sameAddress("not-an-address", RECIPIENT)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("verifySignedTx", () => {
|
|
||||||
test("accepts the approved transaction signed by the approved address", async () => {
|
|
||||||
const raw = await signedFor(TX_PARAMS);
|
|
||||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
|
|
||||||
expect(parsed.from).toBe(signer.address);
|
|
||||||
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts a contract creation with no recipient", async () => {
|
|
||||||
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
|
||||||
const raw = await signedFor(params);
|
|
||||||
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts an absent value as zero", async () => {
|
|
||||||
const approved = { to: RECIPIENT, data: "0x" };
|
|
||||||
const raw = await signedFor(approved);
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(raw, approved, signer.address),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts call data whose case differs from the approval", async () => {
|
|
||||||
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
|
||||||
const raw = await signedFor(approved);
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(raw, approved, signer.address),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a swapped recipient", async () => {
|
|
||||||
const raw = await signedFor({
|
|
||||||
...TX_PARAMS,
|
|
||||||
to: OTHER_RECIPIENT,
|
|
||||||
});
|
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
|
||||||
/approved recipient/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects an inflated value", async () => {
|
|
||||||
const raw = await signedFor({
|
|
||||||
...TX_PARAMS,
|
|
||||||
value: "0x4563918244f40000",
|
|
||||||
});
|
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
|
||||||
/approved value/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects substituted call data", async () => {
|
|
||||||
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
|
||||||
/approved call data/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a transaction signed by a different address", async () => {
|
|
||||||
const raw = await signedFor(TX_PARAMS, other);
|
|
||||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
|
||||||
/different address/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects an unsigned transaction", () => {
|
|
||||||
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(unsigned, TX_PARAMS, signer.address),
|
|
||||||
).toThrow(/no valid signature/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a missing or malformed payload", () => {
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(undefined, TX_PARAMS, signer.address),
|
|
||||||
).toThrow(/missing or malformed/);
|
|
||||||
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
|
|
||||||
/missing or malformed/,
|
|
||||||
);
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
|
|
||||||
).toThrow(/could not be decoded/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("every rejection message is a full sentence", async () => {
|
|
||||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
|
||||||
try {
|
|
||||||
verifySignedTx(raw, TX_PARAMS, signer.address);
|
|
||||||
throw new Error("expected a rejection");
|
|
||||||
} catch (e) {
|
|
||||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const TYPED_DATA = JSON.stringify({
|
|
||||||
domain: {
|
|
||||||
name: "AutistMask Test",
|
|
||||||
version: "1",
|
|
||||||
chainId: 1,
|
|
||||||
verifyingContract: OTHER_RECIPIENT,
|
|
||||||
},
|
|
||||||
primaryType: "Mail",
|
|
||||||
types: {
|
|
||||||
EIP712Domain: [
|
|
||||||
{ name: "name", type: "string" },
|
|
||||||
{ name: "version", type: "string" },
|
|
||||||
{ name: "chainId", type: "uint256" },
|
|
||||||
{ name: "verifyingContract", type: "address" },
|
|
||||||
],
|
|
||||||
Mail: [
|
|
||||||
{ name: "from", type: "address" },
|
|
||||||
{ name: "to", type: "address" },
|
|
||||||
{ name: "contents", type: "string" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
from: signer.address,
|
|
||||||
to: RECIPIENT,
|
|
||||||
contents: "hello",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("verifySignature", () => {
|
|
||||||
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
|
||||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
|
||||||
const personalParams = {
|
|
||||||
method: "personal_sign",
|
|
||||||
message: MESSAGE,
|
|
||||||
from: signer.address,
|
|
||||||
};
|
|
||||||
const typedParams = {
|
|
||||||
method: "eth_signTypedData_v4",
|
|
||||||
typedData: TYPED_DATA,
|
|
||||||
from: signer.address,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function signPersonal(withWallet) {
|
|
||||||
return (withWallet || signer).signMessage(
|
|
||||||
Buffer.from(MESSAGE.slice(2), "hex"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signTyped(withWallet) {
|
|
||||||
const { domain, types, message } = JSON.parse(TYPED_DATA);
|
|
||||||
delete types.EIP712Domain;
|
|
||||||
return (withWallet || signer).signTypedData(domain, types, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("accepts a personal_sign signature from the approved address", async () => {
|
|
||||||
const signature = await signPersonal();
|
|
||||||
expect(verifySignature(personalParams, signature, signer.address)).toBe(
|
|
||||||
signer.address,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts an eth_sign signature the same way", async () => {
|
|
||||||
const signature = await signPersonal();
|
|
||||||
const params = { ...personalParams, method: "eth_sign" };
|
|
||||||
expect(() =>
|
|
||||||
verifySignature(params, signature, signer.address),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts a typed data signature from the approved address", async () => {
|
|
||||||
const signature = await signTyped();
|
|
||||||
expect(verifySignature(typedParams, signature, signer.address)).toBe(
|
|
||||||
signer.address,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not mutate the approved typed data while verifying", async () => {
|
|
||||||
const signature = await signTyped();
|
|
||||||
const before = typedParams.typedData;
|
|
||||||
verifySignature(typedParams, signature, signer.address);
|
|
||||||
expect(typedParams.typedData).toBe(before);
|
|
||||||
expect(
|
|
||||||
JSON.parse(typedParams.typedData).types.EIP712Domain,
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a personal_sign signature from a different address", async () => {
|
|
||||||
const signature = await signPersonal(other);
|
|
||||||
expect(() =>
|
|
||||||
verifySignature(personalParams, signature, signer.address),
|
|
||||||
).toThrow(/different address/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a typed data signature from a different address", async () => {
|
|
||||||
const signature = await signTyped(other);
|
|
||||||
expect(() =>
|
|
||||||
verifySignature(typedParams, signature, signer.address),
|
|
||||||
).toThrow(/different address/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a signature over a different message", async () => {
|
|
||||||
const signature = await signer.signMessage(
|
|
||||||
Buffer.from("00112233", "hex"),
|
|
||||||
);
|
|
||||||
expect(() =>
|
|
||||||
verifySignature(personalParams, signature, signer.address),
|
|
||||||
).toThrow(/different address/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a missing or malformed signature", async () => {
|
|
||||||
expect(() =>
|
|
||||||
verifySignature(personalParams, undefined, signer.address),
|
|
||||||
).toThrow(/missing or malformed/);
|
|
||||||
expect(() =>
|
|
||||||
verifySignature(personalParams, "0x1234", signer.address),
|
|
||||||
).toThrow(/could not be verified/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// End-to-end over the messaging boundary, without a browser: run the exact
|
|
||||||
// sequence the approval popup runs, then hand the artifact to the exact check
|
|
||||||
// the background runs before it broadcasts or resolves. Only what the popup
|
|
||||||
// puts on the wire is passed along, so this also pins down that the wire
|
|
||||||
// payload is sufficient on its own.
|
|
||||||
describe("popup signing sequence to background verification", () => {
|
|
||||||
// Stand-in for the JSON-RPC provider. populateTransaction only needs the
|
|
||||||
// nonce, the gas estimate, the network and the fee data.
|
|
||||||
const fakeProvider = {
|
|
||||||
getNetwork: async () => Network.from(1),
|
|
||||||
getTransactionCount: async () => 7,
|
|
||||||
estimateGas: async () => 21000n,
|
|
||||||
getFeeData: async () => ({
|
|
||||||
gasPrice: 2000000000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// A private-key wallet as it is persisted in state, so the test goes
|
|
||||||
// through getSignerForAddress() the way the popup does.
|
|
||||||
const walletData = { type: "privkey" };
|
|
||||||
|
|
||||||
async function popupSignsTx(txParams) {
|
|
||||||
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
|
||||||
const connected = localSigner.connect(fakeProvider);
|
|
||||||
const populated = await connected.populateTransaction(txParams);
|
|
||||||
delete populated.from;
|
|
||||||
return connected.signTransaction(populated);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
|
||||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
|
||||||
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
|
|
||||||
expect(parsed.nonce).toBe(7);
|
|
||||||
expect(parsed.chainId).toBe(1n);
|
|
||||||
expect(parsed.gasLimit).toBe(21000n);
|
|
||||||
expect(parsed.to).toBe(RECIPIENT);
|
|
||||||
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
|
||||||
expect(parsed.data).toBe(TX_PARAMS.data);
|
|
||||||
expect(parsed.signature).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the wire payload carries no password and no secret", async () => {
|
|
||||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
|
||||||
const payload = {
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id: "test-approval-id",
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx,
|
|
||||||
};
|
|
||||||
expect(Object.keys(payload).sort()).toEqual([
|
|
||||||
"approved",
|
|
||||||
"id",
|
|
||||||
"rawSignedTx",
|
|
||||||
"type",
|
|
||||||
]);
|
|
||||||
const wire = JSON.stringify(payload).toLowerCase();
|
|
||||||
expect(wire).not.toContain("password");
|
|
||||||
expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the background rejects a transaction the popup did not approve", async () => {
|
|
||||||
const rawSignedTx = await popupSignsTx({
|
|
||||||
...TX_PARAMS,
|
|
||||||
to: OTHER_RECIPIENT,
|
|
||||||
});
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
|
|
||||||
).toThrow(/approved recipient/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
94
tests/wallet.test.js
Normal file
94
tests/wallet.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Tests for the DEBUG build flag as it gates mnemonic generation.
|
||||||
|
//
|
||||||
|
// The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle
|
||||||
|
// time. Under jest the global is absent, which is exactly the release-build
|
||||||
|
// case; the debug-build case is exercised by defining the global and
|
||||||
|
// re-requiring the modules with a fresh registry.
|
||||||
|
|
||||||
|
const WORDS_IN_12_WORD_PHRASE = 12;
|
||||||
|
|
||||||
|
function loadWallet() {
|
||||||
|
const constants = require("../src/shared/constants");
|
||||||
|
const wallet = require("../src/shared/wallet");
|
||||||
|
const log = require("../src/shared/log");
|
||||||
|
return { constants, wallet, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateMnemonic in a release build", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
delete globalThis.__BUILD_DEBUG__;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DEBUG defaults to false when the build define is absent", () => {
|
||||||
|
const { constants } = loadWallet();
|
||||||
|
expect(constants.DEBUG).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns fresh, valid 12-word phrases that are not the test phrase", () => {
|
||||||
|
const { constants, wallet } = loadWallet();
|
||||||
|
|
||||||
|
const first = wallet.generateMnemonic();
|
||||||
|
const second = wallet.generateMnemonic();
|
||||||
|
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
for (const phrase of [first, second]) {
|
||||||
|
expect(wallet.isValidMnemonic(phrase)).toBe(true);
|
||||||
|
expect(phrase.split(" ")).toHaveLength(WORDS_IN_12_WORD_PHRASE);
|
||||||
|
expect(phrase).not.toBe(constants.DEBUG_MNEMONIC);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives a usable HD wallet from the generated phrase", () => {
|
||||||
|
const { wallet } = loadWallet();
|
||||||
|
|
||||||
|
const { xpub, firstAddress } = wallet.hdWalletFromMnemonic(
|
||||||
|
wallet.generateMnemonic(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(xpub.startsWith("xpub")).toBe(true);
|
||||||
|
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the runtime debug toggle cannot re-enable the test phrase", () => {
|
||||||
|
const { constants, wallet, log } = loadWallet();
|
||||||
|
|
||||||
|
// What the settings easter-egg toggle does at runtime.
|
||||||
|
log.setRuntimeDebug(true);
|
||||||
|
expect(log.isDebug()).toBe(true);
|
||||||
|
|
||||||
|
const phrase = wallet.generateMnemonic();
|
||||||
|
expect(phrase).not.toBe(constants.DEBUG_MNEMONIC);
|
||||||
|
expect(wallet.isValidMnemonic(phrase)).toBe(true);
|
||||||
|
expect(phrase).not.toBe(wallet.generateMnemonic());
|
||||||
|
|
||||||
|
log.setRuntimeDebug(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateMnemonic in a debug build", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
globalThis.__BUILD_DEBUG__ = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.__BUILD_DEBUG__;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DEBUG is true and the test phrase is returned", () => {
|
||||||
|
const { constants, wallet } = loadWallet();
|
||||||
|
|
||||||
|
expect(constants.DEBUG).toBe(true);
|
||||||
|
expect(wallet.generateMnemonic()).toBe(constants.DEBUG_MNEMONIC);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the test phrase is itself a valid 12-word BIP-39 phrase", () => {
|
||||||
|
const { constants, wallet } = loadWallet();
|
||||||
|
|
||||||
|
expect(wallet.isValidMnemonic(constants.DEBUG_MNEMONIC)).toBe(true);
|
||||||
|
expect(constants.DEBUG_MNEMONIC.split(" ")).toHaveLength(
|
||||||
|
WORDS_IN_12_WORD_PHRASE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user