Restore Account with JSON Backup (#336)

* initial mockup

* it works!

* forgot a file

* refactor a bit

* fix lint

* fix styling, refactor

* fix lint

* Apply suggestions from code review

Co-authored-by: Jaco Greeff <jacogr@gmail.com>

* Open message rename

* Go back to root on import

* Only open in new when not in a window, add back

Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
Shawn Tabrizi 2020-05-30 08:59:55 +02:00 committed by GitHub
parent 7678e27f55
commit 256c5d975f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 409 additions and 8 deletions

View File

@ -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}`);
}

View File

@ -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;
}

View File

@ -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",

View File

@ -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<Result<string>> {
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 (
<>
<HeaderWithSmallerMargin
@ -85,6 +95,15 @@ export default function Import (): React.ReactElement {
</ButtonArea>
</>
)}
{!account &&
<ButtonsRow>
<ActionText
icon={upload}
onClick={_toJson}
text='Restore from JSON backup'
/>
</ButtonsRow>
}
</>
);
}
@ -99,3 +118,12 @@ const SeedInput = styled(TextAreaWithLabel)`
height: unset;
}
`;
const ButtonsRow = styled.div`
display: flex;
flex-direction: row;
${ActionText} {
margin-right: 32px;
}
`;

View File

@ -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<FileState> {
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<FileState>({ address: null, isFileValid: false, json: null });
const [{ isPassValid, password }, setPass] = useState<PassState>({ 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<void> => {
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 (
<>
<HeaderWithSmallerMargin
showBackArrow
text='Restore from JSON'
/>
<div>
<Address
address={(isFileValid && address) || null}
name={(isFileValid && json?.meta.name as string) || null}
/>
<InputFileWithLabel
accept={acceptedFormats}
isError={!isFileValid}
label={'backup file'}
onChange={_onChangeFile}
withLabel
/>
<InputWithLabel
isError={!isPassValid}
label='Password for this file'
onChange={_onChangePass}
type='password'
/>
<Button
isDisabled={!isFileValid || !isPassValid}
onClick={_onRestore}
>
Restore
</Button>
</div>
</>
);
}
const HeaderWithSmallerMargin = styled(Header)`
margin-bottom: 15px;
`;

View File

@ -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 {
<Route path='/account/export/:address'><Export /></Route>
<Route path='/account/import-qr'><ImportQr /></Route>
<Route path='/account/import-seed'><ImportSeed /></Route>
<Route path='/account/restore-json'><RestoreJson /></Route>
<Route path='/account/derive/:address/locked'><Derive isLocked /></Route>
<Route path='/account/derive/:address'><Derive /></Route>
<Route

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M224 136V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H248c-13.2 0-24-10.8-24-24zm65.18 216.01H224v80c0 8.84-7.16 16-16 16h-32c-8.84 0-16-7.16-16-16v-80H94.82c-14.28 0-21.41-17.29-11.27-27.36l96.42-95.7c6.65-6.61 17.39-6.61 24.04 0l96.42 95.7c10.15 10.07 3.03 27.36-11.25 27.36zM377 105L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1c0-6.3-2.5-12.4-7-16.9z"/></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@ -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<InputFileProps> {
const dropRef = createRef<DropzoneRef>();
const [file, setFile] = useState<FileState | undefined>();
const _onDrop = useCallback(
(files: File[]): void => {
files.forEach((file): void => {
const reader = new FileReader();
reader.onabort = NOOP;
reader.onerror = NOOP;
reader.onload = ({ target }: ProgressEvent<FileReader>): 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 = (
<Dropzone
accept={accept}
disabled={isDisabled}
multiple={false}
onDrop={_onDrop}
ref={dropRef}
>
{({ getInputProps, getRootProps }): JSX.Element => (
<div {...getRootProps({ className: classes('ui--InputFile', isError ? 'error' : '', className) })} >
<input {...getInputProps()} />
<em className='label' >
{
!file || clearContent
? placeholder || 'click to select or drag and drop the file here'
: placeholder || `${file.name} (${formatNumber(file.size)} bytes)`
}
</em>
</div>
)}
</Dropzone>
);
return label
? (
<Label
label={label}
>
{dropZone}
</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;
}
`);

View File

@ -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';

View File

@ -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<boolean> {
return sendMessage('pri(derivation.create)', { name, parentAddress, parentPassword, password, suri });
}
export async function jsonRestoreWindowOpen (): Promise<boolean> {
return sendMessage('pri(window.open.json)', null);
}
export async function jsonVerifyFile (json: KeyringPair$Json): Promise<boolean> {
return sendMessage('pri(json.verify.file)', { json, password: '' });
}
export async function jsonVerifyPassword (password: string): Promise<boolean> {
return sendMessage('pri(json.verify.password)', password);
}
export async function jsonRestore (json: KeyringPair$Json, password: string): Promise<ResponseJsonRestore> {
return sendMessage('pri(json.restore)', { json, password });
}

View File

@ -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"