From 1b806fb9e978d7b8adb8ae34df8e2eb11283b7b0 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 25 Feb 2026 16:13:22 +0700 Subject: [PATCH] Store xpubs unencrypted, remove password from viewing flow Xpubs and derived addresses stored unencrypted in extension storage for instant read-only access without a password. Password will only be required for signing transactions (not yet implemented). Real addresses now derived from mnemonic via ethers HDNodeWallet at wallet creation time. Removed lock screen, password fields, and Lock button. BIP-39 mnemonic validation added. README updated with split storage model documentation. --- README.md | 46 +++++------ src/popup/index.html | 94 ++--------------------- src/popup/index.js | 177 ++++++++++++++++--------------------------- 3 files changed, 96 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index 0fce9ab..0b92f75 100644 --- a/README.md +++ b/README.md @@ -151,33 +151,29 @@ menus. The popup has the following views, switched via simple show/hide: -1. **Lock**: Password input + Unlock button. Shown when the wallet is locked or - on first open after browser restart. -2. **Welcome**: Shown on first use. Two options: "Add wallet" (recovery phrase - based) and "Import private key". Password is set during the first wallet - addition. -3. **Add wallet**: A unified view for both creating and importing recovery +1. **Welcome**: Shown on first use. Single "Add wallet" button. +2. **Add wallet**: A unified view for both creating and importing recovery phrase wallets. The recovery phrase text area starts empty. A clickable die button `[die]` generates a random 12-word phrase and fills it in. If the user already has a phrase, they paste it directly. When the die is clicked, a - warning box appears reminding the user to write the phrase down. Password - fields are shown only on first use. -4. **Import private key**: Paste a private key. This creates a wallet with a - single address. Password fields shown only on first use. -5. **Main**: All wallets listed, each showing its addresses with truncated - address and ETH balance. "+" next to recovery phrase wallets to add another - address. "+ Add wallet" and "+ Import private key" buttons at the bottom. - Settings and Lock buttons in the header. Future: a sub-heading showing total - portfolio value in USD (and eventually other currencies). -6. **Address detail**: Full address (click to copy), ETH balance, USD value + warning box appears reminding the user to write the phrase down. No password + required — the xpub is derived and stored for read-only access. +3. **Import private key**: Paste a private key. This creates a wallet with a + single address. +4. **Main**: All wallets listed, each showing its addresses (full, untruncated) + and ETH balance. "+" next to recovery phrase wallets to add another address. + "+ Add wallet" at the bottom. Settings button in the header. Future: a + sub-heading showing total portfolio value in USD (and eventually other + currencies). +5. **Address detail**: Full address (click to copy), ETH balance, USD value (future), Send/Receive buttons, token list with "+ Add" button. -7. **Send**: Token selector, recipient address, amount. Cancel returns to +6. **Send**: Token selector, recipient address, amount. Cancel returns to address detail. -8. **Receive**: Full address displayed with "Copy address" button. -9. **Add token**: Enter contract address. The extension looks up the token +7. **Receive**: Full address displayed with "Copy address" button. +8. **Add token**: Enter contract address. The extension looks up the token name/symbol automatically. -10. **Settings**: Network (RPC endpoint URL) with explanatory text. -11. **Approval**: When a website requests wallet access or a signature, shows +9. **Settings**: Network (RPC endpoint URL) with explanatory text. +10. **Approval**: When a website requests wallet access or a signature, shows the site origin, request details, and Allow/Deny buttons. ### External Services @@ -256,8 +252,12 @@ project owner. - **No framework**: The popup UI is vanilla JS and HTML. The extension is small enough that a framework adds unnecessary complexity and attack surface. -- **Encrypted storage**: Recovery phrases and private keys are encrypted at rest - in the extension's local storage using libsodium. The encryption scheme: +- **Split storage model**: Public data (xpubs, derived addresses, token lists, + balances) is stored unencrypted in extension local storage so the user can + view their wallets and balances at any time without entering a password. + Private data (recovery phrases, private keys) will be encrypted at rest using + libsodium — a password is only required when the user needs to sign a + transaction or message. The encryption scheme for private data: - The user's password is run through Argon2id (`crypto_pwhash`) to derive a 256-bit encryption key. Argon2id is memory-hard, making GPU/ASIC brute force attacks expensive. diff --git a/src/popup/index.html b/src/popup/index.html index 81cff3d..78528cd 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -8,34 +8,6 @@
- - - -
- -

- This password encrypts your recovery phrase on this - device. It does not affect your wallet addresses or - funds — anyone with your recovery phrase can restore - your wallet without this password. -

