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({