diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 2baef2743bf..0a87eebfd78 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -4,14 +4,14 @@ import { MetadataDef } from '@polkadot/extension-inject/types'; import { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; -import { AccountJson, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, ResponseDeriveValidate, RequestMetadataApprove, RequestMetadataReject, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSeedCreate, RequestTypes, ResponseAccountExport, RequestAccountForget, ResponseSeedCreate, RequestSeedValidate, RequestDeriveValidate, ResponseSeedValidate, ResponseType, SigningRequest } from '../types'; +import { AccountJson, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, ResponseDeriveValidate, RequestMetadataApprove, RequestMetadataReject, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSeedCreate, RequestTypes, ResponseAccountExport, RequestAccountForget, ResponseSeedCreate, RequestSeedValidate, RequestDeriveValidate, ResponseSeedValidate, ResponseType, SigningRequest, RequestJsonRestore, ResponseJsonRestore } from '../types'; import chrome from '@polkadot/extension-inject/chrome'; import keyring from '@polkadot/ui-keyring'; import accountsObservable from '@polkadot/ui-keyring/observable/accounts'; import { TypeRegistry } from '@polkadot/types'; import { KeyringPair, KeyringPair$Meta } from '@polkadot/keyring/types'; -import { assert, isHex } from '@polkadot/util'; +import { assert, isHex, isObject } from '@polkadot/util'; import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto'; import State from './State'; @@ -177,6 +177,41 @@ export default class Extension { return true; } + private jsonRestore ({ json, password }: RequestJsonRestore): ResponseJsonRestore { + try { + const pair = keyring.restoreAccount(json, password); + + if (pair) { + return { error: null }; + } + } catch (error) { + return { error: (error as Error).message }; + } + + return { error: 'Could not restore account.' }; + } + + private jsonVerifyFile ({ json }: RequestJsonRestore): boolean { + try { + const publicKey = keyring.decodeAddress(json.address, true); + const isFileValid = publicKey.length === 32 && isHex(json.encoded) && isObject(json.meta) && ( + Array.isArray(json.encoding.content) + ? json.encoding.content[0] === 'pkcs8' + : json.encoding.content === 'pkcs8' + ); + + return isFileValid; + } catch (error) { + console.error(error); + } + + return false; + } + + private jsonVerifyPassword (password: string): boolean { + return keyring.isPassValid(password); + } + private seedCreate ({ length = SEED_DEFAULT_LENGTH, type }: RequestSeedCreate): ResponseSeedCreate { const seed = mnemonicGenerate(length); @@ -270,9 +305,11 @@ export default class Extension { return true; } - private windowOpen (): boolean { + private windowOpen (path = '/'): boolean { + console.error('open', `${chrome.extension.getURL('index.html')}#${path}`); + chrome.tabs.create({ - url: chrome.extension.getURL('index.html') + url: `${chrome.extension.getURL('index.html')}#${path}` }); return true; @@ -367,6 +404,15 @@ export default class Extension { case 'pri(derivation.validate)': return this.derivationValidate(request as RequestDeriveValidate); + case 'pri(json.restore)': + return this.jsonRestore(request as RequestJsonRestore); + + case 'pri(json.verify.file)': + return this.jsonVerifyFile(request as RequestJsonRestore); + + case 'pri(json.verify.password)': + return this.jsonVerifyPassword(request as string); + case 'pri(seed.create)': return this.seedCreate(request as RequestSeedCreate); @@ -388,6 +434,9 @@ export default class Extension { case 'pri(window.open)': return this.windowOpen(); + case 'pri(window.open.json)': + return this.windowOpen('/account/restore-json'); + default: throw new Error(`Unable to handle message of type ${type}`); } diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index f4a8864ad4d..345539dc592 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -5,7 +5,7 @@ import { InjectedAccount, MetadataDef, ProviderList, ProviderMeta, InjectedMetadataKnown } from '@polkadot/extension-inject/types'; import { JsonRpcResponse } from '@polkadot/rpc-provider/types'; import { KeypairType } from '@polkadot/util-crypto/types'; -import { KeyringPair, KeyringPair$Meta } from '@polkadot/keyring/types'; +import { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types'; import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import { TypeRegistry } from '@polkadot/types'; @@ -76,6 +76,9 @@ export interface RequestSignatures { 'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]]; 'pri(derivation.create)': [RequestDeriveCreate, boolean]; 'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate]; + 'pri(json.restore)': [RequestJsonRestore, ResponseJsonRestore]; + 'pri(json.verify.file)': [RequestJsonRestore, boolean]; + 'pri(json.verify.password)': [string, boolean]; 'pri(metadata.approve)': [RequestMetadataApprove, boolean]; 'pri(metadata.reject)': [RequestMetadataReject, boolean]; 'pri(metadata.requests)': [RequestMetadataSubscribe, boolean, MetadataRequest[]]; @@ -87,6 +90,7 @@ export interface RequestSignatures { 'pri(signing.cancel)': [RequestSigningCancel, boolean]; 'pri(signing.requests)': [RequestSigningSubscribe, boolean, SigningRequest[]]; 'pri(window.open)': [null, boolean]; + 'pri(window.open.json)': [null, boolean]; // public/external requests, i.e. from a page 'pub(accounts.list)': [RequestAccountList, InjectedAccount[]]; 'pub(accounts.subscribe)': [RequestAccountSubscribe, boolean, InjectedAccount[]]; @@ -306,3 +310,12 @@ export interface RequestSign { sign (registry: TypeRegistry, pair: KeyringPair): { signature: string }; } + +export interface RequestJsonRestore { + json: KeyringPair$Json; + password: string; +} + +export interface ResponseJsonRestore { + error: string | null; +} diff --git a/packages/extension-ui/package.json b/packages/extension-ui/package.json index fe797ff3bac..1413e0e6634 100644 --- a/packages/extension-ui/package.json +++ b/packages/extension-ui/package.json @@ -15,6 +15,7 @@ "@polkadot/util-crypto": "^2.11.1", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-dropzone": "^11.0.1", "react-is": "^16.13.1", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", diff --git a/packages/extension-ui/src/Popup/ImportSeed.tsx b/packages/extension-ui/src/Popup/ImportSeed.tsx index fe99d3f0b69..841ab7674ec 100644 --- a/packages/extension-ui/src/Popup/ImportSeed.tsx +++ b/packages/extension-ui/src/Popup/ImportSeed.tsx @@ -5,10 +5,11 @@ import React, { useCallback, useContext, useState } from 'react'; import styled from 'styled-components'; -import { ActionContext, Address, ButtonArea, NextStepButton, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components'; +import { ActionContext, Address, ButtonArea, NextStepButton, TextAreaWithLabel, ValidatedInput, VerticalSpace, ActionText } from '../components'; import { allOf, isNotShorterThan, Result } from '../validators'; -import { createAccountSuri, validateSeed } from '../messaging'; +import { createAccountSuri, validateSeed, jsonRestoreWindowOpen } from '../messaging'; import { Header, Name, Password } from '../partials'; +import upload from '../assets/file-upload.svg'; async function validate (suri: string): Promise> { try { @@ -52,6 +53,15 @@ export default function Import (): React.ReactElement { } }, [account, name, onAction, password]); + const _toJson = useCallback((): void => { + // are we in a popup? + if (window.innerWidth <= 480) { + jsonRestoreWindowOpen().catch(console.error); + } else { + onAction('/account/restore-json'); + } + }, [onAction]); + return ( <> )} + {!account && + + + + } ); } @@ -99,3 +118,12 @@ const SeedInput = styled(TextAreaWithLabel)` height: unset; } `; + +const ButtonsRow = styled.div` + display: flex; + flex-direction: row; + + ${ActionText} { + margin-right: 32px; + } +`; diff --git a/packages/extension-ui/src/Popup/RestoreJson.tsx b/packages/extension-ui/src/Popup/RestoreJson.tsx new file mode 100644 index 00000000000..2620f675aea --- /dev/null +++ b/packages/extension-ui/src/Popup/RestoreJson.tsx @@ -0,0 +1,118 @@ +// Copyright 2019-2020 @polkadot/extension-ui authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { KeyringPair$Json } from '@polkadot/keyring/types'; +import React, { useCallback, useContext, useState } from 'react'; +import { ActionContext, InputWithLabel, InputFileWithLabel, Button, Address } from '../components'; +import { u8aToString } from '@polkadot/util'; +import styled from 'styled-components'; +import { jsonRestore, jsonVerifyPassword, jsonVerifyFile } from '../messaging'; + +import { Header } from '../partials'; + +interface FileState { + address: string | null; + isFileValid: boolean; + json: KeyringPair$Json | null; +} + +// FIXME We want to display the decodeError +interface PassState { + decodeError?: string | null; + isPassValid: boolean; + password: string; +} + +const acceptedFormats = ['application/json', 'text/plain'].join(', '); + +async function parseFile (file: Uint8Array): Promise { + try { + const json = JSON.parse(u8aToString(file)) as KeyringPair$Json; + const isFileValid = await jsonVerifyFile(json); + const address = json.address; + + return { address, isFileValid, json }; + } catch (error) { + console.error(error); + } + + return { address: null, isFileValid: false, json: null }; +} + +export default function Upload (): React.ReactElement { + const onAction = useContext(ActionContext); + const [{ address, isFileValid, json }, setJson] = useState({ address: null, isFileValid: false, json: null }); + const [{ isPassValid, password }, setPass] = useState({ isPassValid: false, password: '' }); + + const _onChangePass = useCallback( + (password: string): void => { + jsonVerifyPassword(password) + .then((isPassValid) => setPass({ isPassValid, password })) + .catch(console.error); + }, [] + ); + + const _onChangeFile = useCallback( + async (file: Uint8Array): Promise => { + setJson(await parseFile(file)); + }, [] + ); + + const _onRestore = useCallback( + (): void => { + if (!json || !password) { + return; + } + + jsonRestore(json, password) + .then(({ error }): void => { + if (error) { + setPass(({ password }) => ({ decodeError: error, isPassValid: false, password })); + } else { + onAction('/'); + } + }) + .catch(console.error); + }, + [json, onAction, password] + ); + + return ( + <> + +
+
+ + + +
+ + ); +} + +const HeaderWithSmallerMargin = styled(Header)` + margin-bottom: 15px; +`; diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx index 6fc0cce97ab..5763b725316 100644 --- a/packages/extension-ui/src/Popup/index.tsx +++ b/packages/extension-ui/src/Popup/index.tsx @@ -22,6 +22,7 @@ import Export from './Export'; import Forget from './Forget'; import ImportQr from './ImportQr'; import ImportSeed from './ImportSeed'; +import RestoreJson from './RestoreJson'; import Metadata from './Metadata'; import Signing from './Signing'; import Welcome from './Welcome'; @@ -121,6 +122,7 @@ export default function Popup (): React.ReactElement { + \ No newline at end of file diff --git a/packages/extension-ui/src/components/InputFileWithLabel.tsx b/packages/extension-ui/src/components/InputFileWithLabel.tsx new file mode 100644 index 00000000000..53a6771e173 --- /dev/null +++ b/packages/extension-ui/src/components/InputFileWithLabel.tsx @@ -0,0 +1,141 @@ +// Copyright 2017-2020 @polkadot/react-components authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import React, { useCallback, useState, createRef } from 'react'; +import Dropzone, { DropzoneRef } from 'react-dropzone'; +import styled from 'styled-components'; +import { formatNumber, isHex, u8aToString, hexToU8a } from '@polkadot/util'; + +import Label from './Label'; + +function classes (...classNames: (boolean | null | string | undefined)[]): string { + return classNames + .filter((className): boolean => !!className) + .join(' '); +} + +export interface InputFileProps { + // Reference Example Usage: https://github.com/react-dropzone/react-dropzone/tree/master/examples/Accept + // i.e. MIME types: 'application/json, text/plain', or '.json, .txt' + className?: string; + accept?: string; + clearContent?: boolean; + convertHex?: boolean; + help?: React.ReactNode; + isDisabled?: boolean; + isError?: boolean; + label: string; + onChange?: (contents: Uint8Array, name: string) => void; + placeholder?: React.ReactNode | null; + withEllipsis?: boolean; + withLabel?: boolean; +} + +interface FileState { + name: string; + size: number; +} + +const BYTE_STR_0 = '0'.charCodeAt(0); +const BYTE_STR_X = 'x'.charCodeAt(0); +const NOOP = (): void => undefined; + +function convertResult (result: ArrayBuffer, convertHex?: boolean): Uint8Array { + const data = new Uint8Array(result); + + // this converts the input (if detected as hex), vai the hex conversion route + if (convertHex && data[0] === BYTE_STR_0 && data[1] === BYTE_STR_X) { + const hex = u8aToString(data); + + if (isHex(hex)) { + return hexToU8a(hex); + } + } + + return data; +} + +function InputFile ({ accept, className = '', clearContent, convertHex, isDisabled, isError = false, label, onChange, placeholder }: InputFileProps): React.ReactElement { + const dropRef = createRef(); + const [file, setFile] = useState(); + + const _onDrop = useCallback( + (files: File[]): void => { + files.forEach((file): void => { + const reader = new FileReader(); + + reader.onabort = NOOP; + reader.onerror = NOOP; + + reader.onload = ({ target }: ProgressEvent): void => { + if (target && target.result) { + const name = file.name; + const data = convertResult(target.result as ArrayBuffer, convertHex); + + onChange && onChange(data, name); + dropRef && setFile({ + name, + size: data.length + }); + } + }; + + reader.readAsArrayBuffer(file); + }); + }, + [convertHex, dropRef, onChange] + ); + + const dropZone = ( + + {({ getInputProps, getRootProps }): JSX.Element => ( +
+ + + { + !file || clearContent + ? placeholder || 'click to select or drag and drop the file here' + : placeholder || `${file.name} (${formatNumber(file.size)} bytes)` + } + +
+ )} +
+ ); + + return label + ? ( + + ) + : dropZone; +} + +export default React.memo(styled(InputFile)` + border: 1px solid rgb(67, 68, 75); + background: rgb(17, 18, 24); + border-radius: 0.28571429rem; + font-size: 1rem; + margin: 0.25rem 0; + color: rgb(255, 255, 255); + padding: 0.5rem 0.75rem; + overflow-wrap: anywhere; + + &.error { + border-color: rgb(126, 53, 48); + } + + &:hover { + cursor: pointer; + } +`); diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts index fc0281fa1e0..004fb13a3d7 100644 --- a/packages/extension-ui/src/components/index.ts +++ b/packages/extension-ui/src/components/index.ts @@ -16,6 +16,7 @@ export { default as Fonts } from './Fonts'; export { default as Icon } from './Icon'; export { default as Identicon } from './Identicon'; export { default as InputWithLabel } from './InputWithLabel'; +export { default as InputFileWithLabel } from './InputFileWithLabel'; export { default as Label } from './Label'; export { default as Link } from './Link'; export { default as List } from './List'; diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 2ff92f5b97c..45923526fd3 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -3,13 +3,14 @@ // of the Apache-2.0 license. See the LICENSE file for details. import { Message } from '@polkadot/extension-base/types'; -import { AccountJson, AuthorizeRequest, SigningRequest, RequestTypes, MessageTypes, ResponseTypes, SeedLengths, SubscriptionMessageTypes, MetadataRequest, MessageTypesWithNullRequest, MessageTypesWithNoSubscriptions, MessageTypesWithSubscriptions, ResponseDeriveValidate } from '@polkadot/extension-base/background/types'; +import { AccountJson, AuthorizeRequest, SigningRequest, RequestTypes, MessageTypes, ResponseTypes, SeedLengths, SubscriptionMessageTypes, MetadataRequest, MessageTypesWithNullRequest, MessageTypesWithNoSubscriptions, MessageTypesWithSubscriptions, ResponseDeriveValidate, ResponseJsonRestore } from '@polkadot/extension-base/background/types'; import { Chain } from '@polkadot/extension-chains/types'; import { KeypairType } from '@polkadot/util-crypto/types'; import { PORT_EXTENSION } from '@polkadot/extension-base/defaults'; import chrome from '@polkadot/extension-inject/chrome'; import { findChain } from '@polkadot/extension-chains'; +import { KeyringPair$Json } from '@polkadot/keyring/types'; interface Handler { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -155,3 +156,19 @@ export async function validateDerivationPath (parentAddress: string, suri: strin export async function deriveAccount (parentAddress: string, suri: string, parentPassword: string, name: string, password: string): Promise { return sendMessage('pri(derivation.create)', { name, parentAddress, parentPassword, password, suri }); } + +export async function jsonRestoreWindowOpen (): Promise { + return sendMessage('pri(window.open.json)', null); +} + +export async function jsonVerifyFile (json: KeyringPair$Json): Promise { + return sendMessage('pri(json.verify.file)', { json, password: '' }); +} + +export async function jsonVerifyPassword (password: string): Promise { + return sendMessage('pri(json.verify.password)', password); +} + +export async function jsonRestore (json: KeyringPair$Json, password: string): Promise { + return sendMessage('pri(json.restore)', { json, password }); +} diff --git a/yarn.lock b/yarn.lock index 8f30bc0d47f..82284f23701 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3030,6 +3030,7 @@ __metadata: enzyme-adapter-react-16: ^1.15.2 react: ^16.13.1 react-dom: ^16.13.1 + react-dropzone: ^11.0.1 react-is: ^16.13.1 react-router: ^5.2.0 react-router-dom: ^5.2.0 @@ -5044,6 +5045,13 @@ __metadata: languageName: node linkType: hard +"attr-accept@npm:^2.0.0": + version: 2.1.0 + resolution: "attr-accept@npm:2.1.0" + checksum: 3/a31fdf3b37daed32e8f33a46e17b3a85b156900c9b7e564cf9202a4893bf475aeebda41421c0febce65bac02c528cbe603abc18592af907fc4a7b839a181a5e4 + languageName: node + linkType: hard + "autocomplete.js@npm:0.36.0": version: 0.36.0 resolution: "autocomplete.js@npm:0.36.0" @@ -9819,6 +9827,15 @@ __metadata: languageName: node linkType: hard +"file-selector@npm:^0.1.12": + version: 0.1.12 + resolution: "file-selector@npm:0.1.12" + dependencies: + tslib: ^1.9.0 + checksum: 3/b1a8287dabc8c239998ecb319c5b4eae156bdeb23130d3b5b6bed349f296d7798d0e5ef43e83dad3550aa2b1d1fd45ab7c5302fe8a9e26196676089de565f4ac + languageName: node + linkType: hard + "file-uri-to-path@npm:1.0.0": version: 1.0.0 resolution: "file-uri-to-path@npm:1.0.0" @@ -17262,6 +17279,19 @@ __metadata: languageName: node linkType: hard +"react-dropzone@npm:^11.0.1": + version: 11.0.1 + resolution: "react-dropzone@npm:11.0.1" + dependencies: + attr-accept: ^2.0.0 + file-selector: ^0.1.12 + prop-types: ^15.7.2 + peerDependencies: + react: ">= 16.8" + checksum: 3/c5192e7367df18d77b8b4c219fd9e091d9d63e7a530ec9f983b42e32a738079f0f5f5082057a867ffd7031aa0a703638d7e873476cb6a27b96c0f330e4166523 + languageName: node + linkType: hard + "react-is@npm:^16.12.0, react-is@npm:^16.13.1, react-is@npm:^16.6.0, react-is@npm:^16.7.0, react-is@npm:^16.8.1, react-is@npm:^16.8.6, react-is@npm:^16.9.0": version: 16.13.1 resolution: "react-is@npm:16.13.1"