- -
-
- - -
-
- -

- This password encrypts your private key on this device. - Anyone with your private key can access your funds - without this password. -

- -
-
- - -
- -
+
diff --git a/src/popup/index.js b/src/popup/index.js index 5720d51..e349e35 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -1,12 +1,13 @@ // AutistMask popup UI — view management and event wiring -const { Mnemonic } = require("ethers"); +const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); const DEBUG = true; const DEBUG_MNEMONIC = "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"; +const BIP44_ETH_BASE = "m/44'/60'/0'/0"; + const VIEWS = [ - "lock", "welcome", "add-wallet", "import-key", @@ -29,26 +30,22 @@ function showView(name) { } // Browser-agnostic storage API -const storage = +const storageApi = typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local; -// A wallet is either { type: "hd", name, mnemonic, addresses: [...] } -// or { type: "key", name, privateKey, addresses: [single] }. -// Each address is { address, balance, tokens: [...] }. +// Persisted state (unencrypted, public data only): +// wallets[]: { type, name, xpub (for hd), addresses: [{ address, balance, tokens }], nextIndex } +// Mnemonic/private key will be stored encrypted separately (not yet implemented). const DEFAULT_STATE = { hasWallet: false, wallets: [], rpcUrl: "https://eth.llamarpc.com", - isFirstSetup: true, }; -// Transient state (not persisted) const state = { ...DEFAULT_STATE, - locked: true, - password: null, selectedWallet: null, selectedAddress: null, }; @@ -58,19 +55,17 @@ async function saveState() { hasWallet: state.hasWallet, wallets: state.wallets, rpcUrl: state.rpcUrl, - isFirstSetup: state.isFirstSetup, }; - await storage.set({ autistmask: persisted }); + await storageApi.set({ autistmask: persisted }); } async function loadState() { - const result = await storage.get("autistmask"); + const result = await storageApi.get("autistmask"); if (result.autistmask) { const saved = result.autistmask; state.hasWallet = saved.hasWallet; state.wallets = saved.wallets || []; state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; - state.isFirstSetup = saved.isFirstSetup; } } @@ -89,28 +84,33 @@ function hideError(id) { $(id).classList.add("hidden"); } -function truncateAddress(addr) { - if (!addr) return ""; - return addr.slice(0, 6) + "\u2026" + addr.slice(-4); -} - -function makeStubAddress() { - const hex = Array.from({ length: 40 }, () => - Math.floor(Math.random() * 16).toString(16), - ).join(""); - return { - address: "0x" + hex, - balance: "0.0000", - tokens: [], - }; -} - function generateMnemonic() { if (DEBUG) return DEBUG_MNEMONIC; - const wallet = Mnemonic.fromEntropy( + const m = Mnemonic.fromEntropy( globalThis.crypto.getRandomValues(new Uint8Array(16)), ); - return wallet.phrase; + return m.phrase; +} + +// Derive an Ethereum address at index from an xpub string +function deriveAddressFromXpub(xpub, index) { + const node = HDNodeWallet.fromExtendedKey(xpub); + const child = node.deriveChild(index); + return child.address; +} + +// Create an HD wallet from a mnemonic: returns { xpub, firstAddress } +function hdWalletFromMnemonic(mnemonic) { + const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_BASE); + const xpub = node.neuter().extendedKey; + const firstAddress = node.deriveChild(0).address; + return { xpub, firstAddress }; +} + +// Get address from a private key +function addressFromPrivateKey(key) { + const w = new Wallet(key); + return w.address; } // -- render wallet list on main view -- @@ -135,7 +135,7 @@ function renderWalletList() { wallet.addresses.forEach((addr, ai) => { html += `
`; html += `${addr.address}`; - html += `${addr.balance} ETH`; + html += `${addr.balance} ETH`; html += `
`; }); @@ -155,8 +155,17 @@ function renderWalletList() { btn.addEventListener("click", async (e) => { e.stopPropagation(); const wi = parseInt(btn.dataset.wallet, 10); - // TODO: derive next address from seed via background - state.wallets[wi].addresses.push(makeStubAddress()); + const wallet = state.wallets[wi]; + const newAddr = deriveAddressFromXpub( + wallet.xpub, + wallet.nextIndex, + ); + wallet.addresses.push({ + address: newAddr, + balance: "0.0000", + tokens: [], + }); + wallet.nextIndex++; await saveState(); renderWalletList(); }); @@ -215,7 +224,6 @@ function currentAddress() { async function addWalletAndGoToMain(wallet) { state.wallets.push(wallet); state.hasWallet = true; - state.isFirstSetup = false; await saveState(); renderWalletList(); showView("main"); @@ -225,49 +233,17 @@ function showAddWalletView() { $("wallet-mnemonic").value = ""; $("add-wallet-phrase-warning").classList.add("hidden"); hideError("add-wallet-error"); - const needsPw = state.isFirstSetup; - $("add-wallet-password-section").classList.toggle("hidden", !needsPw); - $("add-wallet-password-confirm-section").classList.toggle( - "hidden", - !needsPw, - ); showView("add-wallet"); } function showImportKeyView() { $("import-private-key").value = ""; hideError("import-key-error"); - const needsPw = state.isFirstSetup; - $("import-key-password-section").classList.toggle("hidden", !needsPw); - $("import-key-password-confirm-section").classList.toggle( - "hidden", - !needsPw, - ); showView("import-key"); } -function validatePasswords(pwId, pw2Id, errorId) { - if (!state.isFirstSetup) return true; - const pw = $(pwId).value; - const pw2 = $(pw2Id).value; - if (!pw) { - showError(errorId, "Please choose a password."); - return false; - } - if (pw.length < 8) { - showError(errorId, "Password must be at least 8 characters."); - return false; - } - if (pw !== pw2) { - showError(errorId, "Passwords do not match."); - return false; - } - state.password = pw; - return true; -} - function backFromWalletAdd() { - if (state.isFirstSetup) { + if (!state.hasWallet) { showView("welcome"); } else { renderWalletList(); @@ -289,27 +265,11 @@ async function init() { if (!state.hasWallet) { showView("welcome"); - } else if (state.locked) { - showView("lock"); } else { renderWalletList(); showView("main"); } - // -- Lock screen -- - $("btn-unlock").addEventListener("click", () => { - const pw = $("unlock-password").value; - if (!pw) { - showError("unlock-error", "Please enter your password."); - return; - } - hideError("unlock-error"); - // TODO: send unlock message to background - state.locked = false; - renderWalletList(); - showView("main"); - }); - // -- Welcome -- $("btn-welcome-add").addEventListener("click", showAddWalletView); @@ -339,22 +299,26 @@ async function init() { ); return; } - if ( - !validatePasswords( - "add-wallet-password", - "add-wallet-password-confirm", + // Validate the mnemonic is real BIP-39 + if (!Mnemonic.isValidMnemonic(mnemonic)) { + showError( "add-wallet-error", - ) - ) { + "Invalid recovery phrase. Please check for typos.", + ); return; } hideError("add-wallet-error"); + + const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const walletNum = state.wallets.length + 1; addWalletAndGoToMain({ type: "hd", name: "Wallet " + walletNum, - mnemonic: mnemonic, - addresses: [makeStubAddress()], + xpub: xpub, + nextIndex: 1, + addresses: [ + { address: firstAddress, balance: "0.0000", tokens: [] }, + ], }); }); @@ -368,13 +332,11 @@ async function init() { showError("import-key-error", "Please enter your private key."); return; } - if ( - !validatePasswords( - "import-key-password", - "import-key-password-confirm", - "import-key-error", - ) - ) { + let addr; + try { + addr = addressFromPrivateKey(key); + } catch (e) { + showError("import-key-error", "Invalid private key."); return; } hideError("import-key-error"); @@ -382,20 +344,13 @@ async function init() { addWalletAndGoToMain({ type: "key", name: "Wallet " + walletNum, - privateKey: key, - addresses: [makeStubAddress()], + addresses: [{ address: addr, balance: "0.0000", tokens: [] }], }); }); $("btn-import-key-back").addEventListener("click", backFromWalletAdd); // -- Main view -- - $("btn-lock").addEventListener("click", () => { - state.locked = true; - $("unlock-password").value = ""; - showView("lock"); - }); - $("btn-settings").addEventListener("click", () => { $("settings-rpc").value = state.rpcUrl; showView("settings"); @@ -455,9 +410,9 @@ async function init() { $("send-status").classList.remove("hidden"); return; } - // TODO: construct and send transaction via background + // TODO: prompt for password, decrypt key, construct and send transaction const el = $("send-status"); - el.textContent = "Sent! (stub)"; + el.textContent = "Sent! (stub — password/signing not yet implemented)"; el.classList.remove("hidden"); }); @@ -488,7 +443,7 @@ async function init() { return; } hideError("add-token-error"); - // TODO: look up token name/symbol/decimals from contract via background + // TODO: look up token name/symbol/decimals from contract via RPC const addr = currentAddress(); if (addr) { addr.tokens.push({