diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afae16d..037d02cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Contributed: Changes: +- Add option to not ask password for 15 minutes (when signing transactions) +- Derived accounts uses the parent genesisHash by default (attaches to same chain) - Make import from seed, QR & JSON options available on first-start - Latest upstream polkadot-js dependencies diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index f66927f8..be311c64 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -4,7 +4,7 @@ import { MetadataDef } from '@polkadot/extension-inject/types'; import { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; -import { AccountJson, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountShow, RequestAccountTie, 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 { AccountJson, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, ResponseDeriveValidate, RequestMetadataApprove, RequestMetadataReject, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, 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'; @@ -17,8 +17,11 @@ import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/ut import State from './State'; import { createSubscription, unsubscribe } from './subscriptions'; +type CachedUnlocks = Record; + const SEED_DEFAULT_LENGTH = 12; const SEED_LENGTHS = [12, 15, 18, 21, 24]; +const PASSWORD_EXPIRY = 15 * 60 * 1000; // a global registry to use internally const registry = new TypeRegistry(); @@ -31,9 +34,12 @@ function transformAccounts (accounts: SubjectInfo): AccountJson[] { } export default class Extension { + readonly #cachedUnlocks: CachedUnlocks; + readonly #state: State; constructor (state: State) { + this.#cachedUnlocks = {}; this.#state = state; } @@ -262,7 +268,7 @@ export default class Extension { }; } - private signingApprovePassword ({ id, password }: RequestSigningApprovePassword): boolean { + private signingApprovePassword ({ id, isSavedPass, password }: RequestSigningApprovePassword): boolean { const queued = this.#state.getSignRequest(id); assert(queued, 'Unable to find request'); @@ -276,11 +282,21 @@ export default class Extension { return false; } - pair.decodePkcs8(password); + const now = Date.now(); + + if (pair.isLocked || password) { + pair.decodePkcs8(password); + } const result = request.sign(registry, pair); + const savedExpiry = this.#cachedUnlocks[request.payload.address] || 0; - pair.lock(); + if (isSavedPass) { + this.#cachedUnlocks[request.payload.address] = now + PASSWORD_EXPIRY; + } else if (savedExpiry < now) { + this.#cachedUnlocks[request.payload.address] = 0; + pair.lock(); + } resolve({ id, @@ -314,6 +330,19 @@ export default class Extension { return true; } + private signingIsLocked ({ id }: RequestSigningIsLocked): boolean { + const queued = this.#state.getSignRequest(id); + + assert(queued, 'Unable to find request'); + + const { request } = queued; + const pair = keyring.getPair(request.payload.address); + + assert(pair, 'Unable to find pair'); + + return pair.isLocked || ((this.#cachedUnlocks[request.payload.address] || 0) < Date.now()); + } + // FIXME This looks very much like what we have in authorization private signingSubscribe (id: string, port: chrome.runtime.Port): boolean { const cb = createSubscription<'pri(signing.requests)'>(id, port); @@ -462,6 +491,9 @@ export default class Extension { case 'pri(signing.cancel)': return this.signingCancel(request as RequestSigningCancel); + case 'pri(signing.isLocked)': + return this.signingIsLocked(request as RequestSigningIsLocked); + case 'pri(signing.requests)': return this.signingSubscribe(id, port); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index c62b27fa..92012793 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -95,6 +95,7 @@ export interface RequestSignatures { 'pri(signing.approve.password)': [RequestSigningApprovePassword, boolean]; 'pri(signing.approve.signature)': [RequestSigningApproveSignature, boolean]; 'pri(signing.cancel)': [RequestSigningCancel, boolean]; + 'pri(signing.isLocked)': [RequestSigningIsLocked, boolean]; 'pri(signing.requests)': [RequestSigningSubscribe, boolean, SigningRequest[]]; 'pri(window.open)': [null, boolean]; 'pri(window.open.json)': [null, boolean]; @@ -235,6 +236,7 @@ export interface RequestRpcUnsubscribe { export interface RequestSigningApprovePassword { id: string; + isSavedPass: boolean; password: string; } @@ -247,6 +249,10 @@ export interface RequestSigningCancel { id: string; } +export interface RequestSigningIsLocked { + id: string; +} + export type RequestSigningSubscribe = null; export interface RequestSeedCreate { diff --git a/packages/extension-ui/src/Popup/Signing/Request.tsx b/packages/extension-ui/src/Popup/Signing/Request.tsx index 0ae84a7e..589eecd6 100644 --- a/packages/extension-ui/src/Popup/Signing/Request.tsx +++ b/packages/extension-ui/src/Popup/Signing/Request.tsx @@ -6,11 +6,11 @@ import { ExtrinsicPayload } from '@polkadot/types/interfaces'; import { AccountJson, RequestSign } from '@polkadot/extension-base/background/types'; import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; -import React, { useContext, useState, useEffect } from 'react'; +import React, { useCallback, useContext, useState, useEffect } from 'react'; import { TypeRegistry } from '@polkadot/types'; -import { ActionBar, ActionContext, Address, ButtonArea, Link, VerticalSpace } from '../../components'; -import { approveSignPassword, approveSignSignature, cancelSignRequest } from '../../messaging'; +import { ActionBar, ActionContext, Address, Button, ButtonArea, Checkbox, Link, VerticalSpace } from '../../components'; +import { approveSignPassword, approveSignSignature, cancelSignRequest, isSignLocked } from '../../messaging'; import Bytes from './Bytes'; import Extrinsic from './Extrinsic'; import Qr from './Qr'; @@ -42,6 +42,16 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst, const onAction = useContext(ActionContext); const [{ hexBytes, payload }, setData] = useState({ hexBytes: null, payload: null }); const [error, setError] = useState(null); + const [isBusy, setIsBusy] = useState(false); + const [isLocked, setIsLocked] = useState(null); + const [isSavedPass, setIsSavedPass] = useState(false); + + useEffect((): void => { + setIsLocked(null); + !isExternal && isSignLocked(signId) + .then((isLocked) => setIsLocked(isLocked)) + .catch((error: Error) => console.error(error)); + }, [isExternal, signId]); useEffect((): void => { const payload = request.payload; @@ -61,24 +71,70 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst, } }, [request]); - const _onCancel = (): Promise => - cancelSignRequest(signId) + const _onCancel = useCallback( + (): Promise => cancelSignRequest(signId) .then(() => onAction()) - .catch((error: Error) => console.error(error)); - const _onSign = (password: string): Promise => - approveSignPassword(signId, password) - .then(() => onAction()) - .catch((error: Error): void => { - setError(error.message); - console.error(error); - }); - const _onSignature = ({ signature }: { signature: string }): Promise => - approveSignSignature(signId, signature) - .then(() => onAction()) - .catch((error: Error): void => { - setError(error.message); - console.error(error); - }); + .catch((error: Error) => console.error(error)), + [onAction, signId] + ); + + const _onSign = useCallback( + (password: string): Promise => { + setIsBusy(true); + + return approveSignPassword(signId, password, !!(password && isSavedPass)) + .then((): void => { + setIsBusy(false); + onAction(); + }) + .catch((error: Error): void => { + setIsBusy(false); + setError(error.message); + console.error(error); + }); + }, + [onAction, isSavedPass, signId] + ); + + const _onSignQuick = useCallback( + () => _onSign(''), + [_onSign] + ); + + const _onSignature = useCallback( + ({ signature }: { signature: string }): Promise => + approveSignSignature(signId, signature) + .then(() => onAction()) + .catch((error: Error): void => { + setError(error.message); + console.error(error); + }), + [onAction, signId] + ); + + const signButton = isLocked + ? ( + + + + ) + : ( + + ); if (payload !== null) { const json = request.payload as SignerPayloadJSON; @@ -108,13 +164,7 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst, ) } - {isFirst && !isExternal && ( - - )} + {(isLocked !== null) && isFirst && !isExternal && signButton} - {!isExternal && ( - - )} + {!isExternal && signButton} Promise; buttonText?: string; } -function Unlock ({ buttonText = 'Sign the transaction', className, error, onSign }: Props): React.ReactElement { - const [isBusy, setIsBusy] = useState(false); +function Unlock ({ buttonText = 'Sign the transaction', children, className, error, isBusy, onSign }: Props): React.ReactElement { const [ownError, setError] = useState(); const [password, setPassword] = useState(''); @@ -29,16 +30,9 @@ function Unlock ({ buttonText = 'Sign the transaction', className, error, onSign }, [] ); + const _onClick = useCallback( - (): void => { - setIsBusy(true); - onSign(password) - .then(() => setIsBusy(false)) - .catch((error: Error): void => { - setIsBusy(false); - setError(error.message); - }); - }, + () => onSign(password), [onSign, password] ); @@ -52,7 +46,9 @@ function Unlock ({ buttonText = 'Sign the transaction', className, error, onSign onChange={_onChangePassword} onEnter={_onClick} type='password' + withoutMargin={!!children} /> + {children}