diff --git a/packages/extension/package.json b/packages/extension/package.json index d0778bbbd..d3389c582 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -67,6 +67,7 @@ "ethereum-cryptography": "^2.2.1", "ethereumjs-abi": "^0.6.8", "eventemitter3": "^5.0.1", + "jdenticon": "^3.3.0", "lodash": "^4.17.21", "memoize-one": "^6.0.0", "moment": "^2.30.1", diff --git a/packages/extension/src/libs/backup-state/configs.ts b/packages/extension/src/libs/backup-state/configs.ts new file mode 100644 index 000000000..2837d11af --- /dev/null +++ b/packages/extension/src/libs/backup-state/configs.ts @@ -0,0 +1,6 @@ +const BACKUP_URL = 'https://backupstore.enkrypt.com/'; +const HEADERS = { + Accept: 'application/json', + 'Content-Type': 'application/json', +}; +export { BACKUP_URL, HEADERS }; diff --git a/packages/extension/src/libs/backup-state/index.ts b/packages/extension/src/libs/backup-state/index.ts new file mode 100644 index 000000000..346c26739 --- /dev/null +++ b/packages/extension/src/libs/backup-state/index.ts @@ -0,0 +1,363 @@ +import BrowserStorage from '../common/browser-storage'; +import { InternalStorageNamespace } from '@/types/provider'; +import { + BackupData, + BackupResponseType, + BackupType, + IState, + ListBackupType, + StorageKeys, +} from './types'; +import PublicKeyRing from '../keyring/public-keyring'; +import sendUsingInternalMessengers from '../messenger/internal-messenger'; +import { InternalMethods } from '@/types/messenger'; +import EthNetwork from '@/providers/ethereum/networks/eth'; +import { + bufferToHex, + hexToBuffer, + NACL_VERSION, + naclEncrypt, + utf8ToHex, +} from '@enkryptcom/utils'; +import { hashPersonalMessage } from '@ethereumjs/util'; +import { v4 as uuidv4 } from 'uuid'; +import { BACKUP_URL, HEADERS } from './configs'; +import { EnkryptAccount, SignerType, WalletType } from '@enkryptcom/types'; +import KeyRingBase from '../keyring/keyring'; + +class BackupState { + private storage: BrowserStorage; + + constructor() { + this.storage = new BrowserStorage(InternalStorageNamespace.backupState); + } + + async #getSignature( + msgHash: string, + mainWallet: EnkryptAccount, + ): Promise { + return sendUsingInternalMessengers({ + method: InternalMethods.sign, + params: [msgHash, mainWallet], + }).then(res => { + if (res.error) { + console.error(res); + return null; + } else { + return JSON.parse(res.result as string); + } + }); + } + + async getMainWallet(): Promise { + const pkr = new PublicKeyRing(); + const allAccounts = await pkr.getAccounts(); + const mainWallet = allAccounts.find( + acc => + acc.walletType === WalletType.mnemonic && + acc.pathIndex === 0 && + acc.signerType === EthNetwork.signer[0] && + acc.basePath === EthNetwork.basePath, + ); + if (!mainWallet) { + throw new Error('No main wallet found'); + } + return mainWallet; + } + + getListBackupMsgHash(pubkey: string): string { + const now = new Date(); + const messageToSign = `${pubkey}-GET-BACKUPS-${(now.getUTCMonth() + 1).toString().padStart(2, '0')}-${now.getUTCDate().toString().padStart(2, '0')}-${now.getUTCFullYear()}`; + return bufferToHex( + hashPersonalMessage(hexToBuffer(utf8ToHex(messageToSign))), + ); + } + + async listBackups(options?: { + signature: string; + pubkey: string; + }): Promise { + let signature: string = ''; + let pubkey: string = ''; + if (options) { + signature = options.signature; + pubkey = options.pubkey; + } else { + const mainWallet = await this.getMainWallet(); + pubkey = mainWallet.publicKey; + const msgHash = this.getListBackupMsgHash(mainWallet.publicKey); + signature = await this.#getSignature(msgHash, mainWallet); + } + if (!signature) { + console.error('No signature found'); + return []; + } + const rawResponse = await fetch( + `${BACKUP_URL}backups/${pubkey}?signature=${signature}`, + { + method: 'GET', + headers: HEADERS, + }, + ); + const content = (await rawResponse.json()) as { + backups: ListBackupType[]; + }; + return content.backups; + } + + async getBackup(userId: string): Promise { + const mainWallet = await this.getMainWallet(); + const now = new Date(); + const messageToSign = `${userId}-GET-BACKUP-${(now.getUTCMonth() + 1).toString().padStart(2, '0')}-${now.getUTCDate().toString().padStart(2, '0')}-${now.getUTCFullYear()}`; + const msgHash = bufferToHex( + hashPersonalMessage(hexToBuffer(utf8ToHex(messageToSign))), + ); + const signature = await this.#getSignature(msgHash, mainWallet); + const rawResponse = await fetch( + `${BACKUP_URL}backups/${mainWallet.publicKey}/users/${userId}?signature=${signature}`, + { + method: 'GET', + headers: HEADERS, + }, + ); + const content = (await rawResponse.json()) as BackupResponseType; + return content.backup; + } + + async deleteBackup(userId: string): Promise { + const mainWallet = await this.getMainWallet(); + const now = new Date(); + const messageToSign = `${userId}-DELETE-BACKUP-${(now.getUTCMonth() + 1).toString().padStart(2, '0')}-${now.getUTCDate().toString().padStart(2, '0')}-${now.getUTCFullYear()}`; + const msgHash = bufferToHex( + hashPersonalMessage(hexToBuffer(utf8ToHex(messageToSign))), + ); + const signature = await this.#getSignature(msgHash, mainWallet); + if (!signature) { + console.error('No signature found'); + return false; + } + return fetch( + `${BACKUP_URL}backups/${mainWallet.publicKey}/users/${userId}?signature=${signature}`, + { + method: 'DELETE', + headers: HEADERS, + }, + ) + .then(res => res.json()) + .then(content => { + if ((content as { message: string }).message === 'Ok') { + return true; + } + console.error(content); + return false; + }); + } + + async restoreBackup(userId: string, keyringPassword: string): Promise { + const mainWallet = await this.getMainWallet(); + await sendUsingInternalMessengers({ + method: InternalMethods.unlock, + params: [keyringPassword, false], + }); + const backup = await this.getBackup(userId); + if (!backup) { + console.error('No backup found'); + return; + } + await sendUsingInternalMessengers({ + method: InternalMethods.ethereumDecrypt, + params: [backup.payload, mainWallet], + }).then(async res => { + if (res.error) { + console.error(res); + return null; + } else { + const kr = new KeyRingBase(); + await kr.unlock(keyringPassword); + const existingAccounts = await kr.getKeysArray(); + const decryptedBackup: BackupData = JSON.parse( + JSON.parse(res.result as string), + ); + const highestPathIndex: Record = {}; + decryptedBackup.accounts.forEach(acc => { + const id = `${acc.basePath}###${acc.signerType}`; + const idx = acc.pathIndex; + if (!highestPathIndex[id] || highestPathIndex[id] < idx) { + highestPathIndex[id] = idx; + } + }); + const getAccountByIndex = ( + accounts: Omit[], + basePath: string, + signerType: SignerType, + idx: number, + ): EnkryptAccount | null => { + for (const acc of accounts) { + if ( + acc.basePath === basePath && + acc.pathIndex === idx && + acc.signerType === signerType + ) { + return acc as EnkryptAccount; + } + } + return null; + }; + + for (const key of Object.keys(highestPathIndex)) { + const [basePath, signerType] = key.split('###'); + for (let i = 0; i <= highestPathIndex[key]; i++) { + const newAccount = getAccountByIndex( + decryptedBackup.accounts, + basePath, + signerType as SignerType, + i, + ); + const existingAccount = getAccountByIndex( + existingAccounts, + basePath, + signerType as SignerType, + i, + ); + if (existingAccount && newAccount) { + await kr.renameAccount(existingAccount.address, newAccount.name); + continue; + } else if (newAccount) { + await kr.saveNewAccount({ + basePath: newAccount.basePath, + name: newAccount.name, + signerType: newAccount.signerType, + walletType: newAccount.walletType, + }); + } else if (!newAccount) { + await kr.saveNewAccount({ + basePath: basePath, + name: `New Account from backup ${i}`, + signerType: signerType as SignerType, + walletType: WalletType.mnemonic, + }); + } + } + } + } + }); + } + + async backup(firstTime: boolean): Promise { + const state = await this.getState(); + if (firstTime && state.lastBackupTime !== 0) { + return true; + } + if (!state.enabled) { + return true; + } + const pkr = new PublicKeyRing(); + const allAccounts = await pkr.getAccounts(); + const mainWallet = await this.getMainWallet(); + const backupData: BackupData = { + accounts: allAccounts + .filter( + acc => !acc.isTestWallet && acc.walletType !== WalletType.privkey, + ) + .map(acc => { + return { + basePath: acc.basePath, + pathIndex: acc.pathIndex, + name: acc.name, + signerType: acc.signerType, + walletType: acc.walletType, + isHardware: acc.isHardware, + HWOptions: acc.HWOptions, + }; + }), + uuid: state.userId, + }; + const encryptPubKey = await sendUsingInternalMessengers({ + method: InternalMethods.getEthereumEncryptionPublicKey, + params: [mainWallet], + }).then(res => { + if (res.error) { + console.error(res); + return null; + } else { + return JSON.parse(res.result as string); + } + }); + if (!encryptPubKey) { + console.error('No encrypt public key found'); + return false; + } + const encryptedStr = naclEncrypt({ + publicKey: encryptPubKey, + data: JSON.stringify(backupData), + version: NACL_VERSION, + }); + const msgHash = bufferToHex(hashPersonalMessage(hexToBuffer(encryptedStr))); + return this.#getSignature(msgHash, mainWallet).then(async signature => { + const rawResponse = await fetch( + `${BACKUP_URL}backups/${mainWallet.publicKey}/users/${state.userId}?signature=${signature}`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + payload: encryptedStr, + }), + }, + ); + const content = (await rawResponse.json()) as { message: string }; + if (content.message === 'Ok') { + await this.setState({ + lastBackupTime: new Date().getTime(), + userId: state.userId, + enabled: state.enabled, + }); + return true; + } + console.error(content); + return false; + }); + } + + async setState(state: IState): Promise { + return this.storage.set(StorageKeys.backupInfo, state); + } + + async getState(): Promise { + const state = await this.storage.get(StorageKeys.backupInfo); + if (!state) { + const newState: IState = { + lastBackupTime: 0, + userId: uuidv4(), + enabled: true, + }; + await this.setState(newState); + return newState; + } + return state; + } + + async getLastUpdatedTime(): Promise { + const state: IState = await this.getState(); + return new Date(state.lastBackupTime); + } + + async getUserId(): Promise { + const state: IState = await this.getState(); + return state.userId; + } + + async disableBackups(): Promise { + const state: IState = await this.getState(); + await this.setState({ ...state, enabled: false }); + } + async enableBackups(): Promise { + const state: IState = await this.getState(); + await this.setState({ ...state, enabled: true }); + } + async isBackupEnabled(): Promise { + const state: IState = await this.getState(); + return state.enabled; + } +} + +export default BackupState; diff --git a/packages/extension/src/libs/backup-state/types.ts b/packages/extension/src/libs/backup-state/types.ts new file mode 100644 index 000000000..ef86ce012 --- /dev/null +++ b/packages/extension/src/libs/backup-state/types.ts @@ -0,0 +1,29 @@ +import { EnkryptAccount } from '@enkryptcom/types'; + +export enum StorageKeys { + backupInfo = 'backup-info', +} + +export interface IState { + lastBackupTime: number; + userId: string; + enabled: boolean; +} + +export interface ListBackupType { + userId: string; + updatedAt: string; +} + +export interface BackupType extends ListBackupType { + payload: string; +} + +export interface BackupResponseType { + backup: BackupType; +} + +export interface BackupData { + accounts: Omit[]; + uuid: string; +} diff --git a/packages/extension/src/libs/keyring/public-keyring.ts b/packages/extension/src/libs/keyring/public-keyring.ts index bdfd73589..e5cbb58b6 100644 --- a/packages/extension/src/libs/keyring/public-keyring.ts +++ b/packages/extension/src/libs/keyring/public-keyring.ts @@ -23,6 +23,7 @@ class PublicKeyRing { signerType: SignerType.secp256k1, walletType: WalletType.mnemonic, isHardware: false, + isTestWallet: true, }; allKeys['0xb1ea5a3e5ea7fa1834d48058ecda26d8c59e8251'] = { address: '0xb1ea5a3e5ea7fa1834d48058ecda26d8c59e8251', //optimism nfts @@ -33,6 +34,7 @@ class PublicKeyRing { signerType: SignerType.secp256k1, walletType: WalletType.mnemonic, isHardware: false, + isTestWallet: true, }; allKeys['0xe5dc07bdcdb8c98850050c7f67de7e164b1ea391'] = { address: '0xe5dc07bdcdb8c98850050c7f67de7e164b1ea391', @@ -43,6 +45,7 @@ class PublicKeyRing { signerType: SignerType.secp256k1, walletType: WalletType.ledger, isHardware: true, + isTestWallet: true, }; allKeys['5E56EZk6jmpq1q3Har3Ms99D9TLN9ra2inFh7Q1Hj6GpUx6D'] = { address: '5E56EZk6jmpq1q3Har3Ms99D9TLN9ra2inFh7Q1Hj6GpUx6D', @@ -53,6 +56,7 @@ class PublicKeyRing { signerType: SignerType.sr25519, walletType: WalletType.ledger, isHardware: true, + isTestWallet: true, }; allKeys['5E56EZk6jmpq1q3Har3Ms99D9TLN9ra2inFh7Q1Hj6GpUx6D'] = { address: '5CFnoCsP3pDK2thhSqYPwKELJFLQ1hBodqzSUypexyh7eHkB', @@ -63,6 +67,7 @@ class PublicKeyRing { signerType: SignerType.sr25519, walletType: WalletType.mnemonic, isHardware: false, + isTestWallet: true, }; allKeys[ 'bc1puzz9tmxawd7zdd7klfgtywrgpma3u22fz5ecxhucd4j8tygqe5ms2vdd9y' @@ -76,6 +81,7 @@ class PublicKeyRing { signerType: SignerType.secp256k1btc, walletType: WalletType.mnemonic, isHardware: false, + isTestWallet: true, }; allKeys['77hREDDaAiimedtD9bR1JDMgYLW3AA5yPvD91pvrueRp'] = { address: '77hREDDaAiimedtD9bR1JDMgYLW3AA5yPvD91pvrueRp', @@ -86,6 +92,7 @@ class PublicKeyRing { signerType: SignerType.ed25519sol, walletType: WalletType.mnemonic, isHardware: false, + isTestWallet: true, }; allKeys['tQvduDby4rvC6VU4rSirhVWuRYxbJz3rvUrVMkUWsZP'] = { address: 'tQvduDby4rvC6VU4rSirhVWuRYxbJz3rvUrVMkUWsZP', @@ -96,6 +103,7 @@ class PublicKeyRing { signerType: SignerType.ed25519sol, walletType: WalletType.mnemonic, isHardware: false, + isTestWallet: true, }; } return allKeys; diff --git a/packages/extension/src/libs/utils/initialize-wallet.ts b/packages/extension/src/libs/utils/initialize-wallet.ts index d5978605a..ff66e07c2 100644 --- a/packages/extension/src/libs/utils/initialize-wallet.ts +++ b/packages/extension/src/libs/utils/initialize-wallet.ts @@ -6,12 +6,23 @@ import KadenaNetworks from '@/providers/kadena/networks'; import SolanaNetworks from '@/providers/solana/networks'; import { NetworkNames, WalletType } from '@enkryptcom/types'; import { getAccountsByNetworkName } from '@/libs/utils/accounts'; +import BackupState from '../backup-state'; export const initAccounts = async (keyring: KeyRing) => { - const secp256k1btc = await getAccountsByNetworkName(NetworkNames.Bitcoin); - const secp256k1 = await getAccountsByNetworkName(NetworkNames.Ethereum); - const sr25519 = await getAccountsByNetworkName(NetworkNames.Polkadot); - const ed25519kda = await getAccountsByNetworkName(NetworkNames.Kadena); - const ed25519sol = await getAccountsByNetworkName(NetworkNames.Solana); + const secp256k1btc = ( + await getAccountsByNetworkName(NetworkNames.Bitcoin) + ).filter(acc => !acc.isTestWallet); + const secp256k1 = ( + await getAccountsByNetworkName(NetworkNames.Ethereum) + ).filter(acc => !acc.isTestWallet); + const sr25519 = ( + await getAccountsByNetworkName(NetworkNames.Polkadot) + ).filter(acc => !acc.isTestWallet); + const ed25519kda = ( + await getAccountsByNetworkName(NetworkNames.Kadena) + ).filter(acc => !acc.isTestWallet); + const ed25519sol = ( + await getAccountsByNetworkName(NetworkNames.Solana) + ).filter(acc => !acc.isTestWallet); if (secp256k1.length == 0) await keyring.saveNewAccount({ basePath: EthereumNetworks.ethereum.basePath, @@ -51,7 +62,31 @@ export const initAccounts = async (keyring: KeyRing) => { export const onboardInitializeWallets = async ( mnemonic: string, password: string, -): Promise => { +): Promise<{ backupsFound: boolean }> => { const kr = new KeyRing(); + const backupsState = new BackupState(); await kr.init(mnemonic, password); + try { + await kr.unlock(password); + const mainAccount = await kr.getNewAccount({ + basePath: EthereumNetworks.ethereum.basePath, + signerType: EthereumNetworks.ethereum.signer[0], + }); + const sigHash = backupsState.getListBackupMsgHash(mainAccount.publicKey); + const signature = await kr.sign(sigHash as `0x${string}`, { + basePath: EthereumNetworks.ethereum.basePath, + signerType: EthereumNetworks.ethereum.signer[0], + pathIndex: 0, + walletType: WalletType.mnemonic, + }); + const backups = await backupsState.listBackups({ + pubkey: mainAccount.publicKey, + signature, + }); + kr.lock(); + return { backupsFound: backups.length > 0 }; + } catch (e) { + console.error(e); + return { backupsFound: false }; + } }; diff --git a/packages/extension/src/types/provider.ts b/packages/extension/src/types/provider.ts index 601e67b47..862fb1c81 100644 --- a/packages/extension/src/types/provider.ts +++ b/packages/extension/src/types/provider.ts @@ -53,6 +53,7 @@ export enum InternalStorageNamespace { rateState = 'RateState', recentlySentAddresses = 'RecentlySentAddresses', updatesState = 'UpdatesState', + backupState = 'BackupState', } export enum EnkryptProviderEventMethods { persistentEvents = 'PersistentEvents', @@ -130,7 +131,7 @@ export abstract class BackgroundProviderInterface extends EventEmitter { export abstract class ProviderAPIInterface { abstract node: string; // eslint-disable-next-line @typescript-eslint/no-unused-vars - constructor(node: string, options?: unknown) { } + constructor(node: string, options?: unknown) {} abstract init(): Promise; abstract getBalance(address: string): Promise; abstract getTransactionStatus( diff --git a/packages/extension/src/ui/action/App.vue b/packages/extension/src/ui/action/App.vue index 0ade5518e..ff8617cd8 100644 --- a/packages/extension/src/ui/action/App.vue +++ b/packages/extension/src/ui/action/App.vue @@ -192,11 +192,13 @@ import UpdatedIcon from '@/ui/action/icons/updates/updated.vue'; import HeartIcon from '@/ui/action/icons/updates/heart.vue'; import { getLatestEnkryptUpdates } from '@action/utils/browser'; import { Updates } from '@/ui/action/types/updates'; +import BackupState from '@/libs/backup-state'; const domainState = new DomainState(); const networksState = new NetworksState(); const rateState = new RateState(); const updatesState = new UpdatesState(); +const backupState = new BackupState(); const appMenuRef = ref(null); const showDepositWindow = ref(false); const accountHeaderData = ref({ @@ -369,7 +371,7 @@ const openBuyPage = () => { case NetworkNames.SyscoinNEVM: case NetworkNames.Rollux: return `${(currentNetwork.value as EvmNetwork).options.buyLink}&address=${currentNetwork.value.displayAddress( - accountHeaderData.value.selectedAccount!.address + accountHeaderData.value.selectedAccount!.address, )}`; case NetworkNames.SyscoinNEVMTest: case NetworkNames.RolluxTest: @@ -408,6 +410,7 @@ const init = async () => { setNetwork(defaultNetwork); } await setActiveNetworks(); + backupState.backup(true).catch(console.error); isLoading.value = false; }; diff --git a/packages/extension/src/ui/action/components/switch/index.vue b/packages/extension/src/ui/action/components/switch/index.vue index 4eec1d802..0db605bb0 100644 --- a/packages/extension/src/ui/action/components/switch/index.vue +++ b/packages/extension/src/ui/action/components/switch/index.vue @@ -1,25 +1,26 @@ diff --git a/packages/extension/src/ui/action/views/settings/views/settings-backups/index.vue b/packages/extension/src/ui/action/views/settings/views/settings-backups/index.vue new file mode 100644 index 000000000..defe0eb93 --- /dev/null +++ b/packages/extension/src/ui/action/views/settings/views/settings-backups/index.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/packages/extension/src/ui/action/views/settings/views/settings-general/index.vue b/packages/extension/src/ui/action/views/settings/views/settings-general/index.vue index 330a4c334..819151194 100644 --- a/packages/extension/src/ui/action/views/settings/views/settings-general/index.vue +++ b/packages/extension/src/ui/action/views/settings/views/settings-general/index.vue @@ -49,7 +49,13 @@ information is collected.

- + +
+

+ Save your current list of accounts across all networks, so you don't + need to re-generate them. +

+