import { createSubscription, unsubscribe, } from '@polkadot/extension-base/background/handlers/subscriptions' import { 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 '@polkadot/extension-base/background/types' import { ALLOWED_PATH, PASSWORD_EXPIRY_MS, } from '@polkadot/extension-base/defaults' import chrome from '@polkadot/extension-inject/chrome' import { MetadataDef } from '@polkadot/extension-inject/types' import { KeyringPair, KeyringPair$Json, KeyringPair$Meta, } from '@polkadot/keyring/types' import keyring from '@polkadot/ui-keyring' import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts' import { SubjectInfo } from '@polkadot/ui-keyring/observable/types' import { assert, isHex } from '@polkadot/util' import { keyExtractSuri, mnemonicGenerate, mnemonicValidate, } from '@polkadot/util-crypto' import { REGISTRY } from '../utils/constants' import { isJsonPayload } from '../utils/guards' import { transformExtensionAccounts } from '../utils/transformExtensionAccounts' import { State } from './State' type CachedUnlocks = Record const SEED_DEFAULT_LENGTH = 12 const SEED_LENGTHS = [12, 15, 18, 21, 24] export class Extension { readonly #cachedUnlocks: CachedUnlocks = {} readonly #state: State constructor(state: State) { this.#state = state } public async handle( id: string, type: TMessageType, request: RequestTypes[TMessageType], port: chrome.runtime.Port ): Promise> { switch (type) { case 'pri(authorize.approve)': return this.authorizeApprove(request as RequestAuthorizeApprove) case 'pri(authorize.list)': return this.getAuthList() case 'pri(authorize.reject)': return this.authorizeReject(request as RequestAuthorizeReject) case 'pri(authorize.toggle)': return this.toggleAuthorization(request as string) case 'pri(authorize.requests)': return this.authorizeSubscribe(id, port) case 'pri(accounts.create.external)': return this.accountsCreateExternal( request as RequestAccountCreateExternal ) case 'pri(accounts.create.hardware)': return this.accountsCreateHardware( request as RequestAccountCreateHardware ) case 'pri(accounts.create.suri)': return this.accountsCreateSuri(request as RequestAccountCreateSuri) case 'pri(accounts.changePassword)': return this.accountsChangePassword( request as RequestAccountChangePassword ) case 'pri(accounts.edit)': return this.accountsEdit(request as RequestAccountEdit) case 'pri(accounts.export)': return this.accountsExport(request as RequestAccountExport) case 'pri(accounts.batchExport)': return this.accountsBatchExport(request as RequestAccountBatchExport) case 'pri(accounts.forget)': return this.accountsForget(request as RequestAccountForget) case 'pri(accounts.show)': return this.accountsShow(request as RequestAccountShow) case 'pri(accounts.subscribe)': return this.accountsSubscribe(id, port) case 'pri(accounts.tie)': return this.accountsTie(request as RequestAccountTie) case 'pri(accounts.validate)': return this.accountsValidate(request as RequestAccountValidate) case 'pri(metadata.approve)': return this.metadataApprove(request as RequestMetadataApprove) case 'pri(metadata.get)': return this.metadataGet(request as string) case 'pri(metadata.list)': return this.metadataList() case 'pri(metadata.reject)': return this.metadataReject(request as RequestMetadataReject) case 'pri(metadata.requests)': return this.metadataSubscribe(id, port) case 'pri(derivation.create)': return this.derivationCreate(request as RequestDeriveCreate) case 'pri(derivation.validate)': return this.derivationValidate(request as RequestDeriveValidate) case 'pri(json.restore)': return this.jsonRestore(request as RequestJsonRestore) case 'pri(json.batchRestore)': return this.batchRestore(request as RequestBatchRestore) case 'pri(json.account.info)': return this.jsonGetAccountInfo(request as KeyringPair$Json) case 'pri(seed.create)': return this.seedCreate(request as RequestSeedCreate) case 'pri(seed.validate)': return this.seedValidate(request as RequestSeedValidate) case 'pri(settings.notification)': return this.#state.setNotification(request as string) case 'pri(signing.approve.password)': return this.signingApprovePassword( request as RequestSigningApprovePassword ) case 'pri(signing.approve.signature)': return this.signingApproveSignature( request as RequestSigningApproveSignature ) 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) case 'pri(window.open)': return this.windowOpen(request as AllowedPath) default: throw new Error(`Unable to handle message of type ${type}`) } } private accountsCreateExternal({ address, genesisHash, name, }: RequestAccountCreateExternal): boolean { keyring.addExternal(address, { genesisHash, name }) return true } private accountsCreateHardware({ accountIndex, address, addressOffset, genesisHash, hardwareType, name, }: RequestAccountCreateHardware): boolean { keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name, }) return true } private accountsCreateSuri({ genesisHash, name, password, suri, type, }: RequestAccountCreateSuri): boolean { keyring.addUri(suri, password, { genesisHash, name }, type) return true } private accountsChangePassword({ address, newPass, oldPass, }: RequestAccountChangePassword): boolean { const pair = keyring.getPair(address) assert(pair, 'Unable to find pair') try { if (!pair.isLocked) pair.lock() pair.decodePkcs8(oldPass) } catch (error) { throw new Error('oldPass is invalid') } keyring.encryptAccount(pair, newPass) return true } private accountsEdit({ address, name }: RequestAccountEdit): boolean { const pair = keyring.getPair(address) assert(pair, 'Unable to find pair') keyring.saveAccountMeta(pair, { ...pair.meta, name }) return true } private accountsExport({ address, password, }: RequestAccountExport): ResponseAccountExport { return { exportedJson: keyring.backupAccount(keyring.getPair(address), password), } } private async accountsBatchExport({ addresses, password, }: RequestAccountBatchExport): Promise { return { exportedJson: await keyring.backupAccounts(addresses, password), } } private accountsForget({ address }: RequestAccountForget): boolean { keyring.forgetAccount(address) return true } private refreshAccountPasswordCache(pair: KeyringPair): number { const { address } = pair const savedExpiry = this.#cachedUnlocks[address] || 0 const remainingTime = savedExpiry - Date.now() if (remainingTime < 0) { this.#cachedUnlocks[address] = 0 pair.lock() return 0 } return remainingTime } private accountsShow({ address, isShowing }: RequestAccountShow): boolean { const pair = keyring.getPair(address) assert(pair, 'Unable to find pair') keyring.saveAccountMeta(pair, { ...pair.meta, isHidden: !isShowing }) return true } private accountsTie({ address, genesisHash }: RequestAccountTie): boolean { const pair = keyring.getPair(address) assert(pair, 'Unable to find pair') keyring.saveAccountMeta(pair, { ...pair.meta, genesisHash }) return true } private accountsValidate({ address, password, }: RequestAccountValidate): boolean { try { keyring.backupAccount(keyring.getPair(address), password) return true } catch (e) { return false } } // FIXME This looks very much like what we have in Tabs private accountsSubscribe(id: string, port: chrome.runtime.Port): boolean { const cb = createSubscription<'pri(accounts.subscribe)'>(id, port) const subscription = accountsObservable.subject.subscribe( (accounts: SubjectInfo) => cb(transformExtensionAccounts(accounts)) ) port.onDisconnect.addListener(() => { unsubscribe(id) subscription.unsubscribe() }) return true } private authorizeApprove({ id }: RequestAuthorizeApprove): boolean { const queued = this.#state.getAuthRequest(id) assert(queued, 'Unable to find request') const { resolve } = queued resolve(true) return true } private getAuthList(): ResponseAuthorizeList { return { list: this.#state.authUrls } } private authorizeReject({ id }: RequestAuthorizeReject): boolean { const queued = this.#state.getAuthRequest(id) assert(queued, 'Unable to find request') const { reject } = queued reject(new Error('Rejected')) return true } // FIXME This looks very much like what we have in accounts private authorizeSubscribe(id: string, port: chrome.runtime.Port): boolean { const cb = createSubscription<'pri(authorize.requests)'>(id, port) const subscription = this.#state.authSubject.subscribe( (requests: AuthorizeRequest[]) => cb(requests) ) port.onDisconnect.addListener(() => { unsubscribe(id) subscription.unsubscribe() }) return true } private metadataApprove({ id }: RequestMetadataApprove): boolean { const queued = this.#state.getMetaRequest(id) assert(queued, 'Unable to find request') const { request, resolve } = queued this.#state.saveMetadata(request) resolve(true) return true } private metadataGet(genesisHash: string | null): MetadataDef | null { return ( this.#state.knownMetadata.find( (result) => result.genesisHash === genesisHash ) || null ) } private metadataList(): MetadataDef[] { return this.#state.knownMetadata } private metadataReject({ id }: RequestMetadataReject): boolean { const queued = this.#state.getMetaRequest(id) assert(queued, 'Unable to find request') const { reject } = queued reject(new Error('Rejected')) return true } private metadataSubscribe(id: string, port: chrome.runtime.Port): boolean { const cb = createSubscription<'pri(metadata.requests)'>(id, port) const subscription = this.#state.metaSubject.subscribe( (requests: MetadataRequest[]) => cb(requests) ) port.onDisconnect.addListener(() => { unsubscribe(id) subscription.unsubscribe() }) return true } private jsonRestore({ file, password }: RequestJsonRestore) { try { keyring.restoreAccount(file, password) } catch (error) { throw new Error((error as Error).message) } } private batchRestore({ file, password }: RequestBatchRestore) { try { keyring.restoreAccounts(file, password) } catch (error) { throw new Error((error as Error).message) } } private jsonGetAccountInfo( json: KeyringPair$Json ): ResponseJsonGetAccountInfo { try { const { address, meta: { genesisHash, name }, type, } = keyring.createFromJson(json) return { address, genesisHash, name, type, } as ResponseJsonGetAccountInfo } catch (e) { console.error(e) throw new Error((e as Error).message) } } private seedCreate({ length = SEED_DEFAULT_LENGTH, type, }: RequestSeedCreate): ResponseSeedCreate { const seed = mnemonicGenerate(length) return { address: keyring.createFromUri(seed, {}, type).address, seed, } } private seedValidate({ suri, type, }: RequestSeedValidate): ResponseSeedValidate { const { phrase } = keyExtractSuri(suri) if (isHex(phrase)) { assert(isHex(phrase, 256), 'Hex seed needs to be 256-bits') } else { // sadly isHex detects as string, so we need a cast here assert( SEED_LENGTHS.includes(phrase.split(' ').length), `Mnemonic needs to contain ${SEED_LENGTHS.join(', ')} words` ) assert(mnemonicValidate(phrase), 'Not a valid mnemonic seed') } return { address: keyring.createFromUri(suri, {}, type).address, suri, } } private signingApprovePassword({ id, password, savePass, }: RequestSigningApprovePassword): boolean { const queued = this.#state.getSignRequest(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.sign(REGISTRY, pair) if (savePass) { this.#cachedUnlocks[address] = Date.now() + PASSWORD_EXPIRY_MS } else { pair.lock() } resolve({ id, ...result }) return true } private signingApproveSignature({ id, signature, }: RequestSigningApproveSignature): boolean { const queued = this.#state.getSignRequest(id) assert(queued, 'Unable to find request') const { resolve } = queued resolve({ id, signature }) return true } private signingCancel({ id }: RequestSigningCancel): boolean { const queued = this.#state.getSignRequest(id) assert(queued, 'Unable to find request') const { reject } = queued reject(new Error('Cancelled')) return true } private signingIsLocked({ id, }: RequestSigningIsLocked): ResponseSigningIsLocked { const queued = this.#state.getSignRequest(id) 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, } } // 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) const subscription = this.#state.signSubject.subscribe( (requests: SigningRequest[]) => cb(requests) ) port.onDisconnect.addListener(() => { unsubscribe(id) subscription.unsubscribe() }) return true } private windowOpen(path: AllowedPath): boolean { const url = `${chrome.extension.getURL('index.html')}#${path}` if (!ALLOWED_PATH.includes(path)) { console.error('Not allowed to open the url:', url) return false } console.log('open', url) void chrome.tabs.create({ url }) return true } private derive( parentAddress: string, suri: string, password: string, metadata: KeyringPair$Meta ): KeyringPair { const parentPair = keyring.getPair(parentAddress) try { parentPair.decodePkcs8(password) } catch (e) { throw new Error('invalid password') } try { return parentPair.derive(suri, metadata) } catch (err) { throw new Error(`"${suri}" is not a valid derivation path`) } } private derivationValidate({ parentAddress, parentPassword, suri, }: RequestDeriveValidate): ResponseDeriveValidate { const childPair = this.derive(parentAddress, suri, parentPassword, {}) return { address: childPair.address, suri, } } private derivationCreate({ genesisHash, name, parentAddress, parentPassword, password, suri, }: RequestDeriveCreate): boolean { const childPair = this.derive(parentAddress, suri, parentPassword, { genesisHash, name, parentAddress, suri, }) keyring.addPair(childPair, password) return true } private toggleAuthorization(url: string): ResponseAuthorizeList { return { list: this.#state.toggleAuthorization(url) } } }