diff --git a/src/components/AddAccountDialog.js b/src/components/AddAccountDialog.js index 7d03712..07aa6d4 100644 --- a/src/components/AddAccountDialog.js +++ b/src/components/AddAccountDialog.js @@ -4,35 +4,83 @@ import Button from '@material-ui/core/Button'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogContent from '@material-ui/core/DialogContent'; import TextField from '@material-ui/core/TextField'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import FormGroup from '@material-ui/core/FormGroup'; +import Switch from '@material-ui/core/Switch'; +import { Account } from '@solana/web3.js'; +import * as bs58 from 'bs58'; import DialogForm from './DialogForm'; export default function AddAccountDialog({ open, onAdd, onClose }) { const [name, setName] = useState(''); + const [isImport, setIsImport] = useState(false); + const [importedPrivateKey, setPrivateKey] = useState(''); + + const importedAccount = isImport ? decodeAccount(importedPrivateKey) : undefined; + const isAddEnabled = isImport ? name && importedAccount !== undefined : name; return ( onAdd(name)} + onSubmit={() => onAdd({ name, importedAccount })} fullWidth > Add account - setName(e.target.value.trim())} - /> +
+ setName(e.target.value.trim())} + /> + + setIsImport(!isImport)} + /> + } + label="Import private key" + /> + + {isImport && ( + setPrivateKey(e.target.value.trim())} + /> + )} +
-
); } + +function decodeAccount(privateKey) { + try { + const a = new Account(bs58.decode(privateKey)); + return a; + } catch (_) { + return undefined; + } +} diff --git a/src/components/NavigationFrame.js b/src/components/NavigationFrame.js index 7c32b48..46a4963 100644 --- a/src/components/NavigationFrame.js +++ b/src/components/NavigationFrame.js @@ -126,7 +126,7 @@ function NetworkSelector() { } function WalletSelector() { - const { accounts, walletIndex, setWalletIndex } = useWalletSelector(); + const { accounts, setWalletSelector, addAccount } = useWalletSelector(); const [anchorEl, setAnchorEl] = useState(null); const [addAccountOpen, setAddAccountOpen] = useState(false); const classes = useStyles(); @@ -140,8 +140,14 @@ function WalletSelector() { setAddAccountOpen(false)} - onAdd={(name) => { - setWalletIndex(accounts.length, name); + onAdd={({ name, importedAccount }) => { + addAccount({ name, importedAccount }); + setWalletSelector({ + walletIndex: importedAccount ? undefined : accounts.length, + importedPubkey: importedAccount + ? importedAccount.publicKey.toString() + : undefined, + }); setAddAccountOpen(false); }} /> @@ -171,23 +177,21 @@ function WalletSelector() { }} getContentAnchorEl={null} > - {accounts.map(({ index, address, name }) => ( + {accounts.map(({ isSelected, selector, address, name, label }) => ( { setAnchorEl(null); - setWalletIndex(index); + setWalletSelector(selector); }} - selected={index === walletIndex} + selected={isSelected} component="div" > - {index === walletIndex ? : null} + {isSelected ? : null}
- - {index === 0 ? 'Main account' : name || `Account ${index}`} - + {name} {address.toBase58()} @@ -204,7 +208,7 @@ function WalletSelector() { - Create Account + Add Account diff --git a/src/components/SendDialog.js b/src/components/SendDialog.js index cc279a4..ffa0d3d 100644 --- a/src/components/SendDialog.js +++ b/src/components/SendDialog.js @@ -28,7 +28,10 @@ import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; import { useAsyncData } from '../utils/fetch-loop'; import CircularProgress from '@material-ui/core/CircularProgress'; -import {TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT} from '../utils/tokens/instructions'; +import { + TOKEN_PROGRAM_ID, + WRAPPED_SOL_MINT, +} from '../utils/tokens/instructions'; import { parseTokenAccountData } from '../utils/tokens/data'; const WUSDC_MINT = new PublicKey( @@ -134,9 +137,10 @@ export default function SendDialog({ open, onClose, publicKey, balanceInfo }) { } function SendSplDialog({ onClose, publicKey, balanceInfo, onSubmitRef }) { - const defaultAddressHelperText = !balanceInfo.mint || balanceInfo.mint.equals(WRAPPED_SOL_MINT) ? - 'Enter Solana Address' : - 'Enter SPL token or Solana address'; + const defaultAddressHelperText = + !balanceInfo.mint || balanceInfo.mint.equals(WRAPPED_SOL_MINT) + ? 'Enter Solana Address' + : 'Enter SPL token or Solana address'; const wallet = useWallet(); const [sendTransaction, sending] = useSendTransaction(); const [addressHelperText, setAddressHelperText] = useState( diff --git a/src/utils/wallet-seed.js b/src/utils/wallet-seed.js index 7f36682..c6e19be 100644 --- a/src/utils/wallet-seed.js +++ b/src/utils/wallet-seed.js @@ -1,5 +1,6 @@ import { pbkdf2 } from 'crypto'; import { randomBytes, secretbox } from 'tweetnacl'; +import * as bip32 from 'bip32'; import bs58 from 'bs58'; import { EventEmitter } from 'events'; @@ -19,11 +20,20 @@ export async function mnemonicToSeed(mnemonic) { return Buffer.from(seed).toString('hex'); } -let unlockedMnemonicAndSeed = JSON.parse( - sessionStorage.getItem('unlocked') || - localStorage.getItem('unlocked') || - 'null', -) || { mnemonic: null, seed: null }; +let unlockedMnemonicAndSeed = (() => { + const stored = JSON.parse( + sessionStorage.getItem('unlocked') || + localStorage.getItem('unlocked') || + 'null', + ); + if (stored === null) { + return { mnemonic: null, seed: null, importsEncryptionKey: null }; + } + return { + importsEncryptionKey: deriveImportsEncryptionKey(stored.seed), + ...stored, + }; +})(); export const walletSeedChanged = new EventEmitter(); export function getUnlockedMnemonicAndSeed() { @@ -34,8 +44,8 @@ export function hasLockedMnemonicAndSeed() { return !!localStorage.getItem('locked'); } -function setUnlockedMnemonicAndSeed(mnemonic, seed) { - unlockedMnemonicAndSeed = { mnemonic, seed }; +function setUnlockedMnemonicAndSeed(mnemonic, seed, importsEncryptionKey) { + unlockedMnemonicAndSeed = { mnemonic, seed, importsEncryptionKey }; walletSeedChanged.emit('change', unlockedMnemonicAndSeed); } @@ -67,7 +77,8 @@ export async function storeMnemonicAndSeed(mnemonic, seed, password) { localStorage.removeItem('locked'); sessionStorage.removeItem('unlocked'); } - setUnlockedMnemonicAndSeed(mnemonic, seed); + const privateKey = deriveImportsEncryptionKey(seed); + setUnlockedMnemonicAndSeed(mnemonic, seed, privateKey); } export async function loadMnemonicAndSeed(password, stayLoggedIn) { @@ -91,7 +102,8 @@ export async function loadMnemonicAndSeed(password, stayLoggedIn) { if (stayLoggedIn) { sessionStorage.setItem('unlocked', decodedPlaintext); } - setUnlockedMnemonicAndSeed(mnemonic, seed); + const privateKey = deriveImportsEncryptionKey(seed); + setUnlockedMnemonicAndSeed(mnemonic, seed, privateKey); return { mnemonic, seed }; } @@ -109,5 +121,12 @@ async function deriveEncryptionKey(password, salt, iterations, digest) { } export function lockWallet() { - setUnlockedMnemonicAndSeed(null, null); + setUnlockedMnemonicAndSeed(null, null, null); +} + +// Returns the 32 byte key used to encrypt imported private keys. +function deriveImportsEncryptionKey(seed) { + // SLIP16 derivation path. + return bip32.fromSeed(Buffer.from(seed, 'hex')).derivePath("m/10016'/0") + .privateKey; } diff --git a/src/utils/wallet.js b/src/utils/wallet.js index 98f0c9b..171435e 100644 --- a/src/utils/wallet.js +++ b/src/utils/wallet.js @@ -1,6 +1,12 @@ import React, { useContext, useMemo } from 'react'; import * as bip32 from 'bip32'; -import { Account, SystemProgram, Transaction } from '@solana/web3.js'; +import * as bs58 from 'bs58'; +import { + Account, + SystemProgram, + Transaction, + PublicKey, +} from '@solana/web3.js'; import nacl from 'tweetnacl'; import { setInitialAccountInfo, @@ -24,12 +30,15 @@ import { useTokenName } from './tokens/names'; import { refreshCache, useAsyncData } from './fetch-loop'; import { getUnlockedMnemonicAndSeed, walletSeedChanged } from './wallet-seed'; +const DEFAULT_WALLET_SELECTOR = { + walletIndex: 0, + importedPubkey: undefined, +}; + export class Wallet { - constructor(connection, seed, walletIndex = 0) { + constructor(connection, account) { this.connection = connection; - this.seed = seed; - this.walletIndex = walletIndex; - this.account = Wallet.getAccountFromSeed(this.seed, this.walletIndex); + this.account = account; } static getAccountFromSeed(seed, walletIndex, accountIndex = 0) { @@ -113,19 +122,63 @@ const WalletContext = React.createContext(null); export function WalletProvider({ children }) { useListener(walletSeedChanged, 'change'); - const { mnemonic, seed } = getUnlockedMnemonicAndSeed(); + const { mnemonic, seed, importsEncryptionKey } = getUnlockedMnemonicAndSeed(); const connection = useConnection(); - const [walletIndex, setWalletIndex] = useLocalStorageState('walletIndex', 0); - const wallet = useMemo( - () => - seed - ? new Wallet(connection, Buffer.from(seed, 'hex'), walletIndex) - : null, - [connection, seed, walletIndex], + + // `privateKeyImports` are accounts imported *in addition* to HD wallets + const [privateKeyImports, setPrivateKeyImports] = useLocalStorageState( + 'walletPrivateKeyImports', + {}, ); + // `walletSelector` identifies which wallet to use. + const [walletSelector, setWalletSelector] = useLocalStorageState( + 'walletSelector', + DEFAULT_WALLET_SELECTOR, + ); + + const wallet = useMemo(() => { + if (!seed) { + return null; + } + const account = + walletSelector.walletIndex !== undefined + ? Wallet.getAccountFromSeed( + Buffer.from(seed, 'hex'), + walletSelector.walletIndex, + ) + : new Account( + (() => { + const { nonce, ciphertext } = privateKeyImports[ + walletSelector.importedPubkey + ]; + return nacl.secretbox.open( + bs58.decode(ciphertext), + bs58.decode(nonce), + importsEncryptionKey, + ); + })(), + ); + return new Wallet(connection, account); + }, [ + connection, + seed, + walletSelector, + privateKeyImports, + importsEncryptionKey, + ]); + return ( {children} @@ -244,34 +297,66 @@ export function useBalanceInfo(publicKey) { } export function useWalletSelector() { - const { walletIndex, setWalletIndex, seed } = useContext(WalletContext); + const { + seed, + importsEncryptionKey, + walletSelector, + setWalletSelector, + privateKeyImports, + setPrivateKeyImports, + } = useContext(WalletContext); + + // `walletCount` is the number of HD wallets. const [walletCount, setWalletCount] = useLocalStorageState('walletCount', 1); - function selectWallet(walletIndex, name) { - if (walletIndex >= walletCount) { - name && localStorage.setItem(`name${walletIndex}`, name); - setWalletCount(walletIndex + 1); + + function addAccount({ name, importedAccount }) { + if (importedAccount === undefined) { + name && localStorage.setItem(`name${walletCount}`, name); + setWalletCount(walletCount + 1); + } else { + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); + const plaintext = importedAccount.secretKey; + const ciphertext = nacl.secretbox(plaintext, nonce, importsEncryptionKey); + // `useLocalStorageState` requires a new object. + let newPrivateKeyImports = { ...privateKeyImports }; + newPrivateKeyImports[importedAccount.publicKey.toString()] = { + name, + ciphertext: bs58.encode(ciphertext), + nonce: bs58.encode(nonce), + }; + setPrivateKeyImports(newPrivateKeyImports); } - setWalletIndex(walletIndex); } + const accounts = useMemo(() => { if (!seed) { return []; } - const seedBuffer = Buffer.from(seed, 'hex'); - return [...Array(walletCount).keys()].map((walletIndex) => { - let address = Wallet.getAccountFromSeed(seedBuffer, walletIndex) - .publicKey; - let name = localStorage.getItem(`name${walletIndex}`); - return { index: walletIndex, address, name }; - }); - }, [seed, walletCount]); - return { accounts, walletIndex, setWalletIndex: selectWallet }; -} -export async function mnemonicToSecretKey(mnemonic) { - const { mnemonicToSeed } = await import('bip39'); - const rootSeed = Buffer.from(await mnemonicToSeed(mnemonic), 'hex'); - const derivedSeed = bip32.fromSeed(rootSeed).derivePath("m/501'/0'/0/0") - .privateKey; - return nacl.sign.keyPair.fromSeed(derivedSeed).secretKey; + const seedBuffer = Buffer.from(seed, 'hex'); + const derivedAccounts = [...Array(walletCount).keys()].map((idx) => { + let address = Wallet.getAccountFromSeed(seedBuffer, idx).publicKey; + let name = localStorage.getItem(`name${idx}`); + return { + selector: { walletIndex: idx, importedPubkey: undefined }, + isSelected: walletSelector.walletIndex === idx, + address, + name: idx === 0 ? 'Main account' : name || `Account ${idx}`, + }; + }); + + const importedAccounts = Object.keys(privateKeyImports).map((pubkey) => { + const { name } = privateKeyImports[pubkey]; + return { + selector: { walletIndex: undefined, importedPubkey: pubkey }, + address: new PublicKey(bs58.decode(pubkey)), + name: `${name} (imported)`, // TODO: do this in the Component with styling. + isSelected: walletSelector.importedPubkey === pubkey, + }; + }); + + return derivedAccounts.concat(importedAccounts); + }, [seed, walletCount, walletSelector, privateKeyImports]); + + return { accounts, setWalletSelector, addAccount }; }