diff --git a/packages/extension-base/src/background/RequestBytesDecrypt.ts b/packages/extension-base/src/background/RequestBytesDecrypt.ts new file mode 100644 index 0000000000..65a0736ef6 --- /dev/null +++ b/packages/extension-base/src/background/RequestBytesDecrypt.ts @@ -0,0 +1,33 @@ +// Copyright 2019-2021 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeyringPair } from '@polkadot/keyring/types'; +import type { DecryptPayloadRaw } from '@polkadot/types/types'; +import type { RequestDecrypt } from './types'; +import type { HexString } from '@polkadot/util/types'; + +import { wrapBytes } from '@polkadot/extension-dapp/wrapBytes'; +import { TypeRegistry } from '@polkadot/types'; +import { u8aToHex, hexToU8a } from '@polkadot/util'; + +export default class RequestBytesDecrypt implements RequestDecrypt { + public readonly payload: DecryptPayloadRaw; + + constructor (payload: DecryptPayloadRaw) { + this.payload = payload; + } + + decrypt (_registry: TypeRegistry, pair: KeyringPair, recipientPublicKey: HexString | string | Uint8Array): { decrypted: string } { + console.log("pair", pair); + console.log("this.payload.data", this.payload.data); + console.log("wrapBytes(this.payload.data)", wrapBytes(this.payload.data)); + console.log("recipientPublicKey", recipientPublicKey); + return { + decrypted: u8aToHex( + pair.decryptMessage( + hexToU8a(this.payload.data), recipientPublicKey + ) + ) + }; + } +} diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index f7e2f5c222..06a891ab00 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -5,7 +5,7 @@ import type { MetadataDef } from '@polkadot/extension-inject/types'; import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types'; import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; -import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types'; +import type { AccountJson, AllowedPath, AuthorizeRequest, DecryptingRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestBatchRestore, RequestDecryptingApprove, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest, RequestDecryptingCancel, RequestDecryptingApprovePassword } from '../types'; import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults'; import chrome from '@polkadot/extension-inject/chrome'; @@ -409,19 +409,23 @@ export default class Extension { private signingIsLocked ({ id }: RequestSigningIsLocked): ResponseSigningIsLocked { const queued = this.#state.getSignRequest(id); + let address; + if (queued) { + address = queued.request.payload.address; + }else{ + const decryptRequest = this.#state.getDecryptingRequest(id); + address = decryptRequest.request.payload.address; + } + assert(address, 'Unable to find address from request'); - assert(queued, 'Unable to find request'); - - const address = queued.request.payload.address; const pair = keyring.getPair(address); - assert(pair, 'Unable to find pair'); const remainingTime = this.refreshAccountPasswordCache(pair); return { isLocked: pair.isLocked, - remainingTime + remainingTime }; } @@ -440,6 +444,108 @@ export default class Extension { return true; } + private decryptingSubscribe (id: string, port: chrome.runtime.Port): boolean { + console.log('decryptingSubscribe'); + const cb = createSubscription<'pri(decrypting.requests)'>(id, port); + const subscription = this.#state.decryptSubject.subscribe((requests: DecryptingRequest[]): void => + cb(requests) + ); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + subscription.unsubscribe(); + }); + + return true; + } + + private decryptingApprove ({ id, decrypted }: RequestDecryptingApprove): boolean { + const queued = this.#state.getDecryptingRequest(id); + + assert(queued, 'Unable to find request'); + + const { resolve } = queued; + + resolve({ id, decrypted}); + + return true; + } + + private decryptingApprovePassword ({ id, password, savePass }: RequestDecryptingApprovePassword): boolean { + const queued = this.#state.getDecryptingRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject, request, resolve } = queued; + const pair = keyring.getPair(queued.account.address); + + // unlike queued.account.address the following + // address is encoded with the default prefix + // which what is used for password caching mapping + const { address } = pair; + + if (!pair) { + reject(new Error('Unable to find pair')); + + return false; + } + + this.refreshAccountPasswordCache(pair); + + // if the keyring pair is locked, the password is needed + if (pair.isLocked && !password) { + reject(new Error('Password needed to unlock the account')); + } + + if (pair.isLocked) { + pair.decodePkcs8(password); + } + + // const { payload } = request; + + // if (isJsonPayload(payload)) { + // // Get the metadata for the genesisHash + // const currentMetadata = this.#state.knownMetadata.find((meta: MetadataDef) => + // meta.genesisHash === payload.genesisHash); + + // // set the registry before calling the sign function + // registry.setSignedExtensions(payload.signedExtensions, currentMetadata?.userExtensions); + + // if (currentMetadata) { + // registry.register(currentMetadata?.types); + // } + // } + + const result = request.decrypt(registry, pair, request.payload.recipientPublicKey); + + if (savePass) { + this.#cachedUnlocks[address] = Date.now() + PASSWORD_EXPIRY_MS; + } else { + pair.lock(); + } + + resolve({ + id, + ...result + }); + + return true; + } + + + private decryptingCancel ({ id }: RequestDecryptingCancel): boolean { + const queued = this.#state.getDecryptingRequest(id); + + assert(queued, 'Unable to find request'); + + const { reject } = queued; + + reject(new Error('Cancelled')); + + return true; + } + + private windowOpen (path: AllowedPath): boolean { const url = `${chrome.extension.getURL('index.html')}#${path}`; @@ -448,7 +554,7 @@ export default class Extension { return false; } - + console.log('open', url); // eslint-disable-next-line no-void @@ -608,6 +714,18 @@ export default class Extension { case 'pri(signing.requests)': return this.signingSubscribe(id, port); + case 'pri(decrypting.requests)': + return this.decryptingSubscribe(id, port); + + case 'pri(decrypting.approve)': + return this.decryptingApprove(request as RequestDecryptingApprove); + + case 'pri(decrypting.approve.password)': + return this.decryptingApprovePassword(request as RequestDecryptingApprovePassword); + + case 'pri(decrypting.cancel)': + return this.decryptingCancel(request as RequestDecryptingCancel); + case 'pri(window.open)': return this.windowOpen(request as AllowedPath); diff --git a/packages/extension-base/src/background/handlers/State.ts b/packages/extension-base/src/background/handlers/State.ts index 46d753e760..703d6bd4dd 100644 --- a/packages/extension-base/src/background/handlers/State.ts +++ b/packages/extension-base/src/background/handlers/State.ts @@ -3,7 +3,7 @@ import type { MetadataDef, ProviderMeta } from '@polkadot/extension-inject/types'; import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback } from '@polkadot/rpc-provider/types'; -import type { AccountJson, AuthorizeRequest, MetadataRequest, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning, SigningRequest } from '../types'; +import type { AccountJson, AuthorizeRequest, MetadataRequest, RequestAuthorizeTab, RequestDecrypt, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning, SigningRequest, DecryptingRequest, ResponseDecrypting } from '../types'; import { BehaviorSubject } from 'rxjs'; @@ -58,6 +58,13 @@ interface SignRequest extends Resolver { url: string; } +interface DecryptRequest extends Resolver { + account: AccountJson; + id: string; + request: RequestDecrypt; + url: string; +} + let idCounter = 0; const NOTIFICATION_URL = chrome.extension.getURL('notification.html'); @@ -108,6 +115,8 @@ export default class State { readonly #providers: Providers; readonly #signRequests: Record = {}; + + readonly #decryptRequests: Record = {}; #windows: number[] = []; @@ -116,6 +125,8 @@ export default class State { public readonly metaSubject: BehaviorSubject = new BehaviorSubject([]); public readonly signSubject: BehaviorSubject = new BehaviorSubject([]); + + public readonly decryptSubject: BehaviorSubject = new BehaviorSubject([]); constructor (providers: Providers = {}) { this.#providers = providers; @@ -147,6 +158,11 @@ export default class State { return Object.keys(this.#signRequests).length; } + public get numDecryptRequests (): number { + return Object.keys(this.#decryptRequests).length; + } + + public get allAuthRequests (): AuthorizeRequest[] { return Object .values(this.#authRequests) @@ -165,6 +181,12 @@ export default class State { .map(({ account, id, request, url }): SigningRequest => ({ account, id, request, url })); } + public get allDecryptRequests (): DecryptingRequest[] { + return Object + .values(this.#decryptRequests) + .map(({ account, id, request, url }): DecryptingRequest => ({ account, id, request, url })); + } + public get authUrls (): AuthUrls { return this.#authUrls; } @@ -260,8 +282,26 @@ export default class State { }; } + private decryptComplete = (id: string, resolve: (result: ResponseDecrypting) => void, reject: (error: Error) => void): Resolver => { + const complete = (): void => { + delete this.#decryptRequests[id]; + this.updateIconSign(true); + }; + + return { + reject: (error: Error): void => { + complete(); + reject(error); + }, + resolve: (result: ResponseDecrypting): void => { + complete(); + resolve(result); + } + }; + } + private stripUrl (url: string): string { - assert(url && (url.startsWith('http:') || url.startsWith('https:') || url.startsWith('ipfs:') || url.startsWith('ipns:')), `Invalid url ${url}, expected to start with http: or https: or ipfs: or ipns:`); + assert(url && (url.startsWith('http:') || url.startsWith('https:') || url.startsWith('ipfs:') || url.startsWith('ipns:') || url.startsWith('chrome-extension:')), `Invalid url ${url}, expected to start with http: or https: or ipfs: or ipns:`); const parts = url.split('/'); @@ -272,12 +312,13 @@ export default class State { const authCount = this.numAuthRequests; const metaCount = this.numMetaRequests; const signCount = this.numSignRequests; + const decryptCount = this.numDecryptRequests; const text = ( authCount ? 'Auth' : metaCount ? 'Meta' - : (signCount ? `${signCount}` : '') + : (signCount ? `${signCount}` : decryptCount ? `${decryptCount}` : '') ); // eslint-disable-next-line no-void @@ -314,6 +355,11 @@ export default class State { this.updateIcon(shouldClose); } + private updateIconDecrypt (shouldClose?: boolean): void { + this.decryptSubject.next(this.allDecryptRequests); + this.updateIcon(shouldClose); + } + public async authorizeUrl (url: string, request: RequestAuthorizeTab): Promise { const idStr = this.stripUrl(url); @@ -383,6 +429,10 @@ export default class State { return this.#signRequests[id]; } + public getDecryptingRequest (id: string): DecryptRequest { + return this.#decryptRequests[id]; + } + // List all providers the extension is exposing public rpcListProviders (): Promise { return Promise.resolve(Object.keys(this.#providers).reduce((acc, key) => { @@ -479,4 +529,21 @@ export default class State { this.popupOpen(); }); } + + public decrypt (url: string, request: RequestDecrypt, account: AccountJson): Promise { + const id = getId(); + + return new Promise((resolve, reject): void => { + this.#decryptRequests[id] = { + ...this.decryptComplete(id, resolve, reject), + account, + id, + request, + url + }; + + this.updateIconDecrypt(); + this.popupOpen(); + }); + } } diff --git a/packages/extension-base/src/background/handlers/Tabs.ts b/packages/extension-base/src/background/handlers/Tabs.ts index 8e51e8a50d..ec01edffd2 100644 --- a/packages/extension-base/src/background/handlers/Tabs.ts +++ b/packages/extension-base/src/background/handlers/Tabs.ts @@ -4,9 +4,9 @@ import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderMeta } from '@polkadot/extension-inject/types'; import type { KeyringPair } from '@polkadot/keyring/types'; import type { JsonRpcResponse } from '@polkadot/rpc-provider/types'; -import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; +import type { SignerPayloadJSON, SignerPayloadRaw, DecryptPayloadRaw } from '@polkadot/types/types'; import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; -import type { MessageTypes, RequestAccountList, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestTypes, ResponseRpcListProviders, ResponseSigning, ResponseTypes, SubscriptionMessageTypes } from '../types'; +import type { MessageTypes, RequestAccountList, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestTypes, ResponseDecrypting, ResponseRpcListProviders, ResponseSigning, ResponseTypes, SubscriptionMessageTypes } from '../types'; import { PHISHING_PAGE_REDIRECT } from '@polkadot/extension-base/defaults'; import { canDerive } from '@polkadot/extension-base/utils'; @@ -16,10 +16,13 @@ import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/ import { assert, isNumber } from '@polkadot/util'; import RequestBytesSign from '../RequestBytesSign'; +import RequestBytesDecrypt from '../RequestBytesDecrypt'; import RequestExtrinsicSign from '../RequestExtrinsicSign'; import State from './State'; import { createSubscription, unsubscribe } from './subscriptions'; +import { convertPublicKeyToCurve25519 } from '@polkadot/util-crypto'; + function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAccount[] { return Object .values(accounts) @@ -80,6 +83,13 @@ export default class Tabs { return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta }); } + private bytesDecrypt (url: string, request: DecryptPayloadRaw): Promise { + const address = request.address; + const pair = this.getSigningPair(address); + + return this.#state.decrypt(url, new RequestBytesDecrypt(request), { address, ...pair.meta }); + } + private extrinsicSign (url: string, request: SignerPayloadJSON): Promise { const address = request.address; const pair = this.getSigningPair(address); @@ -191,6 +201,9 @@ export default class Tabs { case 'pub(bytes.sign)': return this.bytesSign(url, request as SignerPayloadRaw); + case 'pub(bytes.decrypt)': + return this.bytesDecrypt(url, request as DecryptPayloadRaw); + case 'pub(extrinsic.sign)': return this.extrinsicSign(url, request as SignerPayloadJSON); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index 4030d46b17..7299f40e79 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -6,9 +6,10 @@ import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@polkadot/extension-inject/types'; import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types'; import type { JsonRpcResponse } from '@polkadot/rpc-provider/types'; -import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; +import type { SignerPayloadJSON, SignerPayloadRaw, DecryptPayloadRaw } from '@polkadot/types/types'; import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types'; import type { KeypairType } from '@polkadot/util-crypto/types'; +import type { HexString } from '@polkadot/util/types'; import { TypeRegistry } from '@polkadot/types'; @@ -71,6 +72,14 @@ export interface SigningRequest { url: string; } +export interface DecryptingRequest { + account: AccountJson; + id: string; + request: RequestDecrypt; + url: string; +} + + // [MessageType]: [RequestType, ResponseType, SubscriptionMessageType?] export interface RequestSignatures { // private/internal requests, i.e. from a popup @@ -109,12 +118,17 @@ export interface RequestSignatures { 'pri(signing.cancel)': [RequestSigningCancel, boolean]; 'pri(signing.isLocked)': [RequestSigningIsLocked, ResponseSigningIsLocked]; 'pri(signing.requests)': [RequestSigningSubscribe, boolean, SigningRequest[]]; + 'pri(decrypting.requests)': [RequestDecryptingSubscribe, boolean, DecryptingRequest[]]; + 'pri(decrypting.approve)': [RequestDecryptingApprove, boolean]; + 'pri(decrypting.approve.password)': [RequestSigningApprovePassword, boolean]; + 'pri(decrypting.cancel)': [RequestDecryptingCancel, boolean]; 'pri(window.open)': [AllowedPath, boolean]; // public/external requests, i.e. from a page 'pub(accounts.list)': [RequestAccountList, InjectedAccount[]]; 'pub(accounts.subscribe)': [RequestAccountSubscribe, boolean, InjectedAccount[]]; 'pub(authorize.tab)': [RequestAuthorizeTab, null]; 'pub(bytes.sign)': [SignerPayloadRaw, ResponseSigning]; + 'pub(bytes.decrypt)': [DecryptPayloadRaw, ResponseDecrypting]; 'pub(extrinsic.sign)': [SignerPayloadJSON, ResponseSigning]; 'pub(metadata.list)': [null, InjectedMetadataKnown[]]; 'pub(metadata.provide)': [MetadataDef, boolean]; @@ -293,6 +307,25 @@ export interface ResponseSigningIsLocked { } export type RequestSigningSubscribe = null; +export type RequestDecryptingSubscribe = null; + +export interface RequestDecryptingApprove { + id: string; + decrypted: string; +} + +export interface RequestDecryptingApprovePassword { + id: string; + password?: string; + savePass: boolean; +} + + +export interface RequestDecryptingCancel { + id: string; +} + + export interface RequestSeedCreate { length?: SeedLengths; @@ -337,6 +370,12 @@ export interface ResponseSigning { signature: string; } +export interface ResponseDecrypting { + id: string; + decrypted: string; +} + + export interface ResponseDeriveValidate { address: string; suri: string; @@ -377,6 +416,12 @@ export interface RequestSign { sign (registry: TypeRegistry, pair: KeyringPair): { signature: string }; } +export interface RequestDecrypt { + readonly payload: DecryptPayloadRaw; + + decrypt (registry: TypeRegistry, pair: KeyringPair, recipientPublicKey: HexString | string | Uint8Array): { decrypted: string }; +} + export interface RequestJsonRestore { file: KeyringPair$Json; password: string; diff --git a/packages/extension-ui/src/Popup/Decrypting/Bytes.tsx b/packages/extension-ui/src/Popup/Decrypting/Bytes.tsx new file mode 100644 index 0000000000..a711f5ca8c --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Bytes.tsx @@ -0,0 +1,62 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import styled from 'styled-components'; + +import useTranslation from '../../hooks/useTranslation'; + +interface Props { + className?: string; + bytes: string; + url: string; +} + +function Bytes ({ bytes, className, url }: Props): React.ReactElement { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + +
{t('from')}{url}
{t('bytes')}{bytes}
+ ); +} + +export default styled(Bytes)` + border: 0; + display: block; + font-size: 0.75rem; + margin-top: 0.75rem; + + td.data { + max-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + vertical-align: middle; + width: 100%; + + pre { + font-family: inherit; + font-size: 0.75rem; + margin: 0; + } + } + + td.label { + opacity: 0.5; + padding: 0 0.5rem; + text-align: right; + vertical-align: middle; + white-space: nowrap; + } +`; diff --git a/packages/extension-ui/src/Popup/Decrypting/Extrinsic.tsx b/packages/extension-ui/src/Popup/Decrypting/Extrinsic.tsx new file mode 100644 index 0000000000..31a775c083 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Extrinsic.tsx @@ -0,0 +1,157 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Chain } from '@polkadot/extension-chains/types'; +import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces'; +import type { AnyJson, SignerPayloadJSON } from '@polkadot/types/types'; + +import BN from 'bn.js'; +import { TFunction } from 'i18next'; +import React, { useMemo, useRef } from 'react'; + +import { bnToBn, formatNumber } from '@polkadot/util'; + +import { Table } from '../../components'; +import useMetadata from '../../hooks/useMetadata'; +import useTranslation from '../../hooks/useTranslation'; + +interface Decoded { + args: AnyJson | null; + method: Call | null; +} + +interface Props { + className?: string; + payload: ExtrinsicPayload; + request: SignerPayloadJSON; + url: string; +} + +function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string { + return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`; +} + +function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded { + let args: AnyJson | null = null; + let method: Call | null = null; + + try { + if (specVersion.eqn(chain.specVersion)) { + method = chain.registry.createType('Call', data); + args = (method.toHuman() as { args: AnyJson }).args; + } else { + console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion)); + } + } catch (error) { + console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`); + + args = null; + method = null; + } + + return { args, method }; +} + +function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode { + if (!args || !method) { + return ( + + {t('method data')} + {data} + + ); + } + + return ( + <> + + {t('method')} + +
+ {method.section}.{method.method}{ + method.meta + ? `(${method.meta.args.map(({ name }) => name).join(', ')})` + : '' + } +
{JSON.stringify(args, null, 2)}
+
+ + + {method.meta && ( + + {t('info')} + +
+ {method.meta.docs.map((d) => d.toString().trim()).join(' ')} +
+ + + )} + + ); +} + +function mortalityAsString (era: ExtrinsicEra, hexBlockNumber: string, t: TFunction): string { + if (era.isImmortalEra) { + return t('immortal'); + } + + const blockNumber = bnToBn(hexBlockNumber); + const mortal = era.asMortalEra; + + return t('mortal, valid from {{birth}} to {{death}}', { + replace: { + birth: formatNumber(mortal.birth(blockNumber)), + death: formatNumber(mortal.death(blockNumber)) + } + }); +} + +function Extrinsic ({ className, payload: { era, nonce, tip }, request: { blockNumber, genesisHash, method, specVersion: hexSpec }, url }: Props): React.ReactElement { + const { t } = useTranslation(); + const chain = useMetadata(genesisHash); + const specVersion = useRef(bnToBn(hexSpec)).current; + const decoded = useMemo( + () => chain && chain.hasMetadata + ? decodeMethod(method, chain, specVersion) + : { args: null, method: null }, + [method, chain, specVersion] + ); + + return ( + + + + + + + + + + + + + + + + + + {!tip.isEmpty && ( + + + + + )} + {renderMethod(method, decoded, t)} + + + + +
{t('from')}{url}
{chain ? t('chain') : t('genesis')}{chain ? chain.name : genesisHash}
{t('version')}{specVersion.toNumber()}
{t('nonce')}{formatNumber(nonce)}
{t('tip')}{formatNumber(tip)}
{t('lifetime')}{mortalityAsString(era, blockNumber, t)}
+ ); +} + +export default React.memo(Extrinsic); diff --git a/packages/extension-ui/src/Popup/Decrypting/LedgerSign.tsx b/packages/extension-ui/src/Popup/Decrypting/LedgerSign.tsx new file mode 100644 index 0000000000..cce1a17506 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/LedgerSign.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ExtrinsicPayload } from '@polkadot/types/interfaces'; + +import { faSync } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { Button, Warning } from '../../components'; +import { useLedger } from '../../hooks/useLedger'; +import useTranslation from '../../hooks/useTranslation'; + +interface Props { + accountIndex?: number; + addressOffset?: number; + className?: string; + error: string | null; + genesisHash?: string; + onSignature?: ({ signature }: { signature: string }) => void; + payload?: ExtrinsicPayload; + setError: (value: string | null) => void; +} + +function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payload, setError }: Props): React.ReactElement { + const [isBusy, setIsBusy] = useState(false); + const { t } = useTranslation(); + const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset); + + useEffect(() => { + if (ledgerError) { + setError(ledgerError); + } + }, [ledgerError, setError]); + + const _onRefresh = useCallback(() => { + refresh(); + setError(null); + }, [refresh, setError]); + + const _onSignLedger = useCallback( + (): void => { + if (!ledger || !payload || !onSignature) { + return; + } + + setError(null); + setIsBusy(true); + ledger.sign(payload.toU8a(true), accountIndex, addressOffset) + .then((signature) => { + onSignature(signature); + }).catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + }, + [accountIndex, addressOffset, ledger, onSignature, payload, setError] + ); + + return ( +
+ {!!ledgerWarning && ( + + {ledgerWarning} + + )} + {error && ( + + {error} + + )} + {ledgerLocked + ? ( + + ) + : ( + + ) + } +
+ + ); +} + +export default styled(LedgerSign)` + flex-direction: column; + padding: 6px 24px; + + .danger { + margin-bottom: .5rem; + } +`; diff --git a/packages/extension-ui/src/Popup/Decrypting/Qr.tsx b/packages/extension-ui/src/Popup/Decrypting/Qr.tsx new file mode 100644 index 0000000000..f592454fa8 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Qr.tsx @@ -0,0 +1,102 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ExtrinsicPayload } from '@polkadot/types/interfaces'; + +import React, { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { wrapBytes } from '@polkadot/extension-dapp/wrapBytes'; +import { QrDisplayPayload, QrScanSignature } from '@polkadot/react-qr'; + +import { Button } from '../../components'; +import useTranslation from '../../hooks/useTranslation'; +import { CMD_MORTAL, CMD_SIGN_MESSAGE } from './Request'; + +interface Props { + address: string; + children?: React.ReactNode; + className?: string; + cmd: number; + genesisHash: string; + onSignature: ({ signature }: { signature: string }) => void; + payload: ExtrinsicPayload | string; + +} + +function Qr ({ address, className, cmd, genesisHash, onSignature, payload }: Props): React.ReactElement { + const { t } = useTranslation(); + const [isScanning, setIsScanning] = useState(false); + + const payloadU8a = useMemo( + () => { + switch (cmd) { + case CMD_MORTAL: + return (payload as ExtrinsicPayload).toU8a(); + case CMD_SIGN_MESSAGE: + return wrapBytes(payload as string); + default: + return null; + } + }, + [cmd, payload] + ); + + const _onShowQr = useCallback( + () => setIsScanning(true), + [] + ); + + if (!payloadU8a) { + return ( +
+
+ Transaction command:{cmd} not supported. +
+
+ ); + } + + return ( +
+
+ {isScanning + ? + : ( + + ) + } +
+ {!isScanning && ( + + )} +
+ ); +} + +export default styled(Qr)` + height: 100%; + + .qrContainer { + margin: 5px auto 10px auto; + width: 65%; + + img { + border: white solid 1px; + } + } + + .scanButton { + margin-bottom: 8px; + } +`; diff --git a/packages/extension-ui/src/Popup/Decrypting/Request/DecryptArea.tsx b/packages/extension-ui/src/Popup/Decrypting/Request/DecryptArea.tsx new file mode 100644 index 0000000000..0df7031c3e --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Request/DecryptArea.tsx @@ -0,0 +1,143 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { PASSWORD_EXPIRY_MIN } from '@polkadot/extension-base/defaults'; + +import { ActionBar, ActionContext, Button, ButtonArea, Checkbox, Link } from '../../../components'; +import useTranslation from '../../../hooks/useTranslation'; +import { approveDecryptPassword, cancelDecryptRequest, isSignLocked } from '../../../messaging'; +import Unlock from '../Unlock'; + +interface Props { + buttonText: string; + className?: string; + error: string | null; + isExternal?: boolean; + isFirst: boolean; + setError: (value: string | null) => void; + decryptId: string; +} + +function DecryptArea ({ buttonText, className, error, isExternal, isFirst, setError, decryptId }: Props): JSX.Element { + const [savePass, setSavePass] = useState(false); + const [isLocked, setIsLocked] = useState(null); + const [password, setPassword] = useState(''); + const [isBusy, setIsBusy] = useState(false); + const onAction = useContext(ActionContext); + const { t } = useTranslation(); + + useEffect(() => { + setIsLocked(null); + let timeout: NodeJS.Timeout; + + !isExternal && isSignLocked(decryptId) + .then(({ isLocked, remainingTime }) => { + setIsLocked(isLocked); + timeout = setTimeout(() => { + setIsLocked(true); + }, remainingTime); + + // if the account was unlocked check the remember me + // automatically to prolong the unlock period + !isLocked && setSavePass(true); + }) + .catch((error: Error) => console.error(error)); + + return () => { !!timeout && clearTimeout(timeout); }; + }, [isExternal, decryptId]); + + const _onDecrypt = useCallback( + (): Promise => { + setIsBusy(true); + + return approveDecryptPassword(decryptId, savePass, password) + .then((): void => { + setIsBusy(false); + onAction(); + }) + .catch((error: Error): void => { + setIsBusy(false); + setError(error.message); + console.error(error); + }); + }, + [onAction, password, savePass, setError, setIsBusy, decryptId] + ); + + const _onCancel = useCallback( + (): Promise => cancelDecryptRequest(decryptId) + .then(() => onAction()) + .catch((error: Error) => console.error(error)), + [onAction, decryptId] + ); + + const RememberPasswordCheckbox = () => ( + ( + 'Remember my password for the next {{expiration}} minutes', + { replace: { expiration: PASSWORD_EXPIRY_MIN } } + ) + : t( + 'Extend the period without password by {{expiration}} minutes', + { replace: { expiration: PASSWORD_EXPIRY_MIN } } + ) + } + onChange={setSavePass} + /> + ); + + return ( + + {isFirst && !isExternal && ( + <> + { isLocked && ( + + )} + + + + )} + + + {t('Cancel')} + + + + ); +} + +export default styled(DecryptArea)` + flex-direction: column; + padding: 6px 24px; + + .cancelButton { + margin-top: 4px; + margin-bottom: 4px; + text-decoration: underline; + + a { + margin: auto; + } + } +`; diff --git a/packages/extension-ui/src/Popup/Decrypting/Request/index.tsx b/packages/extension-ui/src/Popup/Decrypting/Request/index.tsx new file mode 100644 index 0000000000..7ebb61d050 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Request/index.tsx @@ -0,0 +1,110 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountJson, RequestDecrypt } from '@polkadot/extension-base/background/types'; +import type { ExtrinsicPayload } from '@polkadot/types/interfaces'; +import type { DecryptPayloadRaw } from '@polkadot/types/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +// import { TypeRegistry } from '@polkadot/types'; +import { decodeAddress } from '@polkadot/util-crypto'; + +import { AccountContext, ActionContext, Address, VerticalSpace } from '../../../components'; +import { approveDecrypting } from '../../../messaging'; +import Bytes from '../Bytes'; +// import Extrinsic from '../Extrinsic'; +// import LedgerSign from '../LedgerSign'; +import Qr from '../Qr'; +import DecryptArea from './DecryptArea'; + +interface Props { + account: AccountJson; + buttonText: string; + isFirst: boolean; + request: RequestDecrypt; + decryptId: string; + url: string; +} + +interface Data { + hexBytes: string | null; + payload: ExtrinsicPayload | null; +} + +export const CMD_MORTAL = 2; +export const CMD_SIGN_MESSAGE = 3; + +// keep it global, we can and will re-use this across requests +// const registry = new TypeRegistry(); + +export default function Request ({ account: { accountIndex, addressOffset, isExternal, isHardware }, buttonText, isFirst, request, decryptId, url }: Props): React.ReactElement | null { + const onAction = useContext(ActionContext); + const [{ hexBytes }, setData] = useState({ hexBytes: null, payload: null }); + const [error, setError] = useState(null); + const { accounts } = useContext(AccountContext); + + useEffect((): void => { + const payload = request.payload; + + setData({ + hexBytes: payload.data, + payload: null + }); + }, [request]); + + const _onSignature = useCallback( + ({ signature }: { signature: string }): Promise => + approveDecrypting(decryptId, signature) + .then(() => onAction()) + .catch((error: Error): void => { + setError(error.message); + console.error(error); + }), + [onAction, decryptId] + ); + +if (hexBytes !== null) { + const { address, data } = request.payload as DecryptPayloadRaw; + const account = accounts.find((account) => decodeAddress(account.address).toString() === decodeAddress(address).toString()); + + return ( + <> +
+
+
+ {isExternal && !isHardware && account?.genesisHash + ? ( + + ) + : ( + + ) + } + + + + ); + } + + return null; +} diff --git a/packages/extension-ui/src/Popup/Decrypting/Signing.test.tsx b/packages/extension-ui/src/Popup/Decrypting/Signing.test.tsx new file mode 100644 index 0000000000..04e549e364 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Signing.test.tsx @@ -0,0 +1,342 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '../../../../../__mocks__/chrome'; + +import type { SigningRequest } from '@polkadot/extension-base/background/types'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import { configure, mount, ReactWrapper } from 'enzyme'; +import { EventEmitter } from 'events'; +import React, { useState } from 'react'; +import { act } from 'react-dom/test-utils'; +import { ThemeProvider } from 'styled-components'; + +import { ActionContext, Address, Button, Input, SigningReqContext, themes } from '../../components'; +import * as messaging from '../../messaging'; +import * as MetadataCache from '../../MetadataCache'; +import { flushAllPromises } from '../../testHelpers'; +import Extrinsic from './Extrinsic'; +import { westendMetadata } from './metadataMock'; +import Qr from './Qr'; +import Request from './Request'; +import TransactionIndex from './TransactionIndex'; +import Signing from '.'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +describe('Signing requests', () => { + let wrapper: ReactWrapper; + let onActionStub: jest.Mock; + let signRequests: SigningRequest[] = []; + + const emitter = new EventEmitter(); + + function MockRequestsProvider (): React.ReactElement { + const [requests, setRequests] = useState(signRequests); + + emitter.on('request', setRequests); + + return ( + + + + ); + } + + const mountComponent = async (): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + wrapper = mount( + + + + + + ); + await act(flushAllPromises); + wrapper.update(); + }; + + const check = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: true } }); + + beforeEach(async () => { + jest.spyOn(messaging, 'cancelSignRequest').mockResolvedValue(true); + jest.spyOn(messaging, 'approveSignPassword').mockResolvedValue(true); + jest.spyOn(messaging, 'isSignLocked').mockResolvedValue({ isLocked: true, remainingTime: 0 }); + jest.spyOn(MetadataCache, 'getSavedMeta').mockResolvedValue(westendMetadata); + + signRequests = [ + { + account: { + address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5', + genesisHash: null, + isHidden: false, + name: 'acc1', + parentAddress: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + suri: '//0', + whenCreated: 1602001346486 + }, + id: '1607347015530.2', + request: { + payload: { + address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5', + blockHash: '0x661f57d206d4fecda0408943427d4d25436518acbff543735e7569da9db6bdd7', + blockNumber: '0x0033fa6b', + era: '0xb502', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + method: '0x0403c6111b239376e5e8b983dc2d2459cbb6caed64cc1d21723973d061ae0861ef690b00b04e2bde6f', + nonce: '0x00000003', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x0000002d', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000003', + version: 4 + }, + sign: jest.fn() + }, + url: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts' + }, + { + account: { + address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + genesisHash: null, + isHidden: false, + name: 'acc 2', + suri: '//0', + whenCreated: 1602001346486 + }, + id: '1607356155395.3', + request: { + payload: { + address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', + blockHash: '0xcf69b7935b785f90b22d2b36f2227132ef9c5dd33db1dbac9ecdafac05bf9476', + blockNumber: '0x0036269a', + era: '0xa501', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + method: '0x0400cc4e0e2848c488896dd0a24f153070e85e3c83f6199cfc942ab6de29c56c2d7b0700d0ed902e', + nonce: '0x00000003', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x0000002d', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000003', + version: 4 + }, + sign: jest.fn() + }, + url: 'https://polkadot.js.org/apps' + } + ]; + onActionStub = jest.fn(); + await mountComponent(); + }); + + describe('Switching between requests', () => { + it('initially first request should be shown', () => { + expect(wrapper.find(TransactionIndex).text()).toBe('1/2'); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id); + }); + + it('only the right arrow should be active on first screen', async () => { + expect(wrapper.find('FontAwesomeIcon.arrowLeft')).toHaveLength(1); + expect(wrapper.find('FontAwesomeIcon.arrowLeft.active')).toHaveLength(0); + expect(wrapper.find('FontAwesomeIcon.arrowRight.active')).toHaveLength(1); + wrapper.find('FontAwesomeIcon.arrowLeft').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(TransactionIndex).text()).toBe('1/2'); + }); + + it('should display second request after clicking right arrow', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(TransactionIndex).text()).toBe('2/2'); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[1].id); + }); + + it('only the left should be active on second screen', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find('FontAwesomeIcon.arrowLeft.active')).toHaveLength(1); + expect(wrapper.find('FontAwesomeIcon.arrowRight')).toHaveLength(1); + expect(wrapper.find('FontAwesomeIcon.arrowRight.active')).toHaveLength(0); + expect(wrapper.find(TransactionIndex).text()).toBe('2/2'); + }); + + it('should display previous request after the left arrow has been clicked', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + wrapper.find('FontAwesomeIcon.arrowLeft').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(TransactionIndex).text()).toBe('1/2'); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id); + }); + }); + + describe('External account', () => { + it('shows Qr scanner for external accounts', async () => { + signRequests = [{ + account: { + address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe', + genesisHash: null, + isExternal: true, + isHidden: false, + name: 'Dave account on Signer ', + whenCreated: 1602085704296 + }, + id: '1607357806151.5', + request: { + payload: { + address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe', + blockHash: '0xd2f2dfb56c16af1d0faf5b454153d3199aeb6647537f4161c26a34541c591ec8', + blockNumber: '0x00340171', + era: '0x1503', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + method: '0x0403c6111b239376e5e8b983dc2d2459cbb6caed64cc1d21723973d061ae0861ef690b00b04e2bde6f', + nonce: '0x00000000', + signedExtensions: [ + 'CheckSpecVersion', + 'CheckTxVersion', + 'CheckGenesis', + 'CheckMortality', + 'CheckNonce', + 'CheckWeight', + 'ChargeTransactionPayment' + ], + specVersion: '0x0000002d', + tip: '0x00000000000000000000000000000000', + transactionVersion: '0x00000003', + version: 4 + }, + sign: jest.fn() + }, + url: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts' + }]; + await mountComponent(); + expect(wrapper.find(Extrinsic)).toHaveLength(0); + expect(wrapper.find(Qr)).toHaveLength(1); + }); + }); + + describe('Request rendering', () => { + it('correctly displays request 1', () => { + expect(wrapper.find(Address).find('.fullAddress').text()).toBe(signRequests[0].account.address); + expect(wrapper.find(Extrinsic).find('td.data').map((el): string => el.text())).toEqual([ + 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts', + 'Westend', + '45', + '3', + `balances.transferKeepAlive(dest, value)[ + "5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7", + "123.0000 WND" +]`, + 'Same as the [`transfer`] call, but with a check that the transfer will not kill the origin account.', + 'mortal, valid from {{birth}} to {{death}}' + ]); + }); + + it('correctly displays request 2', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + expect(wrapper.find(Address).find('.fullAddress').text()).toBe(signRequests[1].account.address); + expect(wrapper.find(Extrinsic).find('td.data').map((el): string => el.text())).toEqual([ + 'https://polkadot.js.org/apps', + 'Westend', + '45', + '3', + `balances.transfer(dest, value)[ + "5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q", + "200.0000 mWND" +]`, + 'Transfer some liquid free balance to another account.', + 'mortal, valid from {{birth}} to {{death}}' + ]); + }); + }); + + describe('Submitting', () => { + it('passes request id to cancel call', async () => { + wrapper.find('.cancelButton').find('a').simulate('click'); + await act(flushAllPromises); + + expect(messaging.cancelSignRequest).toBeCalledWith(signRequests[0].id); + }); + + it('passes request id and password to approve call', async () => { + wrapper.find(Input).simulate('change', { target: { value: 'hunter1' } }); + await act(flushAllPromises); + + wrapper.find(Button).find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(messaging.approveSignPassword).toBeCalledWith(signRequests[0].id, false, 'hunter1'); + }); + + it('asks the background to cache the password when the relevant checkbox is checked', async () => { + check(wrapper.find('input[type="checkbox"]')); + await act(flushAllPromises); + + wrapper.find(Input).simulate('change', { target: { value: 'hunter1' } }); + await act(flushAllPromises); + + wrapper.find(Button).find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(messaging.approveSignPassword).toBeCalledWith(signRequests[0].id, true, 'hunter1'); + }); + + it('shows an error when the password is wrong', async () => { + // silencing the following expected console.error + console.error = jest.fn(); + // eslint-disable-next-line @typescript-eslint/require-await + jest.spyOn(messaging, 'approveSignPassword').mockImplementation(async () => { + throw new Error('Unable to decode using the supplied passphrase'); + }); + + wrapper.find(Input).simulate('change', { target: { value: 'anything' } }); + await act(flushAllPromises); + + wrapper.find(Button).find('button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(wrapper.find('.warning-message').first().text()).toBe('Unable to decode using the supplied passphrase'); + }); + + it('when last request has been removed/cancelled, shows the previous one', async () => { + wrapper.find('FontAwesomeIcon.arrowRight').simulate('click'); + await act(flushAllPromises); + + act(() => { + emitter.emit('request', [signRequests[0]]); + }); + await act(flushAllPromises); + wrapper.update(); + + expect(wrapper.find(TransactionIndex)).toHaveLength(0); + expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id); + }); + }); +}); diff --git a/packages/extension-ui/src/Popup/Decrypting/TransactionIndex.tsx b/packages/extension-ui/src/Popup/Decrypting/TransactionIndex.tsx new file mode 100644 index 0000000000..9d30792708 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/TransactionIndex.tsx @@ -0,0 +1,94 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ThemeProps } from '../../types'; + +import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback } from 'react'; +import styled from 'styled-components'; + +interface Props { + className?: string; + index: number; + totalItems: number; + onNextClick: () => void; + onPreviousClick: () => void; +} + +function TransactionIndex ({ className, index, onNextClick, onPreviousClick, totalItems }: Props): React.ReactElement { + const previousClickActive = index !== 0; + const nextClickActive = index < totalItems - 1; + + const prevClick = useCallback( + (): void => { + previousClickActive && onPreviousClick(); + }, + [onPreviousClick, previousClickActive] + ); + + const nextClick = useCallback( + (): void => { + nextClickActive && onNextClick(); + }, + [nextClickActive, onNextClick] + ); + + return ( +
+
+ {index + 1} + /{totalItems} +
+
+ + +
+
+ ); +} + +export default styled(TransactionIndex)(({ theme }: ThemeProps) => ` + align-items: center; + display: flex; + justify-content: space-between; + flex-grow: 1; + padding-right: 24px; + + .arrowLeft, .arrowRight { + display: inline-block; + color: ${theme.iconNeutralColor}; + + &.active { + color: ${theme.primaryColor}; + cursor: pointer; + } + } + + .arrowRight { + margin-left: 0.5rem; + } + + .currentStep { + color: ${theme.primaryColor}; + font-size: ${theme.labelFontSize}; + line-height: ${theme.labelLineHeight}; + margin-left: 10px; + } + + .totalSteps { + font-size: ${theme.labelFontSize}; + line-height: ${theme.labelLineHeight}; + color: ${theme.textColor}; + } +`); diff --git a/packages/extension-ui/src/Popup/Decrypting/Unlock.tsx b/packages/extension-ui/src/Popup/Decrypting/Unlock.tsx new file mode 100644 index 0000000000..3dfba0676d --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/Unlock.tsx @@ -0,0 +1,55 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback } from 'react'; + +import { InputWithLabel, Warning } from '../../components'; +import useTranslation from '../../hooks/useTranslation'; + +interface Props { + className?: string; + error?: string | null; + isBusy: boolean; + onSign: () => Promise; + password: string; + setError: (error: string | null) => void; + setPassword: (password: string) => void; +} + +function Unlock ({ className, error, isBusy, onSign, password, setError, setPassword }: Props): React.ReactElement { + const { t } = useTranslation(); + + const _onChangePassword = useCallback( + (password: string): void => { + setPassword(password); + setError(null); + }, + [setError, setPassword] + ); + + return ( +
+ ('Password for this account')} + onChange={_onChangePassword} + onEnter={onSign} + type='password' + value={password} + withoutMargin={true} + /> + {error && ( + + {error} + + )} +
+ ); +} + +export default React.memo(Unlock); diff --git a/packages/extension-ui/src/Popup/Decrypting/index.tsx b/packages/extension-ui/src/Popup/Decrypting/index.tsx new file mode 100644 index 0000000000..1e031670f0 --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/index.tsx @@ -0,0 +1,70 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// import type { DecryptPayloadRaw } from '@polkadot/types/types'; + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { Loading, DecryptingReqContext } from '../../components'; +import useTranslation from '../../hooks/useTranslation'; +import { Header } from '../../partials'; +import Request from './Request'; +import TransactionIndex from './TransactionIndex'; + +export default function Decrypting (): React.ReactElement { + const { t } = useTranslation(); + const requests = useContext(DecryptingReqContext); + const [requestIndex, setRequestIndex] = useState(0); + + const _onNextClick = useCallback( + () => setRequestIndex((requestIndex) => requestIndex + 1), + [] + ); + + const _onPreviousClick = useCallback( + () => setRequestIndex((requestIndex) => requestIndex - 1), + [] + ); + + useEffect(() => { + setRequestIndex( + (requestIndex) => requestIndex < requests.length + ? requestIndex + : requests.length - 1 + ); + }, [requests]); + + // protect against removal overflows/underflows + const request = requests.length !== 0 + ? requestIndex >= 0 + ? requestIndex < requests.length + ? requests[requestIndex] + : requests[requests.length - 1] + : requests[0] + : null; + + return request + ? ( + <> +
('Decrypt message')}> + {requests.length > 1 && ( + + )} +
+ + + ) + : ; +} diff --git a/packages/extension-ui/src/Popup/Decrypting/metadataMock.ts b/packages/extension-ui/src/Popup/Decrypting/metadataMock.ts new file mode 100644 index 0000000000..a6e09dbd6c --- /dev/null +++ b/packages/extension-ui/src/Popup/Decrypting/metadataMock.ts @@ -0,0 +1,30 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { MetadataDef } from '@polkadot/extension-inject/types'; + +export const westendMetadata = { + chain: 'Westend', + color: '#da68a7', + genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e', + icon: 'polkadot', + metaCalls: 'bWV0YQxgGFN5c3RlbQABKChmaWxsX2Jsb2NrBBhfcmF0aW8cUGVyYmlsbAQFAUEgZGlzcGF0Y2ggdGhhdCB3aWxsIGZpbGwgdGhlIGJsb2NrIHdlaWdodCB1cCB0byB0aGUgZ2l2ZW4gcmF0aW8uGHJlbWFyawQcX3JlbWFyaxRCeXRlcwRoTWFrZSBzb21lIG9uLWNoYWluIHJlbWFyay44c2V0X2hlYXBfcGFnZXMEFHBhZ2VzDHU2NAT4U2V0IHRoZSBudW1iZXIgb2YgcGFnZXMgaW4gdGhlIFdlYkFzc2VtYmx5IGVudmlyb25tZW50J3MgaGVhcC4gc2V0X2NvZGUEEGNvZGUUQnl0ZXMEZFNldCB0aGUgbmV3IHJ1bnRpbWUgY29kZS5cc2V0X2NvZGVfd2l0aG91dF9jaGVja3MEEGNvZGUUQnl0ZXMEGQFTZXQgdGhlIG5ldyBydW50aW1lIGNvZGUgd2l0aG91dCBkb2luZyBhbnkgY2hlY2tzIG9mIHRoZSBnaXZlbiBgY29kZWAuXHNldF9jaGFuZ2VzX3RyaWVfY29uZmlnBExjaGFuZ2VzX3RyaWVfY29uZmlngE9wdGlvbjxDaGFuZ2VzVHJpZUNvbmZpZ3VyYXRpb24+BJxTZXQgdGhlIG5ldyBjaGFuZ2VzIHRyaWUgY29uZmlndXJhdGlvbi4sc2V0X3N0b3JhZ2UEFGl0ZW1zNFZlYzxLZXlWYWx1ZT4EaFNldCBzb21lIGl0ZW1zIG9mIHN0b3JhZ2UuMGtpbGxfc3RvcmFnZQQQa2V5cyBWZWM8S2V5PgR0S2lsbCBzb21lIGl0ZW1zIGZyb20gc3RvcmFnZS4sa2lsbF9wcmVmaXgIGHByZWZpeAxLZXkgX3N1YmtleXMMdTMyBBEBS2lsbCBhbGwgc3RvcmFnZSBpdGVtcyB3aXRoIGEga2V5IHRoYXQgc3RhcnRzIHdpdGggdGhlIGdpdmVuIHByZWZpeC4cc3VpY2lkZQAIYQFLaWxsIHRoZSBzZW5kaW5nIGFjY291bnQsIGFzc3VtaW5nIHRoZXJlIGFyZSBubyByZWZlcmVuY2VzIG91dHN0YW5kaW5nIGFuZCB0aGUgY29tcG9zaXRljGRhdGEgaXMgZXF1YWwgdG8gaXRzIGRlZmF1bHQgdmFsdWUuAAAAAGBSYW5kb21uZXNzQ29sbGVjdGl2ZUZsaXAAAAAAABkQQmFiZQABCExyZXBvcnRfZXF1aXZvY2F0aW9uCEhlcXVpdm9jYXRpb25fcHJvb2ZUQmFiZUVxdWl2b2NhdGlvblByb29mPGtleV9vd25lcl9wcm9vZjRLZXlPd25lclByb29mEAkBUmVwb3J0IGF1dGhvcml0eSBlcXVpdm9jYXRpb24vbWlzYmVoYXZpb3IuIFRoaXMgbWV0aG9kIHdpbGwgdmVyaWZ5BQF0aGUgZXF1aXZvY2F0aW9uIHByb29mIGFuZCB2YWxpZGF0ZSB0aGUgZ2l2ZW4ga2V5IG93bmVyc2hpcCBwcm9vZg0BYWdhaW5zdCB0aGUgZXh0cmFjdGVkIG9mZmVuZGVyLiBJZiBib3RoIGFyZSB2YWxpZCwgdGhlIG9mZmVuY2Ugd2lsbDBiZSByZXBvcnRlZC5wcmVwb3J0X2VxdWl2b2NhdGlvbl91bnNpZ25lZAhIZXF1aXZvY2F0aW9uX3Byb29mVEJhYmVFcXVpdm9jYXRpb25Qcm9vZjxrZXlfb3duZXJfcHJvb2Y0S2V5T3duZXJQcm9vZiAJAVJlcG9ydCBhdXRob3JpdHkgZXF1aXZvY2F0aW9uL21pc2JlaGF2aW9yLiBUaGlzIG1ldGhvZCB3aWxsIHZlcmlmeQUBdGhlIGVxdWl2b2NhdGlvbiBwcm9vZiBhbmQgdmFsaWRhdGUgdGhlIGdpdmVuIGtleSBvd25lcnNoaXAgcHJvb2YNAWFnYWluc3QgdGhlIGV4dHJhY3RlZCBvZmZlbmRlci4gSWYgYm90aCBhcmUgdmFsaWQsIHRoZSBvZmZlbmNlIHdpbGwwYmUgcmVwb3J0ZWQuDQFUaGlzIGV4dHJpbnNpYyBtdXN0IGJlIGNhbGxlZCB1bnNpZ25lZCBhbmQgaXQgaXMgZXhwZWN0ZWQgdGhhdCBvbmx5FQFibG9jayBhdXRob3JzIHdpbGwgY2FsbCBpdCAodmFsaWRhdGVkIGluIGBWYWxpZGF0ZVVuc2lnbmVkYCksIGFzIHN1Y2gVAWlmIHRoZSBibG9jayBhdXRob3IgaXMgZGVmaW5lZCBpdCB3aWxsIGJlIGRlZmluZWQgYXMgdGhlIGVxdWl2b2NhdGlvbiRyZXBvcnRlci4AAAABJFRpbWVzdGFtcAABBAxzZXQEDG5vdzxDb21wYWN0PE1vbWVudD4EVFNldCB0aGUgY3VycmVudCB0aW1lLgAAAAIcSW5kaWNlcwABFBRjbGFpbQQUaW5kZXgwQWNjb3VudEluZGV4BJhBc3NpZ24gYW4gcHJldmlvdXNseSB1bmFzc2lnbmVkIGluZGV4LiB0cmFuc2ZlcggMbmV3JEFjY291bnRJZBRpbmRleDBBY2NvdW50SW5kZXgIXQFBc3NpZ24gYW4gaW5kZXggYWxyZWFkeSBvd25lZCBieSB0aGUgc2VuZGVyIHRvIGFub3RoZXIgYWNjb3VudC4gVGhlIGJhbGFuY2UgcmVzZXJ2YXRpb264aXMgZWZmZWN0aXZlbHkgdHJhbnNmZXJyZWQgdG8gdGhlIG5ldyBhY2NvdW50LhBmcmVlBBRpbmRleDBBY2NvdW50SW5kZXgElEZyZWUgdXAgYW4gaW5kZXggb3duZWQgYnkgdGhlIHNlbmRlci44Zm9yY2VfdHJhbnNmZXIMDG5ldyRBY2NvdW50SWQUaW5kZXgwQWNjb3VudEluZGV4GGZyZWV6ZRBib29sCFUBRm9yY2UgYW4gaW5kZXggdG8gYW4gYWNjb3VudC4gVGhpcyBkb2Vzbid0IHJlcXVpcmUgYSBkZXBvc2l0LiBJZiB0aGUgaW5kZXggaXMgYWxyZWFkeehoZWxkLCB0aGVuIGFueSBkZXBvc2l0IGlzIHJlaW1idXJzZWQgdG8gaXRzIGN1cnJlbnQgb3duZXIuGGZyZWV6ZQQUaW5kZXgwQWNjb3VudEluZGV4BGUBRnJlZXplIGFuIGluZGV4IHNvIGl0IHdpbGwgYWx3YXlzIHBvaW50IHRvIHRoZSBzZW5kZXIgYWNjb3VudC4gVGhpcyBjb25zdW1lcyB0aGUgZGVwb3NpdC4AAAADIEJhbGFuY2VzAAEQIHRyYW5zZmVyCBBkZXN0MExvb2t1cFNvdXJjZRR2YWx1ZUBDb21wYWN0PEJhbGFuY2U+BNRUcmFuc2ZlciBzb21lIGxpcXVpZCBmcmVlIGJhbGFuY2UgdG8gYW5vdGhlciBhY2NvdW50LixzZXRfYmFsYW5jZQwMd2hvMExvb2t1cFNvdXJjZSBuZXdfZnJlZUBDb21wYWN0PEJhbGFuY2U+MG5ld19yZXNlcnZlZEBDb21wYWN0PEJhbGFuY2U+BJBTZXQgdGhlIGJhbGFuY2VzIG9mIGEgZ2l2ZW4gYWNjb3VudC44Zm9yY2VfdHJhbnNmZXIMGHNvdXJjZTBMb29rdXBTb3VyY2UQZGVzdDBMb29rdXBTb3VyY2UUdmFsdWVAQ29tcGFjdDxCYWxhbmNlPhhNAUV4YWN0bHkgYXMgYHRyYW5zZmVyYCwgZXhjZXB0IHRoZSBvcmlnaW4gbXVzdCBiZSByb290IGFuZCB0aGUgc291cmNlIGFjY291bnQgbWF5IGJlKHNwZWNpZmllZC4oIyA8d2VpZ2h0Pj0BLSBTYW1lIGFzIHRyYW5zZmVyLCBidXQgYWRkaXRpb25hbCByZWFkIGFuZCB3cml0ZSBiZWNhdXNlIHRoZSBzb3VyY2UgYWNjb3VudCBpc4Rub3QgYXNzdW1lZCB0byBiZSBpbiB0aGUgb3ZlcmxheS4sIyA8L3dlaWdodD5MdHJhbnNmZXJfa2VlcF9hbGl2ZQgQZGVzdDBMb29rdXBTb3VyY2UUdmFsdWVAQ29tcGFjdDxCYWxhbmNlPghNAVNhbWUgYXMgdGhlIFtgdHJhbnNmZXJgXSBjYWxsLCBidXQgd2l0aCBhIGNoZWNrIHRoYXQgdGhlIHRyYW5zZmVyIHdpbGwgbm90IGtpbGwgdGhlPG9yaWdpbiBhY2NvdW50LgAAAARIVHJhbnNhY3Rpb25QYXltZW50AAAAAAAaKEF1dGhvcnNoaXAAAQQoc2V0X3VuY2xlcwQobmV3X3VuY2xlcyxWZWM8SGVhZGVyPgRgUHJvdmlkZSBhIHNldCBvZiB1bmNsZXMuAAAABRxTdGFraW5nAAFgEGJvbmQMKGNvbnRyb2xsZXIwTG9va3VwU291cmNlFHZhbHVlSENvbXBhY3Q8QmFsYW5jZU9mPhRwYXllZURSZXdhcmREZXN0aW5hdGlvbghhAVRha2UgdGhlIG9yaWdpbiBhY2NvdW50IGFzIGEgc3Rhc2ggYW5kIGxvY2sgdXAgYHZhbHVlYCBvZiBpdHMgYmFsYW5jZS4gYGNvbnRyb2xsZXJgIHdpbGyAYmUgdGhlIGFjY291bnQgdGhhdCBjb250cm9scyBpdC4oYm9uZF9leHRyYQQ4bWF4X2FkZGl0aW9uYWxIQ29tcGFjdDxCYWxhbmNlT2Y+CGEBQWRkIHNvbWUgZXh0cmEgYW1vdW50IHRoYXQgaGF2ZSBhcHBlYXJlZCBpbiB0aGUgc3Rhc2ggYGZyZWVfYmFsYW5jZWAgaW50byB0aGUgYmFsYW5jZSB1cDBmb3Igc3Rha2luZy4YdW5ib25kBBR2YWx1ZUhDb21wYWN0PEJhbGFuY2VPZj4MUQFTY2hlZHVsZSBhIHBvcnRpb24gb2YgdGhlIHN0YXNoIHRvIGJlIHVubG9ja2VkIHJlYWR5IGZvciB0cmFuc2ZlciBvdXQgYWZ0ZXIgdGhlIGJvbmT8cGVyaW9kIGVuZHMuIElmIHRoaXMgbGVhdmVzIGFuIGFtb3VudCBhY3RpdmVseSBib25kZWQgbGVzcyB0aGFuIQFUOjpDdXJyZW5jeTo6bWluaW11bV9iYWxhbmNlKCksIHRoZW4gaXQgaXMgaW5jcmVhc2VkIHRvIHRoZSBmdWxsIGFtb3VudC5Ed2l0aGRyYXdfdW5ib25kZWQESG51bV9zbGFzaGluZ19zcGFucwx1MzIEKQFSZW1vdmUgYW55IHVubG9ja2VkIGNodW5rcyBmcm9tIHRoZSBgdW5sb2NraW5nYCBxdWV1ZSBmcm9tIG91ciBtYW5hZ2VtZW50LiB2YWxpZGF0ZQQUcHJlZnM4VmFsaWRhdG9yUHJlZnME5ERlY2xhcmUgdGhlIGRlc2lyZSB0byB2YWxpZGF0ZSBmb3IgdGhlIG9yaWdpbiBjb250cm9sbGVyLiBub21pbmF0ZQQcdGFyZ2V0c0RWZWM8TG9va3VwU291cmNlPgQNAURlY2xhcmUgdGhlIGRlc2lyZSB0byBub21pbmF0ZSBgdGFyZ2V0c2AgZm9yIHRoZSBvcmlnaW4gY29udHJvbGxlci4UY2hpbGwABMREZWNsYXJlIG5vIGRlc2lyZSB0byBlaXRoZXIgdmFsaWRhdGUgb3Igbm9taW5hdGUuJHNldF9wYXllZQQUcGF5ZWVEUmV3YXJkRGVzdGluYXRpb24EtChSZS0pc2V0IHRoZSBwYXltZW50IHRhcmdldCBmb3IgYSBjb250cm9sbGVyLjhzZXRfY29udHJvbGxlcgQoY29udHJvbGxlcjBMb29rdXBTb3VyY2UEjChSZS0pc2V0IHRoZSBjb250cm9sbGVyIG9mIGEgc3Rhc2guTHNldF92YWxpZGF0b3JfY291bnQEDG5ldzBDb21wYWN0PHUzMj4EkFNldHMgdGhlIGlkZWFsIG51bWJlciBvZiB2YWxpZGF0b3JzLmBpbmNyZWFzZV92YWxpZGF0b3JfY291bnQEKGFkZGl0aW9uYWwwQ29tcGFjdDx1MzI+BKhJbmNyZW1lbnRzIHRoZSBpZGVhbCBudW1iZXIgb2YgdmFsaWRhdG9ycy5Uc2NhbGVfdmFsaWRhdG9yX2NvdW50BBhmYWN0b3IcUGVyY2VudATQU2NhbGUgdXAgdGhlIGlkZWFsIG51bWJlciBvZiB2YWxpZGF0b3JzIGJ5IGEgZmFjdG9yLjRmb3JjZV9ub19lcmFzAASsRm9yY2UgdGhlcmUgdG8gYmUgbm8gbmV3IGVyYXMgaW5kZWZpbml0ZWx5LjRmb3JjZV9uZXdfZXJhAAhJAUZvcmNlIHRoZXJlIHRvIGJlIGEgbmV3IGVyYSBhdCB0aGUgZW5kIG9mIHRoZSBuZXh0IHNlc3Npb24uIEFmdGVyIHRoaXMsIGl0IHdpbGwgYmWccmVzZXQgdG8gbm9ybWFsIChub24tZm9yY2VkKSBiZWhhdmlvdXIuRHNldF9pbnZ1bG5lcmFibGVzBDRpbnZ1bG5lcmFibGVzOFZlYzxBY2NvdW50SWQ+BMhTZXQgdGhlIHZhbGlkYXRvcnMgd2hvIGNhbm5vdCBiZSBzbGFzaGVkIChpZiBhbnkpLjRmb3JjZV91bnN0YWtlCBRzdGFzaCRBY2NvdW50SWRIbnVtX3NsYXNoaW5nX3NwYW5zDHUzMgQJAUZvcmNlIGEgY3VycmVudCBzdGFrZXIgdG8gYmVjb21lIGNvbXBsZXRlbHkgdW5zdGFrZWQsIGltbWVkaWF0ZWx5LlBmb3JjZV9uZXdfZXJhX2Fsd2F5cwAEAQFGb3JjZSB0aGVyZSB0byBiZSBhIG5ldyBlcmEgYXQgdGhlIGVuZCBvZiBzZXNzaW9ucyBpbmRlZmluaXRlbHkuVGNhbmNlbF9kZWZlcnJlZF9zbGFzaAgMZXJhIEVyYUluZGV4NHNsYXNoX2luZGljZXMgVmVjPHUzMj4ElENhbmNlbCBlbmFjdG1lbnQgb2YgYSBkZWZlcnJlZCBzbGFzaC44cGF5b3V0X3N0YWtlcnMIPHZhbGlkYXRvcl9zdGFzaCRBY2NvdW50SWQMZXJhIEVyYUluZGV4BA0BUGF5IG91dCBhbGwgdGhlIHN0YWtlcnMgYmVoaW5kIGEgc2luZ2xlIHZhbGlkYXRvciBmb3IgYSBzaW5nbGUgZXJhLhhyZWJvbmQEFHZhbHVlSENvbXBhY3Q8QmFsYW5jZU9mPgTcUmVib25kIGEgcG9ydGlvbiBvZiB0aGUgc3Rhc2ggc2NoZWR1bGVkIHRvIGJlIHVubG9ja2VkLkRzZXRfaGlzdG9yeV9kZXB0aAhEbmV3X2hpc3RvcnlfZGVwdGhEQ29tcGFjdDxFcmFJbmRleD5IX2VyYV9pdGVtc19kZWxldGVkMENvbXBhY3Q8dTMyPggtAVNldCBgSGlzdG9yeURlcHRoYCB2YWx1ZS4gVGhpcyBmdW5jdGlvbiB3aWxsIGRlbGV0ZSBhbnkgaGlzdG9yeSBpbmZvcm1hdGlvbnx3aGVuIGBIaXN0b3J5RGVwdGhgIGlzIHJlZHVjZWQuKHJlYXBfc3Rhc2gIFHN0YXNoJEFjY291bnRJZEhudW1fc2xhc2hpbmdfc3BhbnMMdTMyDDUBUmVtb3ZlIGFsbCBkYXRhIHN0cnVjdHVyZSBjb25jZXJuaW5nIGEgc3Rha2VyL3N0YXNoIG9uY2UgaXRzIGJhbGFuY2UgaXMgemVyby5dAVRoaXMgaXMgZXNzZW50aWFsbHkgZXF1aXZhbGVudCB0byBgd2l0aGRyYXdfdW5ib25kZWRgIGV4Y2VwdCBpdCBjYW4gYmUgY2FsbGVkIGJ5IGFueW9uZbxhbmQgdGhlIHRhcmdldCBgc3Rhc2hgIG11c3QgaGF2ZSBubyBmdW5kcyBsZWZ0LmBzdWJtaXRfZWxlY3Rpb25fc29sdXRpb24UHHdpbm5lcnNMVmVjPFZhbGlkYXRvckluZGV4Phxjb21wYWN0SENvbXBhY3RBc3NpZ25tZW50cxRzY29yZTRFbGVjdGlvblNjb3JlDGVyYSBFcmFJbmRleBBzaXplMEVsZWN0aW9uU2l6ZQTgU3VibWl0IGFuIGVsZWN0aW9uIHJlc3VsdCB0byB0aGUgY2hhaW4uIElmIHRoZSBzb2x1dGlvbjqEc3VibWl0X2VsZWN0aW9uX3NvbHV0aW9uX3Vuc2lnbmVkFBx3aW5uZXJzTFZlYzxWYWxpZGF0b3JJbmRleD4cY29tcGFjdEhDb21wYWN0QXNzaWdubWVudHMUc2NvcmU0RWxlY3Rpb25TY29yZQxlcmEgRXJhSW5kZXgQc2l6ZTBFbGVjdGlvblNpemUEvFVuc2lnbmVkIHZlcnNpb24gb2YgYHN1Ym1pdF9lbGVjdGlvbl9zb2x1dGlvbmAuAAAABiBPZmZlbmNlcwABAAAAAAcoSGlzdG9yaWNhbAAAAAAAGxxTZXNzaW9uAAEIIHNldF9rZXlzCBBrZXlzEEtleXMUcHJvb2YUQnl0ZXMM5FNldHMgdGhlIHNlc3Npb24ga2V5KHMpIG9mIHRoZSBmdW5jdGlvbiBjYWxsZXIgdG8gYGtleXNgLh0BQWxsb3dzIGFuIGFjY291bnQgdG8gc2V0IGl0cyBzZXNzaW9uIGtleSBwcmlvciB0byBiZWNvbWluZyBhIHZhbGlkYXRvci7AVGhpcyBkb2Vzbid0IHRha2UgZWZmZWN0IHVudGlsIHRoZSBuZXh0IHNlc3Npb24uKHB1cmdlX2tleXMACMhSZW1vdmVzIGFueSBzZXNzaW9uIGtleShzKSBvZiB0aGUgZnVuY3Rpb24gY2FsbGVyLsBUaGlzIGRvZXNuJ3QgdGFrZSBlZmZlY3QgdW50aWwgdGhlIG5leHQgc2Vzc2lvbi4AAAAIPEZpbmFsaXR5VHJhY2tlcgABBChmaW5hbF9oaW50BBBoaW50UENvbXBhY3Q8QmxvY2tOdW1iZXI+CPBIaW50IHRoYXQgdGhlIGF1dGhvciBvZiB0aGlzIGJsb2NrIHRoaW5rcyB0aGUgYmVzdCBmaW5hbGl6ZWRoYmxvY2sgaXMgdGhlIGdpdmVuIG51bWJlci4AAAAJHEdyYW5kcGEAAQxMcmVwb3J0X2VxdWl2b2NhdGlvbghIZXF1aXZvY2F0aW9uX3Byb29mYEdyYW5kcGFFcXVpdm9jYXRpb25Qcm9vZjxrZXlfb3duZXJfcHJvb2Y0S2V5T3duZXJQcm9vZhAJAVJlcG9ydCB2b3RlciBlcXVpdm9jYXRpb24vbWlzYmVoYXZpb3IuIFRoaXMgbWV0aG9kIHdpbGwgdmVyaWZ5IHRoZfRlcXVpdm9jYXRpb24gcHJvb2YgYW5kIHZhbGlkYXRlIHRoZSBnaXZlbiBrZXkgb3duZXJzaGlwIHByb29m+GFnYWluc3QgdGhlIGV4dHJhY3RlZCBvZmZlbmRlci4gSWYgYm90aCBhcmUgdmFsaWQsIHRoZSBvZmZlbmNlRHdpbGwgYmUgcmVwb3J0ZWQucHJlcG9ydF9lcXVpdm9jYXRpb25fdW5zaWduZWQISGVxdWl2b2NhdGlvbl9wcm9vZmBHcmFuZHBhRXF1aXZvY2F0aW9uUHJvb2Y8a2V5X293bmVyX3Byb29mNEtleU93bmVyUHJvb2YQCQFSZXBvcnQgdm90ZXIgZXF1aXZvY2F0aW9uL21pc2JlaGF2aW9yLiBUaGlzIG1ldGhvZCB3aWxsIHZlcmlmeSB0aGX0ZXF1aXZvY2F0aW9uIHByb29mIGFuZCB2YWxpZGF0ZSB0aGUgZ2l2ZW4ga2V5IG93bmVyc2hpcCBwcm9vZvhhZ2FpbnN0IHRoZSBleHRyYWN0ZWQgb2ZmZW5kZXIuIElmIGJvdGggYXJlIHZhbGlkLCB0aGUgb2ZmZW5jZUR3aWxsIGJlIHJlcG9ydGVkLjBub3RlX3N0YWxsZWQIFGRlbGF5LEJsb2NrTnVtYmVybGJlc3RfZmluYWxpemVkX2Jsb2NrX251bWJlcixCbG9ja051bWJlchwZAU5vdGUgdGhhdCB0aGUgY3VycmVudCBhdXRob3JpdHkgc2V0IG9mIHRoZSBHUkFORFBBIGZpbmFsaXR5IGdhZGdldCBoYXMlAXN0YWxsZWQuIFRoaXMgd2lsbCB0cmlnZ2VyIGEgZm9yY2VkIGF1dGhvcml0eSBzZXQgY2hhbmdlIGF0IHRoZSBiZWdpbm5pbmcdAW9mIHRoZSBuZXh0IHNlc3Npb24sIHRvIGJlIGVuYWN0ZWQgYGRlbGF5YCBibG9ja3MgYWZ0ZXIgdGhhdC4gVGhlIGRlbGF5EQFzaG91bGQgYmUgaGlnaCBlbm91Z2ggdG8gc2FmZWx5IGFzc3VtZSB0aGF0IHRoZSBibG9jayBzaWduYWxsaW5nIHRoZSUBZm9yY2VkIGNoYW5nZSB3aWxsIG5vdCBiZSByZS1vcmdlZCAoZS5nLiAxMDAwIGJsb2NrcykuIFRoZSBHUkFORFBBIHZvdGVycyUBd2lsbCBzdGFydCB0aGUgbmV3IGF1dGhvcml0eSBzZXQgdXNpbmcgdGhlIGdpdmVuIGZpbmFsaXplZCBibG9jayBhcyBiYXNlLlhPbmx5IGNhbGxhYmxlIGJ5IHJvb3QuAAAACiBJbU9ubGluZQABBCRoZWFydGJlYXQIJGhlYXJ0YmVhdCRIZWFydGJlYXQoX3NpZ25hdHVyZSRTaWduYXR1cmUkKCMgPHdlaWdodD49AS0gQ29tcGxleGl0eTogYE8oSyArIEUpYCB3aGVyZSBLIGlzIGxlbmd0aCBvZiBgS2V5c2AgKGhlYXJ0YmVhdC52YWxpZGF0b3JzX2xlbin0YW5kIEUgaXMgbGVuZ3RoIG9mIGBoZWFydGJlYXQubmV0d29ya19zdGF0ZS5leHRlcm5hbF9hZGRyZXNzYIAtIGBPKEspYDogZGVjb2Rpbmcgb2YgbGVuZ3RoIGBLYKQtIGBPKEUpYDogZGVjb2RpbmcvZW5jb2Rpbmcgb2YgbGVuZ3RoIGBFYDkBLSBEYlJlYWRzOiBwYWxsZXRfc2Vzc2lvbiBgVmFsaWRhdG9yc2AsIHBhbGxldF9zZXNzaW9uIGBDdXJyZW50SW5kZXhgLCBgS2V5c2AsUGBSZWNlaXZlZEhlYXJ0YmVhdHNggC0gRGJXcml0ZXM6IGBSZWNlaXZlZEhlYXJ0YmVhdHNgLCMgPC93ZWlnaHQ+AAAAC0hBdXRob3JpdHlEaXNjb3ZlcnkAAQAAAAAMHFV0aWxpdHkAAQgUYmF0Y2gEFGNhbGxzJFZlYzxDYWxsPgR8U2VuZCBhIGJhdGNoIG9mIGRpc3BhdGNoIGNhbGxzLjRhc19kZXJpdmF0aXZlCBRpbmRleAx1MTYQY2FsbBBDYWxsBNxTZW5kIGEgY2FsbCB0aHJvdWdoIGFuIGluZGV4ZWQgcHNldWRvbnltIG9mIHRoZSBzZW5kZXIuAAAAECBJZGVudGl0eQABPDRhZGRfcmVnaXN0cmFyBBxhY2NvdW50JEFjY291bnRJZAR4QWRkIGEgcmVnaXN0cmFyIHRvIHRoZSBzeXN0ZW0uMHNldF9pZGVudGl0eQQQaW5mbzBJZGVudGl0eUluZm8EKQFTZXQgYW4gYWNjb3VudCdzIGlkZW50aXR5IGluZm9ybWF0aW9uIGFuZCByZXNlcnZlIHRoZSBhcHByb3ByaWF0ZSBkZXBvc2l0LiBzZXRfc3VicwQQc3Vic1RWZWM8KEFjY291bnRJZCxEYXRhKT4EjFNldCB0aGUgc3ViLWFjY291bnRzIG9mIHRoZSBzZW5kZXIuOGNsZWFyX2lkZW50aXR5AAQ5AUNsZWFyIGFuIGFjY291bnQncyBpZGVudGl0eSBpbmZvIGFuZCBhbGwgc3ViLWFjY291bnRzIGFuZCByZXR1cm4gYWxsIGRlcG9zaXRzLkRyZXF1ZXN0X2p1ZGdlbWVudAgkcmVnX2luZGV4XENvbXBhY3Q8UmVnaXN0cmFySW5kZXg+HG1heF9mZWVIQ29tcGFjdDxCYWxhbmNlT2Y+BJRSZXF1ZXN0IGEganVkZ2VtZW50IGZyb20gYSByZWdpc3RyYXIuOGNhbmNlbF9yZXF1ZXN0BCRyZWdfaW5kZXg4UmVnaXN0cmFySW5kZXgEaENhbmNlbCBhIHByZXZpb3VzIHJlcXVlc3QuHHNldF9mZWUIFGluZGV4XENvbXBhY3Q8UmVnaXN0cmFySW5kZXg+DGZlZUhDb21wYWN0PEJhbGFuY2VPZj4EGQFTZXQgdGhlIGZlZSByZXF1aXJlZCBmb3IgYSBqdWRnZW1lbnQgdG8gYmUgcmVxdWVzdGVkIGZyb20gYSByZWdpc3RyYXIuOHNldF9hY2NvdW50X2lkCBRpbmRleFxDb21wYWN0PFJlZ2lzdHJhckluZGV4PgxuZXckQWNjb3VudElkBLxDaGFuZ2UgdGhlIGFjY291bnQgYXNzb2NpYXRlZCB3aXRoIGEgcmVnaXN0cmFyLihzZXRfZmllbGRzCBRpbmRleFxDb21wYWN0PFJlZ2lzdHJhckluZGV4PhhmaWVsZHM4SWRlbnRpdHlGaWVsZHMEqFNldCB0aGUgZmllbGQgaW5mb3JtYXRpb24gZm9yIGEgcmVnaXN0cmFyLkRwcm92aWRlX2p1ZGdlbWVudAwkcmVnX2luZGV4XENvbXBhY3Q8UmVnaXN0cmFySW5kZXg+GHRhcmdldDBMb29rdXBTb3VyY2UkanVkZ2VtZW50RElkZW50aXR5SnVkZ2VtZW50BLhQcm92aWRlIGEganVkZ2VtZW50IGZvciBhbiBhY2NvdW50J3MgaWRlbnRpdHkuNGtpbGxfaWRlbnRpdHkEGHRhcmdldDBMb29rdXBTb3VyY2UEQQFSZW1vdmUgYW4gYWNjb3VudCdzIGlkZW50aXR5IGFuZCBzdWItYWNjb3VudCBpbmZvcm1hdGlvbiBhbmQgc2xhc2ggdGhlIGRlcG9zaXRzLhxhZGRfc3ViCAxzdWIwTG9va3VwU291cmNlEGRhdGEQRGF0YQSsQWRkIHRoZSBnaXZlbiBhY2NvdW50IHRvIHRoZSBzZW5kZXIncyBzdWJzLihyZW5hbWVfc3ViCAxzdWIwTG9va3VwU291cmNlEGRhdGEQRGF0YQTMQWx0ZXIgdGhlIGFzc29jaWF0ZWQgbmFtZSBvZiB0aGUgZ2l2ZW4gc3ViLWFjY291bnQuKHJlbW92ZV9zdWIEDHN1YjBMb29rdXBTb3VyY2UEwFJlbW92ZSB0aGUgZ2l2ZW4gYWNjb3VudCBmcm9tIHRoZSBzZW5kZXIncyBzdWJzLiBxdWl0X3N1YgAEjFJlbW92ZSB0aGUgc2VuZGVyIGFzIGEgc3ViLWFjY291bnQuAAAAESBSZWNvdmVyeQABJDBhc19yZWNvdmVyZWQIHGFjY291bnQkQWNjb3VudElkEGNhbGwQQ2FsbASgU2VuZCBhIGNhbGwgdGhyb3VnaCBhIHJlY292ZXJlZCBhY2NvdW50LjRzZXRfcmVjb3ZlcmVkCBBsb3N0JEFjY291bnRJZBxyZXNjdWVyJEFjY291bnRJZAgZAUFsbG93IFJPT1QgdG8gYnlwYXNzIHRoZSByZWNvdmVyeSBwcm9jZXNzIGFuZCBzZXQgYW4gYSByZXNjdWVyIGFjY291bnRwZm9yIGEgbG9zdCBhY2NvdW50IGRpcmVjdGx5LjxjcmVhdGVfcmVjb3ZlcnkMHGZyaWVuZHM4VmVjPEFjY291bnRJZD4kdGhyZXNob2xkDHUxNjBkZWxheV9wZXJpb2QsQmxvY2tOdW1iZXIEWQFDcmVhdGUgYSByZWNvdmVyeSBjb25maWd1cmF0aW9uIGZvciB5b3VyIGFjY291bnQuIFRoaXMgbWFrZXMgeW91ciBhY2NvdW50IHJlY292ZXJhYmxlLkRpbml0aWF0ZV9yZWNvdmVyeQQcYWNjb3VudCRBY2NvdW50SWQE6EluaXRpYXRlIHRoZSBwcm9jZXNzIGZvciByZWNvdmVyaW5nIGEgcmVjb3ZlcmFibGUgYWNjb3VudC44dm91Y2hfcmVjb3ZlcnkIEGxvc3QkQWNjb3VudElkHHJlc2N1ZXIkQWNjb3VudElkCCUBQWxsb3cgYSAiZnJpZW5kIiBvZiBhIHJlY292ZXJhYmxlIGFjY291bnQgdG8gdm91Y2ggZm9yIGFuIGFjdGl2ZSByZWNvdmVyeWRwcm9jZXNzIGZvciB0aGF0IGFjY291bnQuOGNsYWltX3JlY292ZXJ5BBxhY2NvdW50JEFjY291bnRJZATwQWxsb3cgYSBzdWNjZXNzZnVsIHJlc2N1ZXIgdG8gY2xhaW0gdGhlaXIgcmVjb3ZlcmVkIGFjY291bnQuOGNsb3NlX3JlY292ZXJ5BBxyZXNjdWVyJEFjY291bnRJZAgRAUFzIHRoZSBjb250cm9sbGVyIG9mIGEgcmVjb3ZlcmFibGUgYWNjb3VudCwgY2xvc2UgYW4gYWN0aXZlIHJlY292ZXJ5ZHByb2Nlc3MgZm9yIHlvdXIgYWNjb3VudC48cmVtb3ZlX3JlY292ZXJ5AARZAVJlbW92ZSB0aGUgcmVjb3ZlcnkgcHJvY2VzcyBmb3IgeW91ciBhY2NvdW50LiBSZWNvdmVyZWQgYWNjb3VudHMgYXJlIHN0aWxsIGFjY2Vzc2libGUuQGNhbmNlbF9yZWNvdmVyZWQEHGFjY291bnQkQWNjb3VudElkBNxDYW5jZWwgdGhlIGFiaWxpdHkgdG8gdXNlIGBhc19yZWNvdmVyZWRgIGZvciBgYWNjb3VudGAuAAAAEhxWZXN0aW5nAAEQEHZlc3QABLhVbmxvY2sgYW55IHZlc3RlZCBmdW5kcyBvZiB0aGUgc2VuZGVyIGFjY291bnQuKHZlc3Rfb3RoZXIEGHRhcmdldDBMb29rdXBTb3VyY2UEuFVubG9jayBhbnkgdmVzdGVkIGZ1bmRzIG9mIGEgYHRhcmdldGAgYWNjb3VudC48dmVzdGVkX3RyYW5zZmVyCBh0YXJnZXQwTG9va3VwU291cmNlIHNjaGVkdWxlLFZlc3RpbmdJbmZvBGRDcmVhdGUgYSB2ZXN0ZWQgdHJhbnNmZXIuVGZvcmNlX3Zlc3RlZF90cmFuc2ZlcgwYc291cmNlMExvb2t1cFNvdXJjZRh0YXJnZXQwTG9va3VwU291cmNlIHNjaGVkdWxlLFZlc3RpbmdJbmZvBGBGb3JjZSBhIHZlc3RlZCB0cmFuc2Zlci4AAAATJFNjaGVkdWxlcgABGCBzY2hlZHVsZRAQd2hlbixCbG9ja051bWJlcjhtYXliZV9wZXJpb2RpYzhPcHRpb248UGVyaW9kPiBwcmlvcml0eSBQcmlvcml0eRBjYWxsEENhbGwEcEFub255bW91c2x5IHNjaGVkdWxlIGEgdGFzay4YY2FuY2VsCBB3aGVuLEJsb2NrTnVtYmVyFGluZGV4DHUzMgSUQ2FuY2VsIGFuIGFub255bW91c2x5IHNjaGVkdWxlZCB0YXNrLjhzY2hlZHVsZV9uYW1lZBQIaWQUQnl0ZXMQd2hlbixCbG9ja051bWJlcjhtYXliZV9wZXJpb2RpYzhPcHRpb248UGVyaW9kPiBwcmlvcml0eSBQcmlvcml0eRBjYWxsEENhbGwEWFNjaGVkdWxlIGEgbmFtZWQgdGFzay4wY2FuY2VsX25hbWVkBAhpZBRCeXRlcwR4Q2FuY2VsIGEgbmFtZWQgc2NoZWR1bGVkIHRhc2suOHNjaGVkdWxlX2FmdGVyEBRhZnRlcixCbG9ja051bWJlcjhtYXliZV9wZXJpb2RpYzhPcHRpb248UGVyaW9kPiBwcmlvcml0eSBQcmlvcml0eRBjYWxsEENhbGwEqEFub255bW91c2x5IHNjaGVkdWxlIGEgdGFzayBhZnRlciBhIGRlbGF5LlBzY2hlZHVsZV9uYW1lZF9hZnRlchQIaWQUQnl0ZXMUYWZ0ZXIsQmxvY2tOdW1iZXI4bWF5YmVfcGVyaW9kaWM4T3B0aW9uPFBlcmlvZD4gcHJpb3JpdHkgUHJpb3JpdHkQY2FsbBBDYWxsBJBTY2hlZHVsZSBhIG5hbWVkIHRhc2sgYWZ0ZXIgYSBkZWxheS4AAAAUEFN1ZG8AARAQc3VkbwQQY2FsbBBDYWxsBDUBQXV0aGVudGljYXRlcyB0aGUgc3VkbyBrZXkgYW5kIGRpc3BhdGNoZXMgYSBmdW5jdGlvbiBjYWxsIHdpdGggYFJvb3RgIG9yaWdpbi5Uc3Vkb191bmNoZWNrZWRfd2VpZ2h0CBBjYWxsEENhbGwcX3dlaWdodBhXZWlnaHQMNQFBdXRoZW50aWNhdGVzIHRoZSBzdWRvIGtleSBhbmQgZGlzcGF0Y2hlcyBhIGZ1bmN0aW9uIGNhbGwgd2l0aCBgUm9vdGAgb3JpZ2luLi0BVGhpcyBmdW5jdGlvbiBkb2VzIG5vdCBjaGVjayB0aGUgd2VpZ2h0IG9mIHRoZSBjYWxsLCBhbmQgaW5zdGVhZCBhbGxvd3MgdGhlsFN1ZG8gdXNlciB0byBzcGVjaWZ5IHRoZSB3ZWlnaHQgb2YgdGhlIGNhbGwuHHNldF9rZXkEDG5ldzBMb29rdXBTb3VyY2UEcQFBdXRoZW50aWNhdGVzIHRoZSBjdXJyZW50IHN1ZG8ga2V5IGFuZCBzZXRzIHRoZSBnaXZlbiBBY2NvdW50SWQgKGBuZXdgKSBhcyB0aGUgbmV3IHN1ZG8ga2V5LhxzdWRvX2FzCAx3aG8wTG9va3VwU291cmNlEGNhbGwQQ2FsbAhNAUF1dGhlbnRpY2F0ZXMgdGhlIHN1ZG8ga2V5IGFuZCBkaXNwYXRjaGVzIGEgZnVuY3Rpb24gY2FsbCB3aXRoIGBTaWduZWRgIG9yaWdpbiBmcm9tQGEgZ2l2ZW4gYWNjb3VudC4AAAAVFFByb3h5AAEoFHByb3h5DBByZWFsJEFjY291bnRJZEBmb3JjZV9wcm94eV90eXBlRE9wdGlvbjxQcm94eVR5cGU+EGNhbGwQQ2FsbAhNAURpc3BhdGNoIHRoZSBnaXZlbiBgY2FsbGAgZnJvbSBhbiBhY2NvdW50IHRoYXQgdGhlIHNlbmRlciBpcyBhdXRob3Jpc2VkIGZvciB0aHJvdWdoMGBhZGRfcHJveHlgLiRhZGRfcHJveHkMIGRlbGVnYXRlJEFjY291bnRJZChwcm94eV90eXBlJFByb3h5VHlwZRRkZWxheSxCbG9ja051bWJlcgRFAVJlZ2lzdGVyIGEgcHJveHkgYWNjb3VudCBmb3IgdGhlIHNlbmRlciB0aGF0IGlzIGFibGUgdG8gbWFrZSBjYWxscyBvbiBpdHMgYmVoYWxmLjByZW1vdmVfcHJveHkMIGRlbGVnYXRlJEFjY291bnRJZChwcm94eV90eXBlJFByb3h5VHlwZRRkZWxheSxCbG9ja051bWJlcgSoVW5yZWdpc3RlciBhIHByb3h5IGFjY291bnQgZm9yIHRoZSBzZW5kZXIuOHJlbW92ZV9wcm94aWVzAAS0VW5yZWdpc3RlciBhbGwgcHJveHkgYWNjb3VudHMgZm9yIHRoZSBzZW5kZXIuJGFub255bW91cwwocHJveHlfdHlwZSRQcm94eVR5cGUUZGVsYXksQmxvY2tOdW1iZXIUaW5kZXgMdTE2CDkBU3Bhd24gYSBmcmVzaCBuZXcgYWNjb3VudCB0aGF0IGlzIGd1YXJhbnRlZWQgdG8gYmUgb3RoZXJ3aXNlIGluYWNjZXNzaWJsZSwgYW5k/GluaXRpYWxpemUgaXQgd2l0aCBhIHByb3h5IG9mIGBwcm94eV90eXBlYCBmb3IgYG9yaWdpbmAgc2VuZGVyLjhraWxsX2Fub255bW91cxQcc3Bhd25lciRBY2NvdW50SWQocHJveHlfdHlwZSRQcm94eVR5cGUUaW5kZXgMdTE2GGhlaWdodFBDb21wYWN0PEJsb2NrTnVtYmVyPiRleHRfaW5kZXgwQ29tcGFjdDx1MzI+BLRSZW1vdmVzIGEgcHJldmlvdXNseSBzcGF3bmVkIGFub255bW91cyBwcm94eS4gYW5ub3VuY2UIEHJlYWwkQWNjb3VudElkJGNhbGxfaGFzaChDYWxsSGFzaE9mBAUBUHVibGlzaCB0aGUgaGFzaCBvZiBhIHByb3h5LWNhbGwgdGhhdCB3aWxsIGJlIG1hZGUgaW4gdGhlIGZ1dHVyZS5McmVtb3ZlX2Fubm91bmNlbWVudAgQcmVhbCRBY2NvdW50SWQkY2FsbF9oYXNoKENhbGxIYXNoT2YEcFJlbW92ZSBhIGdpdmVuIGFubm91bmNlbWVudC5McmVqZWN0X2Fubm91bmNlbWVudAggZGVsZWdhdGUkQWNjb3VudElkJGNhbGxfaGFzaChDYWxsSGFzaE9mBLBSZW1vdmUgdGhlIGdpdmVuIGFubm91bmNlbWVudCBvZiBhIGRlbGVnYXRlLjxwcm94eV9hbm5vdW5jZWQQIGRlbGVnYXRlJEFjY291bnRJZBByZWFsJEFjY291bnRJZEBmb3JjZV9wcm94eV90eXBlRE9wdGlvbjxQcm94eVR5cGU+EGNhbGwQQ2FsbAhNAURpc3BhdGNoIHRoZSBnaXZlbiBgY2FsbGAgZnJvbSBhbiBhY2NvdW50IHRoYXQgdGhlIHNlbmRlciBpcyBhdXRob3Jpc2VkIGZvciB0aHJvdWdoMGBhZGRfcHJveHlgLgAAABYgTXVsdGlzaWcAARBQYXNfbXVsdGlfdGhyZXNob2xkXzEIRG90aGVyX3NpZ25hdG9yaWVzOFZlYzxBY2NvdW50SWQ+EGNhbGwQQ2FsbARRAUltbWVkaWF0ZWx5IGRpc3BhdGNoIGEgbXVsdGktc2lnbmF0dXJlIGNhbGwgdXNpbmcgYSBzaW5nbGUgYXBwcm92YWwgZnJvbSB0aGUgY2FsbGVyLiBhc19tdWx0aRgkdGhyZXNob2xkDHUxNkRvdGhlcl9zaWduYXRvcmllczhWZWM8QWNjb3VudElkPjxtYXliZV90aW1lcG9pbnRET3B0aW9uPFRpbWVwb2ludD4QY2FsbChPcGFxdWVDYWxsKHN0b3JlX2NhbGwQYm9vbChtYXhfd2VpZ2h0GFdlaWdodAhVAVJlZ2lzdGVyIGFwcHJvdmFsIGZvciBhIGRpc3BhdGNoIHRvIGJlIG1hZGUgZnJvbSBhIGRldGVybWluaXN0aWMgY29tcG9zaXRlIGFjY291bnQgaWb4YXBwcm92ZWQgYnkgYSB0b3RhbCBvZiBgdGhyZXNob2xkIC0gMWAgb2YgYG90aGVyX3NpZ25hdG9yaWVzYC5AYXBwcm92ZV9hc19tdWx0aRQkdGhyZXNob2xkDHUxNkRvdGhlcl9zaWduYXRvcmllczhWZWM8QWNjb3VudElkPjxtYXliZV90aW1lcG9pbnRET3B0aW9uPFRpbWVwb2ludD4kY2FsbF9oYXNoHFt1ODszMl0obWF4X3dlaWdodBhXZWlnaHQIVQFSZWdpc3RlciBhcHByb3ZhbCBmb3IgYSBkaXNwYXRjaCB0byBiZSBtYWRlIGZyb20gYSBkZXRlcm1pbmlzdGljIGNvbXBvc2l0ZSBhY2NvdW50IGlm+GFwcHJvdmVkIGJ5IGEgdG90YWwgb2YgYHRocmVzaG9sZCAtIDFgIG9mIGBvdGhlcl9zaWduYXRvcmllc2AuPGNhbmNlbF9hc19tdWx0aRAkdGhyZXNob2xkDHUxNkRvdGhlcl9zaWduYXRvcmllczhWZWM8QWNjb3VudElkPiR0aW1lcG9pbnQkVGltZXBvaW50JGNhbGxfaGFzaBxbdTg7MzJdCFUBQ2FuY2VsIGEgcHJlLWV4aXN0aW5nLCBvbi1nb2luZyBtdWx0aXNpZyB0cmFuc2FjdGlvbi4gQW55IGRlcG9zaXQgcmVzZXJ2ZWQgcHJldmlvdXNsecRmb3IgdGhpcyBvcGVyYXRpb24gd2lsbCBiZSB1bnJlc2VydmVkIG9uIHN1Y2Nlc3MuAAAAFwQcQENoZWNrU3BlY1ZlcnNpb244Q2hlY2tUeFZlcnNpb24wQ2hlY2tHZW5lc2lzOENoZWNrTW9ydGFsaXR5KENoZWNrTm9uY2UsQ2hlY2tXZWlnaHRgQ2hhcmdlVHJhbnNhY3Rpb25QYXltZW50', + specVersion: 45, + ss58Format: 42, + tokenDecimals: 12, + tokenSymbol: 'WND', + types: { + Address: 'AccountId', + Keys: 'SessionKeys5', + LookupSource: 'AccountId', + ProxyType: { + _enum: [ + 'Any', + 'NonTransfer', + 'Staking', + 'Unused', + 'IdentityJudgement' + ] as unknown + } + } +} as MetadataDef; diff --git a/packages/extension-ui/src/Popup/index.tsx b/packages/extension-ui/src/Popup/index.tsx index 9aa9be9b83..ba55965739 100644 --- a/packages/extension-ui/src/Popup/index.tsx +++ b/packages/extension-ui/src/Popup/index.tsx @@ -1,7 +1,7 @@ // Copyright 2019-2021 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types'; +import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, DecryptingRequest } from '@polkadot/extension-base/background/types'; import type { SettingsStruct } from '@polkadot/ui-settings/types'; import React, { useCallback, useEffect, useState } from 'react'; @@ -12,9 +12,9 @@ import { canDerive } from '@polkadot/extension-base/utils'; import uiSettings from '@polkadot/ui-settings'; import { ErrorBoundary, Loading } from '../components'; -import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts'; +import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, DecryptingReqContext } from '../components/contexts'; import ToastProvider from '../components/Toast/ToastProvider'; -import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging'; +import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests, subscribeDecryptingRequests } from '../messaging'; import { buildHierarchy } from '../util/buildHierarchy'; import Accounts from './Accounts'; import AuthList from './AuthManagement'; @@ -31,6 +31,7 @@ import Metadata from './Metadata'; import PhishingDetected from './PhishingDetected'; import RestoreJson from './RestoreJson'; import Signing from './Signing'; +import Decrypting from './Decrypting'; import Welcome from './Welcome'; const startSettings = uiSettings.get(); @@ -71,6 +72,7 @@ export default function Popup (): React.ReactElement { const [mediaAllowed, setMediaAllowed] = useState(false); const [metaRequests, setMetaRequests] = useState(null); const [signRequests, setSignRequests] = useState(null); + const [decryptRequests, setDecryptRequests] = useState(null); const [isWelcomeDone, setWelcomeDone] = useState(false); const [settingsCtx, setSettingsCtx] = useState(startSettings); @@ -90,7 +92,8 @@ export default function Popup (): React.ReactElement { subscribeAccounts(setAccounts), subscribeAuthorizeRequests(setAuthRequests), subscribeMetadataRequests(setMetaRequests), - subscribeSigningRequests(setSignRequests) + subscribeSigningRequests(setSignRequests), + subscribeDecryptingRequests(setDecryptRequests) ]).catch(console.error); uiSettings.on('change', (settings): void => { @@ -123,11 +126,13 @@ export default function Popup (): React.ReactElement { ? wrapWithErrorBoundary(, 'metadata') : signRequests && signRequests.length ? wrapWithErrorBoundary(, 'signing') - : wrapWithErrorBoundary(, 'accounts') + : decryptRequests && decryptRequests.length + ? wrapWithErrorBoundary(, 'decrypting') + : wrapWithErrorBoundary(, 'accounts') : wrapWithErrorBoundary(, 'welcome'); return ( - {accounts && authRequests && metaRequests && signRequests && ( + {accounts && authRequests && metaRequests && signRequests && decryptRequests && ( @@ -135,6 +140,7 @@ export default function Popup (): React.ReactElement { + {wrapWithErrorBoundary(, 'auth-list')} @@ -157,6 +163,7 @@ export default function Popup (): React.ReactElement { + diff --git a/packages/extension-ui/src/components/contexts.tsx b/packages/extension-ui/src/components/contexts.tsx index f2c8aef7d8..617ee4c875 100644 --- a/packages/extension-ui/src/components/contexts.tsx +++ b/packages/extension-ui/src/components/contexts.tsx @@ -1,7 +1,7 @@ // Copyright 2019-2021 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types'; +import type { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest, DecryptingRequest } from '@polkadot/extension-base/background/types'; import type { SettingsStruct } from '@polkadot/ui-settings/types'; import type { AvailableThemes } from './themes'; @@ -19,6 +19,7 @@ const MediaContext = React.createContext(false); const MetadataReqContext = React.createContext([]); const SettingsContext = React.createContext(settings.get()); const SigningReqContext = React.createContext([]); +const DecryptingReqContext = React.createContext([]); const ThemeSwitchContext = React.createContext<(theme: AvailableThemes) => void>(noop); const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop }); @@ -30,6 +31,7 @@ export { MetadataReqContext, SettingsContext, SigningReqContext, + DecryptingReqContext, ThemeSwitchContext, ToastContext }; diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 2562488b43..eea75b8096 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -1,7 +1,7 @@ // Copyright 2019-2021 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types'; +import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes, DecryptingRequest } from '@polkadot/extension-base/background/types'; import type { Message } from '@polkadot/extension-base/types'; import type { Chain } from '@polkadot/extension-chains/types'; import type { KeyringPair$Json } from '@polkadot/keyring/types'; @@ -119,6 +119,20 @@ export async function approveSignSignature (id: string, signature: string): Prom return sendMessage('pri(signing.approve.signature)', { id, signature }); } +export async function approveDecrypting (id: string, decrypted: string): Promise { + return sendMessage('pri(decrypting.approve)', { id, decrypted }); +} + +export async function approveDecryptPassword (id: string, savePass: boolean, password?: string): Promise { + return sendMessage('pri(decrypting.approve.password)', { id, password, savePass }); +} + + +export async function cancelDecryptRequest (id: string): Promise { + return sendMessage('pri(decrypting.cancel)', { id }); +} + + export async function createAccountExternal (name: string, address: string, genesisHash: string): Promise { return sendMessage('pri(accounts.create.external)', { address, genesisHash, name }); } @@ -204,6 +218,11 @@ export async function subscribeSigningRequests (cb: (accounts: SigningRequest[]) return sendMessage('pri(signing.requests)', null, cb); } +export async function subscribeDecryptingRequests (cb: (accounts: DecryptingRequest[]) => void): Promise { + console.log('subscribeDecryptingRequests'); + return sendMessage('pri(decrypting.requests)', null, cb); +} + export async function validateSeed (suri: string, type?: KeypairType): Promise<{ address: string; suri: string }> { return sendMessage('pri(seed.validate)', { suri, type }); } diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index a6ccff635c..5105f1ccea 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -159,5 +159,7 @@ "All account": "", "password for encrypting all accounts": "", "I want to export all my accounts": "", - "Notifications": "" + "Notifications": "", + "Decrypt the message": "", + "Decrypt message": "" }