import nanoid from 'nanoid'; import { JsonRpcEngine } from 'json-rpc-engine'; import { ObservableStore } from '@metamask/obs-store'; import log from 'loglevel'; import { CapabilitiesController as RpcCap } from 'rpc-cap'; import { ethErrors } from 'eth-rpc-errors'; import { cloneDeep } from 'lodash'; import { CAVEAT_NAMES } from '../../../../shared/constants/permissions'; import { APPROVAL_TYPE, SAFE_METHODS, // methods that do not require any permissions to use WALLET_PREFIX, METADATA_STORE_KEY, METADATA_CACHE_MAX_SIZE, LOG_STORE_KEY, HISTORY_STORE_KEY, NOTIFICATION_NAMES, CAVEAT_TYPES, } from './enums'; import createPermissionsMethodMiddleware from './permissionsMethodMiddleware'; import PermissionsLogController from './permissionsLog'; // instanbul ignore next const noop = () => undefined; export class PermissionsController { constructor( { approvals, getKeyringAccounts, getRestrictedMethods, getUnlockPromise, isUnlocked, notifyDomain, notifyAllDomains, preferences, } = {}, restoredPermissions = {}, restoredState = {}, ) { // additional top-level store key set in _initializeMetadataStore this.store = new ObservableStore({ [LOG_STORE_KEY]: restoredState[LOG_STORE_KEY] || [], [HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {}, }); this.getKeyringAccounts = getKeyringAccounts; this._getUnlockPromise = getUnlockPromise; this._notifyDomain = notifyDomain; this._notifyAllDomains = notifyAllDomains; this._isUnlocked = isUnlocked; this._restrictedMethods = getRestrictedMethods({ getKeyringAccounts: this.getKeyringAccounts.bind(this), getIdentities: this._getIdentities.bind(this), }); this.permissionsLog = new PermissionsLogController({ restrictedMethods: Object.keys(this._restrictedMethods), store: this.store, }); /** * @type {import('@metamask/controllers').ApprovalController} * @public */ this.approvals = approvals; this._initializePermissions(restoredPermissions); this._lastSelectedAddress = preferences.getState().selectedAddress; this.preferences = preferences; this._initializeMetadataStore(restoredState); preferences.subscribe(async ({ selectedAddress }) => { if (selectedAddress && selectedAddress !== this._lastSelectedAddress) { this._lastSelectedAddress = selectedAddress; await this._handleAccountSelected(selectedAddress); } }); } createMiddleware({ origin, extensionId }) { if (typeof origin !== 'string' || !origin.length) { throw new Error('Must provide non-empty string origin.'); } const metadataState = this.store.getState()[METADATA_STORE_KEY]; if (extensionId && metadataState[origin]?.extensionId !== extensionId) { this.addDomainMetadata(origin, { extensionId }); } const engine = new JsonRpcEngine(); engine.push(this.permissionsLog.createMiddleware()); engine.push( createPermissionsMethodMiddleware({ addDomainMetadata: this.addDomainMetadata.bind(this), getAccounts: this.getAccounts.bind(this, origin), getUnlockPromise: () => this._getUnlockPromise(true), hasPermission: this.hasPermission.bind(this, origin), notifyAccountsChanged: this.notifyAccountsChanged.bind(this, origin), requestAccountsPermission: this._requestPermissions.bind( this, { origin }, { eth_accounts: {} }, ), }), ); engine.push( this.permissions.providerMiddlewareFunction.bind(this.permissions, { origin, }), ); return engine.asMiddleware(); } /** * Request {@code eth_accounts} permissions * @param {string} origin - The requesting origin * @returns {Promise} The permissions request ID */ async requestAccountsPermissionWithId(origin) { const id = nanoid(); this._requestPermissions({ origin }, { eth_accounts: {} }, id).then( async () => { const permittedAccounts = await this.getAccounts(origin); this.notifyAccountsChanged(origin, permittedAccounts); }, ); return id; } /** * Returns the accounts that should be exposed for the given origin domain, * if any. This method exists for when a trusted context needs to know * which accounts are exposed to a given domain. * * @param {string} origin - The origin string. */ getAccounts(origin) { return new Promise((resolve, _) => { const req = { method: 'eth_accounts' }; const res = {}; this.permissions.providerMiddlewareFunction( { origin }, req, res, noop, _end, ); function _end() { if (res.error || !Array.isArray(res.result)) { resolve([]); } else { resolve(res.result); } } }); } /** * Returns whether the given origin has the given permission. * * @param {string} origin - The origin to check. * @param {string} permission - The permission to check for. * @returns {boolean} Whether the origin has the permission. */ hasPermission(origin, permission) { return Boolean(this.permissions.getPermission(origin, permission)); } /** * Gets the identities from the preferences controller store * * @returns {Object} identities */ _getIdentities() { return this.preferences.getState().identities; } /** * Submits a permissions request to rpc-cap. Internal, background use only. * * @param {IOriginMetadata} domain - The external domain metadata. * @param {IRequestedPermissions} permissions - The requested permissions. * @param {string} [id] - The desired id of the permissions request, if any. * @returns {Promise} A Promise that resolves with the * approved permissions, or rejects with an error. */ _requestPermissions(domain, permissions, id) { return new Promise((resolve, reject) => { // rpc-cap assigns an id to the request if there is none, as expected by // requestUserApproval below const req = { id, method: 'wallet_requestPermissions', params: [permissions], }; const res = {}; this.permissions.providerMiddlewareFunction(domain, req, res, noop, _end); function _end(_err) { const err = _err || res.error; if (err) { reject(err); } else { resolve(res.result); } } }); } /** * User approval callback. Resolves the Promise for the permissions request * waited upon by rpc-cap, see requestUserApproval in _initializePermissions. * The request will be rejected if finalizePermissionsRequest fails. * Idempotent for a given request id. * * @param {Object} approved - The request object approved by the user * @param {Array} accounts - The accounts to expose, if any */ async approvePermissionsRequest(approved, accounts) { const { id } = approved.metadata; if (!this.approvals.has({ id })) { log.debug(`Permissions request with id '${id}' not found.`); return; } try { if (Object.keys(approved.permissions).length === 0) { this.approvals.reject( id, ethErrors.rpc.invalidRequest({ message: 'Must request at least one permission.', }), ); } else { // attempt to finalize the request and resolve it, // settings caveats as necessary approved.permissions = await this.finalizePermissionsRequest( approved.permissions, accounts, ); this.approvals.accept(id, approved.permissions); } } catch (err) { // if finalization fails, reject the request this.approvals.reject( id, ethErrors.rpc.invalidRequest({ message: err.message, data: err, }), ); } } /** * User rejection callback. Rejects the Promise for the permissions request * waited upon by rpc-cap, see requestUserApproval in _initializePermissions. * Idempotent for a given id. * * @param {string} id - The id of the request rejected by the user */ async rejectPermissionsRequest(id) { if (!this.approvals.has({ id })) { log.debug(`Permissions request with id '${id}' not found.`); return; } this.approvals.reject(id, ethErrors.provider.userRejectedRequest()); } /** * Expose an account to the given origin. Changes the eth_accounts * permissions and emits accountsChanged. * * Throws error if the origin or account is invalid, or if the update fails. * * @param {string} origin - The origin to expose the account to. * @param {string} account - The new account to expose. */ async addPermittedAccount(origin, account) { const domains = this.permissions.getDomains(); if (!domains[origin]) { throw new Error('Unrecognized domain'); } this.validatePermittedAccounts([account]); const oldPermittedAccounts = this._getPermittedAccounts(origin); if (oldPermittedAccounts.length === 0) { throw new Error(`Origin does not have 'eth_accounts' permission`); } else if (oldPermittedAccounts.includes(account)) { throw new Error('Account is already permitted for origin'); } this.permissions.updateCaveatFor( origin, 'eth_accounts', CAVEAT_NAMES.exposedAccounts, [...oldPermittedAccounts, account], ); const permittedAccounts = await this.getAccounts(origin); this.notifyAccountsChanged(origin, permittedAccounts); } /** * Removes an exposed account from the given origin. Changes the eth_accounts * permission and emits accountsChanged. * If origin only has a single permitted account, removes the eth_accounts * permission from the origin. * * Throws error if the origin or account is invalid, or if the update fails. * * @param {string} origin - The origin to remove the account from. * @param {string} account - The account to remove. */ async removePermittedAccount(origin, account) { const domains = this.permissions.getDomains(); if (!domains[origin]) { throw new Error('Unrecognized domain'); } this.validatePermittedAccounts([account]); const oldPermittedAccounts = this._getPermittedAccounts(origin); if (oldPermittedAccounts.length === 0) { throw new Error(`Origin does not have 'eth_accounts' permission`); } else if (!oldPermittedAccounts.includes(account)) { throw new Error('Account is not permitted for origin'); } let newPermittedAccounts = oldPermittedAccounts.filter( (acc) => acc !== account, ); if (newPermittedAccounts.length === 0) { this.removePermissionsFor({ [origin]: ['eth_accounts'] }); } else { this.permissions.updateCaveatFor( origin, 'eth_accounts', CAVEAT_NAMES.exposedAccounts, newPermittedAccounts, ); newPermittedAccounts = await this.getAccounts(origin); } this.notifyAccountsChanged(origin, newPermittedAccounts); } /** * Remove all permissions associated with a particular account. Any eth_accounts * permissions left with no permitted accounts will be removed as well. * * Throws error if the account is invalid, or if the update fails. * * @param {string} account - The account to remove. */ async removeAllAccountPermissions(account) { this.validatePermittedAccounts([account]); const domains = this.permissions.getDomains(); const connectedOrigins = Object.keys(domains).filter((origin) => this._getPermittedAccounts(origin).includes(account), ); await Promise.all( connectedOrigins.map((origin) => this.removePermittedAccount(origin, account), ), ); } /** * Finalizes a permissions request. Throws if request validation fails. * Clones the passed-in parameters to prevent inadvertent modification. * Sets (adds or replaces) caveats for the following permissions: * - eth_accounts: the permitted accounts caveat * * @param {Object} requestedPermissions - The requested permissions. * @param {string[]} requestedAccounts - The accounts to expose, if any. * @returns {Object} The finalized permissions request object. */ async finalizePermissionsRequest(requestedPermissions, requestedAccounts) { const finalizedPermissions = cloneDeep(requestedPermissions); const finalizedAccounts = cloneDeep(requestedAccounts); const { eth_accounts: ethAccounts } = finalizedPermissions; if (ethAccounts) { this.validatePermittedAccounts(finalizedAccounts); if (!ethAccounts.caveats) { ethAccounts.caveats = []; } // caveat names are unique, and we will only construct this caveat here ethAccounts.caveats = ethAccounts.caveats.filter( (c) => c.name !== CAVEAT_NAMES.exposedAccounts && c.name !== CAVEAT_NAMES.primaryAccountOnly, ); ethAccounts.caveats.push({ type: CAVEAT_TYPES.limitResponseLength, value: 1, name: CAVEAT_NAMES.primaryAccountOnly, }); ethAccounts.caveats.push({ type: CAVEAT_TYPES.filterResponse, value: finalizedAccounts, name: CAVEAT_NAMES.exposedAccounts, }); } return finalizedPermissions; } /** * Validate an array of accounts representing accounts to be exposed * to a domain. Throws error if validation fails. * * @param {string[]} accounts - An array of addresses. */ validatePermittedAccounts(accounts) { if (!Array.isArray(accounts) || accounts.length === 0) { throw new Error('Must provide non-empty array of account(s).'); } // assert accounts exist const allIdentities = this._getIdentities(); accounts.forEach((acc) => { if (!allIdentities[acc]) { throw new Error(`Unknown account: ${acc}`); } }); } /** * Notify a domain that its permitted accounts have changed. * Also updates the accounts history log. * * @param {string} origin - The origin of the domain to notify. * @param {Array} newAccounts - The currently permitted accounts. */ notifyAccountsChanged(origin, newAccounts) { if (typeof origin !== 'string' || !origin) { throw new Error(`Invalid origin: '${origin}'`); } if (!Array.isArray(newAccounts)) { throw new Error('Invalid accounts', newAccounts); } // We do not share accounts when the extension is locked. if (this._isUnlocked()) { this._notifyDomain(origin, { method: NOTIFICATION_NAMES.accountsChanged, params: newAccounts, }); this.permissionsLog.updateAccountsHistory(origin, newAccounts); } // NOTE: // We don't check for accounts changing in the notifyAllDomains case, // because the log only records when accounts were last seen, and the // the accounts only change for all domains at once when permissions are // removed. } /** * Removes the given permissions for the given domain. * Should only be called after confirming that the permissions exist, to * avoid sending unnecessary notifications. * * @param {Object} domains - The map of domain origins to permissions to remove. * e.g. { origin: [permissions] } */ removePermissionsFor(domains) { Object.entries(domains).forEach(([origin, perms]) => { this.permissions.removePermissionsFor( origin, perms.map((methodName) => { if (methodName === 'eth_accounts') { this.notifyAccountsChanged(origin, []); } return { parentCapability: methodName }; }), ); }); } /** * Removes all known domains and their related permissions. */ clearPermissions() { this.permissions.clearDomains(); // It's safe to notify that no accounts are available, regardless of // extension lock state this._notifyAllDomains({ method: NOTIFICATION_NAMES.accountsChanged, params: [], }); } /** * Stores domain metadata for the given origin (domain). * Deletes metadata for domains without permissions in a FIFO manner, once * more than 100 distinct origins have been added since boot. * Metadata is never deleted for domains with permissions, to prevent a * degraded user experience, since metadata cannot yet be requested on demand. * * @param {string} origin - The origin whose domain metadata to store. * @param {Object} metadata - The domain's metadata that will be stored. */ addDomainMetadata(origin, metadata) { const oldMetadataState = this.store.getState()[METADATA_STORE_KEY]; const newMetadataState = { ...oldMetadataState }; // delete pending metadata origin from queue, and delete its metadata if // it doesn't have any permissions if (this._pendingSiteMetadata.size >= METADATA_CACHE_MAX_SIZE) { const permissionsDomains = this.permissions.getDomains(); const oldOrigin = this._pendingSiteMetadata.values().next().value; this._pendingSiteMetadata.delete(oldOrigin); if (!permissionsDomains[oldOrigin]) { delete newMetadataState[oldOrigin]; } } // add new metadata to store after popping newMetadataState[origin] = { ...oldMetadataState[origin], ...metadata, lastUpdated: Date.now(), }; if ( !newMetadataState[origin].extensionId && !newMetadataState[origin].host ) { newMetadataState[origin].host = new URL(origin).host; } this._pendingSiteMetadata.add(origin); this._setDomainMetadata(newMetadataState); } /** * Removes all domains without permissions from the restored metadata state, * and rehydrates the metadata store. * * Requires PermissionsController._initializePermissions to have been called first. * * @param {Object} restoredState - The restored permissions controller state. */ _initializeMetadataStore(restoredState) { const metadataState = restoredState[METADATA_STORE_KEY] || {}; const newMetadataState = this._trimDomainMetadata(metadataState); this._pendingSiteMetadata = new Set(); this._setDomainMetadata(newMetadataState); } /** * Trims the given metadataState object by removing metadata for all origins * without permissions. * Returns a new object; does not mutate the argument. * * @param {Object} metadataState - The metadata store state object to trim. * @returns {Object} The new metadata state object. */ _trimDomainMetadata(metadataState) { const newMetadataState = { ...metadataState }; const origins = Object.keys(metadataState); const permissionsDomains = this.permissions.getDomains(); origins.forEach((origin) => { if (!permissionsDomains[origin]) { delete newMetadataState[origin]; } }); return newMetadataState; } /** * Replaces the existing domain metadata with the passed-in object. * @param {Object} newMetadataState - The new metadata to set. */ _setDomainMetadata(newMetadataState) { this.store.updateState({ [METADATA_STORE_KEY]: newMetadataState }); } /** * Get current set of permitted accounts for the given origin * * @param {string} origin - The origin to obtain permitted accounts for * @returns {Array} The list of permitted accounts */ _getPermittedAccounts(origin) { const permittedAccounts = this.permissions .getPermission(origin, 'eth_accounts') ?.caveats?.find((caveat) => caveat.name === CAVEAT_NAMES.exposedAccounts) ?.value; return permittedAccounts || []; } /** * When a new account is selected in the UI, emit accountsChanged to each origin * where the selected account is exposed. * * Note: This will emit "false positive" accountsChanged events, but they are * handled by the inpage provider. * * @param {string} account - The newly selected account's address. */ async _handleAccountSelected(account) { if (typeof account !== 'string') { throw new Error('Selected account should be a non-empty string.'); } const domains = this.permissions.getDomains() || {}; const connectedDomains = Object.entries(domains) .filter(([_, { permissions }]) => { const ethAccounts = permissions.find( (permission) => permission.parentCapability === 'eth_accounts', ); const exposedAccounts = ethAccounts?.caveats.find( (caveat) => caveat.name === 'exposedAccounts', )?.value; return exposedAccounts?.includes(account); }) .map(([domain]) => domain); await Promise.all( connectedDomains.map((origin) => this._handleConnectedAccountSelected(origin), ), ); } /** * When a new account is selected in the UI, emit accountsChanged to 'origin' * * Note: This will emit "false positive" accountsChanged events, but they are * handled by the inpage provider. * * @param {string} origin - The origin */ async _handleConnectedAccountSelected(origin) { const permittedAccounts = await this.getAccounts(origin); this.notifyAccountsChanged(origin, permittedAccounts); } /** * A convenience method for retrieving a login object * or creating a new one if needed. * * @param {string} origin - The origin string representing the domain. */ _initializePermissions(restoredState) { // these permission requests are almost certainly stale const initState = { ...restoredState, permissionsRequests: [] }; this.permissions = new RpcCap( { // Supports passthrough methods: safeMethods: SAFE_METHODS, // optional prefix for internal methods methodPrefix: WALLET_PREFIX, restrictedMethods: this._restrictedMethods, /** * A promise-returning callback used to determine whether to approve * permissions requests or not. * * Currently only returns a boolean, but eventually should return any * specific parameters or amendments to the permissions. * * @param {string} req - The internal rpc-cap user request object. */ requestUserApproval: async (req) => { const { metadata: { id, origin }, } = req; return this.approvals.addAndShowApprovalRequest({ id, origin, type: APPROVAL_TYPE, }); }, }, initState, ); } }