// Copyright 2019-2026 @polkadot/extension-base authors & contributors // SPDX-License-Identifier: Apache-2.0 /* global chrome */ import type { MetadataDef } from '@polkadot/extension-inject/types'; import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types'; import type { Registry } from '@polkadot/types/types'; import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; import type { KeypairType } from '@polkadot/util-crypto/types'; import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js'; import type { AuthorizedAccountsDiff } from './State.js'; import type State from './State.js'; import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults'; import { metadataExpand } from '@polkadot/extension-chains'; import { TypeRegistry } from '@polkadot/types'; import { keyring } from '@polkadot/ui-keyring'; import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts'; import { assert, isHex } from '@polkadot/util'; import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto'; import { isExtrinsicRequest } from '../../utils/index.js'; import { withErrorLog } from './helpers.js'; import { createSubscription, unsubscribe } from './subscriptions.js'; type CachedUnlocks = Record; const SEED_DEFAULT_LENGTH = 12; const SEED_LENGTHS = [12, 15, 18, 21, 24]; const ETH_DERIVE_DEFAULT = "/m/44'/60'/0'/0/0"; function getSuri (seed: string, type?: KeypairType): string { return type === 'ethereum' ? `${seed}${ETH_DERIVE_DEFAULT}` : seed; } export default class Extension { readonly #cachedUnlocks: CachedUnlocks; readonly #state: State; constructor (state: State) { this.#cachedUnlocks = {}; this.#state = state; } private transformAccounts (accounts: SubjectInfo): AccountJson[] { return Object.values(accounts).map(({ json: { address, meta }, type }): AccountJson => ({ address, isDefaultAuthSelected: this.#state.defaultAuthAccountSelection.includes(address), ...meta, type })); } private accountsCreateExternal ({ address, genesisHash, name }: RequestAccountCreateExternal): boolean { keyring.addExternal(address, { genesisHash, name }); return true; } private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name, type }: RequestAccountCreateHardware): boolean { keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name, type }); return true; } private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean { keyring.addUri(getSuri(suri, type), 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 { 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 async accountsForget ({ address }: RequestAccountForget): Promise { const authorizedAccountsDiff: AuthorizedAccountsDiff = []; // cycle through authUrls and prepare the array of diff Object.entries(this.#state.authUrls).forEach(([url, urlInfo]) => { // Note that urlInfo.authorizedAccounts may be undefined if this website entry // was created before the "account authorization per website" functionality was introduced if (urlInfo.authorizedAccounts?.includes(address)) { authorizedAccountsDiff.push([url, urlInfo.authorizedAccounts.filter((previousAddress) => previousAddress !== address)]); } }); await this.#state.updateAuthorizedAccounts(authorizedAccountsDiff); // cycle through default account selection for auth and remove any occurrence of the account const newDefaultAuthAccounts = this.#state.defaultAuthAccountSelection.filter((defaultSelectionAddress) => defaultSelectionAddress !== address); await this.#state.updateDefaultAuthAccounts(newDefaultAuthAccounts); 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 { return false; } } private accountsSubscribe (id: string, port: chrome.runtime.Port): boolean { const cb = createSubscription<'pri(accounts.subscribe)'>(id, port); const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo): void => cb(this.transformAccounts(accounts)) ); port.onDisconnect.addListener((): void => { unsubscribe(id); subscription.unsubscribe(); }); return true; } private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean { const queued = this.#state.getAuthRequest(id); assert(queued, 'Unable to find request'); const { resolve } = queued; resolve({ authorizedAccounts, result: true }); return true; } private async authorizeUpdate ({ authorizedAccounts, url }: RequestUpdateAuthorizedAccounts): Promise { return await this.#state.updateAuthorizedAccounts([[url, authorizedAccounts]]); } private getAuthList (): ResponseAuthorizeList { return { list: this.#state.authUrls }; } // 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[]): void => cb(requests) ); port.onDisconnect.addListener((): void => { unsubscribe(id); subscription.unsubscribe(); }); return true; } private async metadataApprove ({ id }: RequestMetadataApprove): Promise { const queued = this.#state.getMetaRequest(id); assert(queued, 'Unable to find request'); const { request, resolve } = queued; await 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[]): void => cb(requests) ); port.onDisconnect.addListener((): void => { unsubscribe(id); subscription.unsubscribe(); }); return true; } private jsonRestore ({ file, password }: RequestJsonRestore): void { try { keyring.restoreAccount(file, password); } catch (error) { throw new Error((error as Error).message); } } private batchRestore ({ file, password }: RequestBatchRestore): void { 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, seed: _seed, type }: RequestSeedCreate): ResponseSeedCreate { const seed = _seed || mnemonicGenerate(length); return { address: keyring.createFromUri(getSuri(seed, type), {}, 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(getSuri(suri, type), {}, 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); 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); } // construct a new registry (avoiding pollution), between requests let registry: Registry; if (isExtrinsicRequest(request)) { const payload = request.payload; // Get the metadata for the genesisHash const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash); if (metadata) { // we have metadata, expand it and extract the info/registry const expanded = metadataExpand(metadata, false); registry = expanded.registry; registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions); } else { // we have no metadata, create a new registry registry = new TypeRegistry(); registry.setSignedExtensions(payload.signedExtensions); } } else { // for non-payload, just create a registry to use registry = new TypeRegistry(); } const result = request.sign(registry, pair); if (savePass) { // unlike queued.account.address the following // address is encoded with the default prefix // which what is used for password caching mapping this.#cachedUnlocks[pair.address] = Date.now() + PASSWORD_EXPIRY_MS; } else { pair.lock(); } resolve({ id, ...result }); return true; } private signingApproveSignature ({ id, signature, signedTransaction }: RequestSigningApproveSignature): boolean { const queued = this.#state.getSignRequest(id); assert(queued, 'Unable to find request'); const { resolve } = queued; resolve({ id, signature, signedTransaction }); 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[]): void => cb(requests) ); port.onDisconnect.addListener((): void => { unsubscribe(id); subscription.unsubscribe(); }); return true; } private windowOpen (path: AllowedPath): boolean { const url = `${chrome.runtime.getURL('index.html')}#${path}`; if (!ALLOWED_PATH.includes(path)) { console.error('Not allowed to open the url:', url); return false; } withErrorLog(() => 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 { throw new Error('invalid password'); } try { return parentPair.derive(suri, metadata); } catch { 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 async removeAuthorization (url: string): Promise { const remAuth = await this.#state.removeAuthorization(url); return { list: remAuth }; } // Reject the authorization request and add the URL to the authorized list with no keys. // The site will not prompt for re-authorization on future visits. private rejectAuthRequest (id: string): void { const queued = this.#state.getAuthRequest(id); assert(queued, 'Unable to find request'); const { reject } = queued; reject(new Error('Rejected')); } // Cancel the authorization request and do not add the URL to the authorized list. // The site will prompt for authorization on future visits. private cancelAuthRequest (id: string): void { const queued = this.#state.getAuthRequest(id); assert(queued, 'Unable to find request'); const { reject } = queued; reject(new Error('Cancelled')); } private updateCurrentTabs ({ urls }: RequestActiveTabsUrlUpdate) { this.#state.updateCurrentTabsUrl(urls); } private getConnectedTabsUrl () { return this.#state.getConnectedTabsUrl(); } // Weird thought, the eslint override is not needed in Tabs // eslint-disable-next-line @typescript-eslint/require-await 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.remove)': return this.removeAuthorization(request as string); case 'pri(authorize.reject)': return this.rejectAuthRequest(request as string); case 'pri(authorize.cancel)': return this.cancelAuthRequest(request as string); case 'pri(authorize.requests)': return port && this.authorizeSubscribe(id, port); case 'pri(authorize.update)': return this.authorizeUpdate(request as RequestUpdateAuthorizedAccounts); 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 port && 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 await 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 port && this.metadataSubscribe(id, port); case 'pri(activeTabsUrl.update)': return this.updateCurrentTabs(request as RequestActiveTabsUrlUpdate); case 'pri(connectedTabsUrl.get)': return this.getConnectedTabsUrl(); 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(ping)': return Promise.resolve(true); 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 port && 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}`); } } }