Skip to content

Commit

Permalink
📚 Accounts backup (#3252)
Browse files Browse the repository at this point in the history
This PR implements the complete functionality of accounts backup:
- it allows exporting private keys for accounts imported with a private
key or a JSON keystore file
- it allows exporting mnemonics of HD wallets

### What has been done?
- add export flow for private keys
- add export flow for mnemonics

Outside of scope of this PR 
- exporting private keys for specific accounts from HD wallets -
#3253

### Testing
1. Export private keys - plain text
  - [x] import account with a private key (plain text)
  - [x] after it is imported - go through the export flow
- [x] compare exported private key with the private key you used for
import

2. Export private keys - JSON file
  - [x] import account with JSON file
  - [x] after it is imported go through the export flow
- [x] copy exported private key, note the address of this account and
remove it
- [x] import account with copied private key, compare addresses - it
should be the same account
 
3. Export mnemonics
- [x] import wallet with the mnemonic as usual
- [x] go through the export flow
- [x] compare mnemonics

4. Other - Ledger, read-only
- [x] there should be no option to export any secrets for accounts
imported with Ledger
- [x] there should be no option to export any secrets for read-only
accounts

5. Exploratory testing
- [x] try removing and adding accounts again - there should be no
problem exporting their accounts
- [x] try to export short (12 words) and long (24 words) mnemonics 
- [x] try to export starting with both locked and unlocked wallet

## UI

![Screenshot 2023-04-05 at 08 34
05](https://user-images.githubusercontent.com/23117945/230000187-17f1645d-116b-45e9-8482-76de21d6be8d.png)![image](https://user-images.githubusercontent.com/20949277/231096292-18d32627-4af6-4dc5-a575-5a2020f6af4c.png)



![image](https://user-images.githubusercontent.com/20949277/231096872-046f8b86-1d53-4855-b2ca-32032674db75.png)![image](https://user-images.githubusercontent.com/20949277/231096598-38618c15-1492-425a-957d-e2d798994c05.png)

Latest build:
[extension-builds-3252](https://github.com/tahowallet/extension/suites/12523466508/artifacts/668490612)
(as of Thu, 27 Apr 2023 12:04:59 GMT).
  • Loading branch information
kkosiorowska committed Apr 27, 2023
2 parents 17b16da + 592ba71 commit 66e54ce
Show file tree
Hide file tree
Showing 37 changed files with 1,154 additions and 171 deletions.
2 changes: 1 addition & 1 deletion background/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1668,7 +1668,7 @@ export default class Main extends BaseService<never> {
return this.keyringService.exportPrivateKey(address)
}

async importSigner(signerRaw: SignerRawWithType): Promise<HexString | null> {
async importSigner(signerRaw: SignerRawWithType): Promise<string | null> {
return this.keyringService.importSigner(signerRaw)
}

Expand Down
2 changes: 1 addition & 1 deletion background/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@ledgerhq/hw-transport": "^6.20.0",
"@ledgerhq/hw-transport-webusb": "^6.20.0",
"@redux-devtools/remote": "^0.7.4",
"@tallyho/hd-keyring": "0.4.0",
"@tallyho/hd-keyring": "0.5.0",
"@tallyho/provider-bridge-shared": "0.0.1",
"@tallyho/window-provider": "0.0.1",
"@types/w3c-web-usb": "^1.0.5",
Expand Down
58 changes: 26 additions & 32 deletions background/redux-slices/keyrings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createSlice } from "@reduxjs/toolkit"
import Emittery from "emittery"

import { setNewSelectedAccount, UIState } from "./ui"
import { createBackgroundAsyncThunk } from "./utils"
import {
Keyring,
Expand All @@ -10,6 +9,8 @@ import {
SignerRawWithType,
} from "../services/keyring/index"
import { HexString } from "../types"
import logger from "../lib/logger"
import { UIState, setNewSelectedAccount } from "./ui"

type KeyringToVerify = {
id: string
Expand All @@ -22,7 +23,6 @@ export type KeyringsState = {
metadata: {
[keyringId: string]: SignerMetadata
}
importing: false | "pending" | "done" | "failed"
status: "locked" | "unlocked" | "uninitialized"
keyringToVerify: KeyringToVerify
}
Expand All @@ -31,7 +31,6 @@ export const initialState: KeyringsState = {
keyrings: [],
privateKeys: [],
metadata: {},
importing: false,
status: "uninitialized",
keyringToVerify: null,
}
Expand All @@ -50,23 +49,40 @@ export const importSigner = createBackgroundAsyncThunk(
async (
signerRaw: SignerRawWithType,
{ getState, dispatch, extra: { main } }
) => {
const address = await main.importSigner(signerRaw)
if (!address) return
): Promise<{ success: boolean; errorMessage?: string }> => {
let address = null

try {
address = await main.importSigner(signerRaw)
} catch (error) {
logger.error("Internal signer import failed:", error)

return {
success: false,
errorMessage: "Unexpected error during account import.",
}
}

if (!address) {
return {
success: false,
errorMessage:
"Failed to import new account. Address may already be imported.",
}
}

const { ui } = getState() as {
ui: UIState
}
// Set the selected account as the first address of the last added keyring,
// which will correspond to the last imported keyring, AKA this one. Note that
// this does rely on the KeyringService's behavior of pushing new keyrings to
// the end of the keyring list.

dispatch(
setNewSelectedAccount({
address,
network: ui.selectedAccount.network,
})
)

return { success: true }
}
)

Expand Down Expand Up @@ -108,28 +124,6 @@ const keyringsSlice = createSlice({
keyringToVerify: payload,
}),
},
extraReducers: (builder) => {
builder
.addCase(importSigner.pending, (state) => {
return {
...state,
importing: "pending",
}
})
.addCase(importSigner.fulfilled, (state) => {
return {
...state,
importing: "done",
keyringToVerify: null,
}
})
.addCase(importSigner.rejected, (state) => {
return {
...state,
importing: "failed",
}
})
},
})

export const {
Expand Down
3 changes: 2 additions & 1 deletion background/redux-slices/migrations/to-28.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type OldState = {
source: "import" | "internal"
}
}
importing: false | "pending" | "done" | "failed"
[sliceKey: string]: unknown
}
}
Expand All @@ -26,7 +27,7 @@ type NewState = {
export default (prevState: Record<string, unknown>): NewState => {
const oldState = prevState as OldState
const {
keyrings: { keyringMetadata, ...keyringsState },
keyrings: { keyringMetadata, importing, ...keyringsState },
} = oldState

return {
Expand Down
12 changes: 10 additions & 2 deletions background/services/keyring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,16 @@ export default class KeyringService extends BaseService<Events> {
this.requireUnlocked()

try {
const privateKeyWallet = await this.#findPrivateKey(address)
return privateKeyWallet.privateKey
const signerWithType = await this.#findSigner(address)

if (isPrivateKey(signerWithType)) {
return signerWithType.signer.privateKey
}

return signerWithType.signer.exportPrivateKey(
address,
"I solemnly swear that I am treating this private key material with great care."
)
} catch (e) {
return null
}
Expand Down
63 changes: 49 additions & 14 deletions background/services/keyring/tests/index.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,6 @@ describe("Keyring Service", () => {

expect(keyrings.length).toBe(1)
})
it("should be able to export mnemonic", async () => {
const mnemonic = await keyringService.exportMnemonic(
HD_WALLET_MOCK.addresses[0]
)

expect(mnemonic).toBe(HD_WALLET_MOCK.mnemonic)
})
it("should be able to sign transaction", async () => {
const address = HD_WALLET_MOCK.addresses[0]
const signed = await keyringService.signTransaction(
Expand Down Expand Up @@ -203,13 +196,6 @@ describe("Keyring Service", () => {
})
expect(keyringService.getPrivateKeys().length).toBe(1)
})
it("should be able to export private key", async () => {
const privateKey = await keyringService.exportPrivateKey(
PK_WALLET_MOCK.address
)

expect(privateKey).toBe(PK_WALLET_MOCK.privateKey)
})
it("should be able to sign transaction", async () => {
const { address } = PK_WALLET_MOCK
const signed = await keyringService.signTransaction(
Expand Down Expand Up @@ -245,4 +231,53 @@ describe("Keyring Service", () => {
expect(signed).toBeDefined()
})
})

describe("export secrets", () => {
beforeEach(async () => {
await keyringService.importSigner({
type: SignerTypes.privateKey,
privateKey: PK_WALLET_MOCK.privateKey,
})
await keyringService.importSigner({
type: SignerTypes.keyring,
mnemonic: HD_WALLET_MOCK.mnemonic,
source: "import",
})
})
it("should be able to export private key", async () => {
const privateKey = await keyringService.exportPrivateKey(
PK_WALLET_MOCK.address
)

expect(privateKey).toBe(PK_WALLET_MOCK.privateKey)
})
it("should be able to export mnemonic", async () => {
const mnemonic = await keyringService.exportMnemonic(
HD_WALLET_MOCK.addresses[0]
)

expect(mnemonic).toBe(HD_WALLET_MOCK.mnemonic)
})
it("should be able to export private key from HD wallet addresses", async () => {
const privateKey = await keyringService.exportPrivateKey(
HD_WALLET_MOCK.addresses[0]
)

expect(privateKey).toBe(PK_WALLET_MOCK.privateKey) // first address from both mocks is the same
})
it("should require wallet to be unlocked to export secrets", async () => {
keyringService.lock()

const errorMessage = "KeyringService must be unlocked."
const exportMnemonic = async () => {
await keyringService.exportMnemonic(HD_WALLET_MOCK.addresses[0])
}
const exportPrivateKey = async () => {
await keyringService.exportPrivateKey(PK_WALLET_MOCK.address)
}

expect(exportMnemonic()).rejects.toThrowError(errorMessage)
expect(exportPrivateKey()).rejects.toThrowError(errorMessage)
})
})
})
44 changes: 43 additions & 1 deletion ui/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,47 @@
"lastAccountWarningBody": "Are you sure you want to proceed?",
"removeAddress": "Remove address",
"copyAddress": "Copy address",
"removeConfirm": "Yes, I want to remove it"
"removeConfirm": "Yes, I want to remove it",
"showPrivateKey": {
"header": "Show Private Key",
"warningMessage": "Anybody that has your Private Key can move your assets.",
"privateKeyInfo": "What is a Private Key?",
"privateKey":"Private key",
"exportingPrivateKey": {
"header": "Exporting Private Key for:",
"confirmationDesc": "I understand that other Private Keys, Recovery Phrases, Ledger or Read-only accounts will not be saved with this recovery phrase.",
"invalidMessage": "Check the above box to confirm",
"showBtn": "Show Private Key",
"copyBtn": "Copy Private key to clipboard",
"copySuccess": "Copied!"
},
"explainer": {
"header": "What is a private key?",
"text1": "A private key is also known as a secret key is a string of letter and numbers that allow you to access and manage your assets on one address.",
"text2": "A private key only unlocks one address, while a recovery phrase unlocks all addresses that are tied to that phrase.",
"text3": "If you don’t use recovery phrase, you need to save a private key for each address."
}
},
"showMnemonic": {
"header": "Show Recovery Phrase",
"warningMessage": "Anybody that has your recovery phrase can move your assets.",
"mnemonicInfo": "Why do i need a recovery phrase?",
"exportingMnemonic": {
"confirmationDesc": "I understand that other Recovery Phrases, Ledger, Private Keys or Read-only accounts will not be saved with this recovery phrase.",
"invalidMessage": "Check the above box to confirm",
"showBtn": "Show recovery phrase",
"copyBtn": "Copy phrase to clipboard",
"copySuccess": "Copied!",
"address_one": "address",
"address_other": "addresses"
},
"explainer": {
"header": "Why do i need a recovery phrase?",
"text1": "You need a recovery phrase in case you lose access to your computer, wallet or you forget your password.",
"text2": "Recovery phrase is the only way to regain access to your funds stored on those addresses.",
"text3": "Taho has the option to import or create multiple recovery phrases. If you have multiple, make sure that you have safely save and store them all."
}
}
},
"notificationPanel": {
"accountPanelName": "Accounts",
Expand Down Expand Up @@ -611,9 +651,11 @@
"showPasswordHint": "Show Password",
"hidePasswordHint": "Hide Password",
"close": "Close",
"readMore": "Read more",
"accountItemSummary": {
"connectedStatus": "Connected"
},
"mouseOverToShow": "Mouse over to show",
"selectToken": "Select token",
"backButtonText": "Back",
"saveBtn": "Save",
Expand Down
45 changes: 45 additions & 0 deletions ui/components/AccountItem/AccountItemOptionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@ import { AccountTotal } from "@tallyho/tally-background/redux-slices/selectors"
import { setSnackbarMessage } from "@tallyho/tally-background/redux-slices/ui"
import React, { ReactElement, useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { AccountType } from "@tallyho/tally-background/redux-slices/accounts"
import { FeatureFlags, isEnabled } from "@tallyho/tally-background/features"
import { useBackgroundDispatch } from "../../hooks"
import SharedDropdown from "../Shared/SharedDropDown"
import SharedSlideUpMenu from "../Shared/SharedSlideUpMenu"
import AccountItemEditName from "./AccountItemEditName"
import AccountItemRemovalConfirm from "./AccountItemRemovalConfirm"
import ShowPrivateKey from "../AccountsBackup/ShowPrivateKey"

type AccountItemOptionsMenuProps = {
accountTotal: AccountTotal
accountType: AccountType
}

const allowExportPrivateKeys = [
AccountType.PrivateKey,
AccountType.Imported,
AccountType.Internal,
]

export default function AccountItemOptionsMenu({
accountTotal,
accountType,
}: AccountItemOptionsMenuProps): ReactElement {
const { t } = useTranslation("translation", {
keyPrefix: "accounts.accountItem",
Expand All @@ -22,13 +33,18 @@ export default function AccountItemOptionsMenu({
const { address, network } = accountTotal
const [showAddressRemoveConfirm, setShowAddressRemoveConfirm] =
useState(false)
const [showPrivateKeyMenu, setShowPrivateKeyMenu] = useState(false)
const [showEditName, setShowEditName] = useState(false)

const copyAddress = useCallback(() => {
navigator.clipboard.writeText(address)
dispatch(setSnackbarMessage("Address copied to clipboard"))
}, [address, dispatch])

const canExportPrivateKey =
isEnabled(FeatureFlags.SUPPORT_PRIVATE_KEYS) &&
allowExportPrivateKeys.includes(accountType)

return (
<div className="options_menu_wrap">
<SharedSlideUpMenu
Expand Down Expand Up @@ -72,6 +88,23 @@ export default function AccountItemOptionsMenu({
/>
</div>
</SharedSlideUpMenu>
<SharedSlideUpMenu
isOpen={showPrivateKeyMenu}
size="custom"
customSize="580px"
close={(e) => {
e?.stopPropagation()
setShowPrivateKeyMenu(false)
}}
>
<div
role="presentation"
onClick={(e) => e.stopPropagation()}
style={{ cursor: "default" }}
>
<ShowPrivateKey account={accountTotal} />
</div>
</SharedSlideUpMenu>
<SharedDropdown
toggler={(toggle) => (
<button
Expand Down Expand Up @@ -99,6 +132,18 @@ export default function AccountItemOptionsMenu({
copyAddress()
},
},
...(canExportPrivateKey
? [
{
key: "key",
icon: "icons/s/key.svg",
label: t("showPrivateKey.header"),
onClick: () => {
setShowPrivateKeyMenu(true)
},
},
]
: []),
{
key: "remove",
icon: "garbage@2x.png",
Expand Down
Loading

0 comments on commit 66e54ce

Please sign in to comment.