@ -1,7 +1,12 @@ |
||||
module.exports = { |
||||
// TODO: Remove the `exit` setting, it can hide broken tests.
|
||||
exit: true, |
||||
ignore: ['./app/scripts/migrations/*.test.js', './app/scripts/platforms/*.test.js'], |
||||
ignore: [ |
||||
'./app/scripts/lib/**/*.test.js', |
||||
'./app/scripts/migrations/*.test.js', |
||||
'./app/scripts/platforms/*.test.js', |
||||
'./app/scripts/controllers/permissions/*.test.js', |
||||
], |
||||
recursive: true, |
||||
require: ['test/env.js', 'test/setup.js'], |
||||
} |
||||
}; |
||||
|
@ -1,5 +0,0 @@ |
||||
const baseConfig = require('./.mocharc'); |
||||
|
||||
module.exports = Object.assign({}, baseConfig, { |
||||
ignore: [...baseConfig.ignore, './app/scripts/controllers/permissions/*.test.js'] |
||||
}); |
@ -0,0 +1,6 @@ |
||||
import { addons } from '@storybook/addons'; |
||||
import MetaMaskStorybookTheme from './metamask-storybook-theme'; |
||||
|
||||
addons.setConfig({ |
||||
theme: MetaMaskStorybookTheme, |
||||
}); |
@ -0,0 +1,14 @@ |
||||
// .storybook/YourTheme.js
|
||||
|
||||
import { create } from '@storybook/theming'; |
||||
import logo from '../app/images/logo/metamask-logo-horizontal.svg'; |
||||
|
||||
export default create({ |
||||
base: 'light', |
||||
brandTitle: 'MetaMask Storybook', |
||||
brandImage: logo, |
||||
|
||||
// Typography
|
||||
fontBase: 'Euclid, Roboto, Helvetica, Arial, sans-serif', |
||||
fontCode: 'Inconsolata, monospace', |
||||
}); |
@ -0,0 +1,27 @@ |
||||
<link rel="preconnect" href="https://fonts.googleapis.com" /> |
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
||||
<link |
||||
href="https://fonts.googleapis.com/css2?family=Inconsolata&display=swap" |
||||
rel="stylesheet" |
||||
/> |
||||
|
||||
<style> |
||||
* { |
||||
--gray-pre-bg: #f8f8f8; |
||||
--font-family-monospace: Inconsolata, monospace; |
||||
--font-size-code: 0.875rem; |
||||
} |
||||
|
||||
.docblock-source { |
||||
background: var(--gray-pre-bg) !important; |
||||
} |
||||
|
||||
.docblock-source code { |
||||
font-family: var(--font-family-monospace) !important; |
||||
font-size: var(--font-size-code) !important; |
||||
} |
||||
.docblock-source code * { |
||||
font-family: var(--font-family-monospace) !important; |
||||
font-size: var(--font-size-code) !important; |
||||
} |
||||
</style> |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 347 B |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 358 B |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,71 @@ |
||||
import { |
||||
CaveatTypes, |
||||
RestrictedMethods, |
||||
} from '../../../../shared/constants/permissions'; |
||||
|
||||
export function getPermissionBackgroundApiMethods(permissionController) { |
||||
return { |
||||
addPermittedAccount: (origin, account) => { |
||||
const existing = permissionController.getCaveat( |
||||
origin, |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
if (existing.value.includes(account)) { |
||||
throw new Error( |
||||
`eth_accounts permission for origin "${origin}" already permits account "${account}".`, |
||||
); |
||||
} |
||||
|
||||
permissionController.updateCaveat( |
||||
origin, |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
[...existing.value, account], |
||||
); |
||||
}, |
||||
|
||||
removePermittedAccount: (origin, account) => { |
||||
const existing = permissionController.getCaveat( |
||||
origin, |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
if (!existing.value.includes(account)) { |
||||
throw new Error( |
||||
`eth_accounts permission for origin "${origin}" already does not permit account "${account}".`, |
||||
); |
||||
} |
||||
|
||||
const remainingAccounts = existing.value.filter( |
||||
(existingAccount) => existingAccount !== account, |
||||
); |
||||
|
||||
if (remainingAccounts.length === 0) { |
||||
permissionController.revokePermission( |
||||
origin, |
||||
RestrictedMethods.eth_accounts, |
||||
); |
||||
} else { |
||||
permissionController.updateCaveat( |
||||
origin, |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
remainingAccounts, |
||||
); |
||||
} |
||||
}, |
||||
|
||||
requestAccountsPermissionWithId: async (origin) => { |
||||
const [, { id }] = await permissionController.requestPermissions( |
||||
{ origin }, |
||||
{ |
||||
eth_accounts: {}, |
||||
}, |
||||
); |
||||
return id; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,181 @@ |
||||
import { |
||||
CaveatTypes, |
||||
RestrictedMethods, |
||||
} from '../../../../shared/constants/permissions'; |
||||
import { getPermissionBackgroundApiMethods } from './background-api'; |
||||
|
||||
describe('permission background API methods', () => { |
||||
describe('addPermittedAccount', () => { |
||||
it('adds a permitted account', () => { |
||||
const permissionController = { |
||||
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; |
||||
}), |
||||
updateCaveat: jest.fn(), |
||||
}; |
||||
|
||||
getPermissionBackgroundApiMethods( |
||||
permissionController, |
||||
).addPermittedAccount('foo.com', '0x2'); |
||||
|
||||
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.updateCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
['0x1', '0x2'], |
||||
); |
||||
}); |
||||
|
||||
it('throws if the specified account is already permitted', () => { |
||||
const permissionController = { |
||||
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; |
||||
}), |
||||
updateCaveat: jest.fn(), |
||||
}; |
||||
|
||||
expect(() => |
||||
getPermissionBackgroundApiMethods( |
||||
permissionController, |
||||
).addPermittedAccount('foo.com', '0x1'), |
||||
).toThrow( |
||||
`eth_accounts permission for origin "foo.com" already permits account "0x1".`, |
||||
); |
||||
|
||||
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
expect(permissionController.updateCaveat).not.toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
describe('removePermittedAccount', () => { |
||||
it('removes a permitted account', () => { |
||||
const permissionController = { |
||||
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||
return { |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1', '0x2'], |
||||
}; |
||||
}), |
||||
revokePermission: jest.fn(), |
||||
updateCaveat: jest.fn(), |
||||
}; |
||||
|
||||
getPermissionBackgroundApiMethods( |
||||
permissionController, |
||||
).removePermittedAccount('foo.com', '0x2'); |
||||
|
||||
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
expect(permissionController.revokePermission).not.toHaveBeenCalled(); |
||||
|
||||
expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.updateCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
['0x1'], |
||||
); |
||||
}); |
||||
|
||||
it('revokes the accounts permission if the removed account is the only permitted account', () => { |
||||
const permissionController = { |
||||
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||
return { |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1'], |
||||
}; |
||||
}), |
||||
revokePermission: jest.fn(), |
||||
updateCaveat: jest.fn(), |
||||
}; |
||||
|
||||
getPermissionBackgroundApiMethods( |
||||
permissionController, |
||||
).removePermittedAccount('foo.com', '0x1'); |
||||
|
||||
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
expect(permissionController.revokePermission).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.revokePermission).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
); |
||||
|
||||
expect(permissionController.updateCaveat).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('throws if the specified account is not permitted', () => { |
||||
const permissionController = { |
||||
getCaveat: jest.fn().mockImplementationOnce(() => { |
||||
return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; |
||||
}), |
||||
revokePermission: jest.fn(), |
||||
updateCaveat: jest.fn(), |
||||
}; |
||||
|
||||
expect(() => |
||||
getPermissionBackgroundApiMethods( |
||||
permissionController, |
||||
).removePermittedAccount('foo.com', '0x2'), |
||||
).toThrow( |
||||
`eth_accounts permission for origin "foo.com" already does not permit account "0x2".`, |
||||
); |
||||
|
||||
expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.getCaveat).toHaveBeenCalledWith( |
||||
'foo.com', |
||||
RestrictedMethods.eth_accounts, |
||||
CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
expect(permissionController.revokePermission).not.toHaveBeenCalled(); |
||||
expect(permissionController.updateCaveat).not.toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
describe('requestAccountsPermissionWithId', () => { |
||||
it('request an accounts permission and returns the request id', async () => { |
||||
const permissionController = { |
||||
requestPermissions: jest.fn().mockImplementationOnce(async () => { |
||||
return [null, { id: 'arbitraryId' }]; |
||||
}), |
||||
}; |
||||
|
||||
const id = await getPermissionBackgroundApiMethods( |
||||
permissionController, |
||||
).requestAccountsPermissionWithId('foo.com'); |
||||
|
||||
expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); |
||||
expect(permissionController.requestPermissions).toHaveBeenCalledWith( |
||||
{ origin: 'foo.com' }, |
||||
{ eth_accounts: {} }, |
||||
); |
||||
|
||||
expect(id).toStrictEqual('arbitraryId'); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,39 @@ |
||||
import { CaveatMutatorOperation } from '@metamask/snap-controllers'; |
||||
import { CaveatTypes } from '../../../../shared/constants/permissions'; |
||||
|
||||
/** |
||||
* Factories that construct caveat mutator functions that are passed to |
||||
* PermissionController.updatePermissionsByCaveat. |
||||
*/ |
||||
export const CaveatMutatorFactories = { |
||||
[CaveatTypes.restrictReturnedAccounts]: { |
||||
removeAccount, |
||||
}, |
||||
}; |
||||
|
||||
/** |
||||
* Removes the target account from the value arrays of all |
||||
* `restrictReturnedAccounts` caveats. No-ops if the target account is not in |
||||
* the array, and revokes the parent permission if it's the only account in |
||||
* the array. |
||||
* |
||||
* @param {string} targetAccount - The address of the account to remove from |
||||
* all accounts permissions. |
||||
* @param {string[]} existingAccounts - The account address array from the |
||||
* account permissions. |
||||
*/ |
||||
function removeAccount(targetAccount, existingAccounts) { |
||||
const newAccounts = existingAccounts.filter( |
||||
(address) => address !== targetAccount, |
||||
); |
||||
|
||||
if (newAccounts.length === existingAccounts.length) { |
||||
return { operation: CaveatMutatorOperation.noop }; |
||||
} else if (newAccounts.length > 0) { |
||||
return { |
||||
operation: CaveatMutatorOperation.updateValue, |
||||
value: newAccounts, |
||||
}; |
||||
} |
||||
return { operation: CaveatMutatorOperation.revokePermission }; |
||||
} |
@ -0,0 +1,32 @@ |
||||
import { CaveatMutatorOperation } from '@metamask/snap-controllers'; |
||||
import { CaveatTypes } from '../../../../shared/constants/permissions'; |
||||
import { CaveatMutatorFactories } from './caveat-mutators'; |
||||
|
||||
describe('caveat mutators', () => { |
||||
describe('restrictReturnedAccounts', () => { |
||||
const { removeAccount } = CaveatMutatorFactories[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
describe('removeAccount', () => { |
||||
it('returns the no-op operation if the target account is not permitted', () => { |
||||
expect(removeAccount('0x2', ['0x1'])).toStrictEqual({ |
||||
operation: CaveatMutatorOperation.noop, |
||||
}); |
||||
}); |
||||
|
||||
it('returns the update operation and a new value if the target account is permitted', () => { |
||||
expect(removeAccount('0x2', ['0x1', '0x2'])).toStrictEqual({ |
||||
operation: CaveatMutatorOperation.updateValue, |
||||
value: ['0x1'], |
||||
}); |
||||
}); |
||||
|
||||
it('returns the revoke permission operation the target account is the only permitted account', () => { |
||||
expect(removeAccount('0x1', ['0x1'])).toStrictEqual({ |
||||
operation: CaveatMutatorOperation.revokePermission, |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,718 +1,4 @@ |
||||
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<string>} 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<IOcapLdCapability[]>} 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<string>} 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<string>} 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, |
||||
); |
||||
} |
||||
} |
||||
export * from './caveat-mutators'; |
||||
export * from './background-api'; |
||||
export * from './specifications'; |
||||
export * from './selectors'; |
||||
|
@ -1,950 +0,0 @@ |
||||
import { strict as assert } from 'assert'; |
||||
import sinon from 'sinon'; |
||||
|
||||
import { |
||||
constants, |
||||
getters, |
||||
getPermControllerOpts, |
||||
getPermissionsMiddleware, |
||||
} from '../../../../test/mocks/permission-controller'; |
||||
import { |
||||
getUserApprovalPromise, |
||||
grantPermissions, |
||||
} from '../../../../test/helpers/permission-controller-helpers'; |
||||
import { METADATA_STORE_KEY } from './enums'; |
||||
|
||||
import { PermissionsController } from '.'; |
||||
|
||||
const { CAVEATS, ERRORS, PERMS, RPC_REQUESTS } = getters; |
||||
|
||||
const { ACCOUNTS, DOMAINS, PERM_NAMES } = constants; |
||||
|
||||
const initPermController = () => { |
||||
return new PermissionsController({ |
||||
...getPermControllerOpts(), |
||||
}); |
||||
}; |
||||
|
||||
const createApprovalSpies = (permController) => { |
||||
sinon.spy(permController.approvals, '_add'); |
||||
}; |
||||
|
||||
const getNextApprovalId = (permController) => { |
||||
return permController.approvals._approvals.keys().next().value; |
||||
}; |
||||
|
||||
const validatePermission = (perm, name, origin, caveats) => { |
||||
assert.equal( |
||||
name, |
||||
perm.parentCapability, |
||||
'should have expected permission name', |
||||
); |
||||
assert.equal(origin, perm.invoker, 'should have expected permission origin'); |
||||
if (caveats) { |
||||
assert.deepEqual( |
||||
caveats, |
||||
perm.caveats, |
||||
'should have expected permission caveats', |
||||
); |
||||
} else { |
||||
assert.ok(!perm.caveats, 'should not have any caveats'); |
||||
} |
||||
}; |
||||
|
||||
describe('permissions middleware', function () { |
||||
describe('wallet_requestPermissions', function () { |
||||
let permController; |
||||
|
||||
beforeEach(function () { |
||||
permController = initPermController(); |
||||
permController.notifyAccountsChanged = sinon.fake(); |
||||
}); |
||||
|
||||
it('grants permissions on user approval', async function () { |
||||
createApprovalSpies(permController); |
||||
|
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.requestPermission( |
||||
DOMAINS.a.origin, |
||||
PERM_NAMES.eth_accounts, |
||||
); |
||||
const res = {}; |
||||
|
||||
const userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const pendingApproval = assert.doesNotReject( |
||||
aMiddleware(req, res), |
||||
'should not reject permissions request', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
assert.ok( |
||||
permController.approvals._add.calledOnce, |
||||
'should have added single approval request', |
||||
); |
||||
|
||||
const id = getNextApprovalId(permController); |
||||
const approvedReq = PERMS.approvedRequest( |
||||
id, |
||||
PERMS.requests.eth_accounts(), |
||||
); |
||||
|
||||
await permController.approvePermissionsRequest( |
||||
approvedReq, |
||||
ACCOUNTS.a.permitted, |
||||
); |
||||
await pendingApproval; |
||||
|
||||
assert.ok( |
||||
res.result && !res.error, |
||||
'response should have result and no error', |
||||
); |
||||
|
||||
assert.equal( |
||||
res.result.length, |
||||
1, |
||||
'origin should have single approved permission', |
||||
); |
||||
|
||||
validatePermission( |
||||
res.result[0], |
||||
PERM_NAMES.eth_accounts, |
||||
DOMAINS.a.origin, |
||||
CAVEATS.eth_accounts(ACCOUNTS.a.permitted), |
||||
); |
||||
|
||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
||||
assert.deepEqual( |
||||
aAccounts, |
||||
[ACCOUNTS.a.primary], |
||||
'origin should have correct accounts', |
||||
); |
||||
|
||||
assert.ok( |
||||
permController.notifyAccountsChanged.calledOnceWith( |
||||
DOMAINS.a.origin, |
||||
aAccounts, |
||||
), |
||||
'expected notification call should have been made', |
||||
); |
||||
}); |
||||
|
||||
it('handles serial approved requests that overwrite existing permissions', async function () { |
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
// create first request
|
||||
|
||||
const req1 = RPC_REQUESTS.requestPermission( |
||||
DOMAINS.a.origin, |
||||
PERM_NAMES.eth_accounts, |
||||
); |
||||
const res1 = {}; |
||||
|
||||
// send, approve, and validate first request
|
||||
// note use of ACCOUNTS.a.permitted
|
||||
|
||||
let userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const pendingApproval1 = assert.doesNotReject( |
||||
aMiddleware(req1, res1), |
||||
'should not reject permissions request', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
const id1 = getNextApprovalId(permController); |
||||
const approvedReq1 = PERMS.approvedRequest( |
||||
id1, |
||||
PERMS.requests.eth_accounts(), |
||||
); |
||||
|
||||
await permController.approvePermissionsRequest( |
||||
approvedReq1, |
||||
ACCOUNTS.a.permitted, |
||||
); |
||||
await pendingApproval1; |
||||
|
||||
assert.ok( |
||||
res1.result && !res1.error, |
||||
'response should have result and no error', |
||||
); |
||||
|
||||
assert.equal( |
||||
res1.result.length, |
||||
1, |
||||
'origin should have single approved permission', |
||||
); |
||||
|
||||
validatePermission( |
||||
res1.result[0], |
||||
PERM_NAMES.eth_accounts, |
||||
DOMAINS.a.origin, |
||||
CAVEATS.eth_accounts(ACCOUNTS.a.permitted), |
||||
); |
||||
|
||||
const accounts1 = await permController.getAccounts(DOMAINS.a.origin); |
||||
assert.deepEqual( |
||||
accounts1, |
||||
[ACCOUNTS.a.primary], |
||||
'origin should have correct accounts', |
||||
); |
||||
|
||||
assert.ok( |
||||
permController.notifyAccountsChanged.calledOnceWith( |
||||
DOMAINS.a.origin, |
||||
accounts1, |
||||
), |
||||
'expected notification call should have been made', |
||||
); |
||||
|
||||
// create second request
|
||||
|
||||
const requestedPerms2 = { |
||||
...PERMS.requests.eth_accounts(), |
||||
...PERMS.requests.test_method(), |
||||
}; |
||||
|
||||
const req2 = RPC_REQUESTS.requestPermissions(DOMAINS.a.origin, { |
||||
...requestedPerms2, |
||||
}); |
||||
const res2 = {}; |
||||
|
||||
// send, approve, and validate second request
|
||||
// note use of ACCOUNTS.b.permitted
|
||||
|
||||
userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const pendingApproval2 = assert.doesNotReject( |
||||
aMiddleware(req2, res2), |
||||
'should not reject permissions request', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
const id2 = getNextApprovalId(permController); |
||||
const approvedReq2 = PERMS.approvedRequest(id2, { ...requestedPerms2 }); |
||||
|
||||
await permController.approvePermissionsRequest( |
||||
approvedReq2, |
||||
ACCOUNTS.b.permitted, |
||||
); |
||||
await pendingApproval2; |
||||
|
||||
assert.ok( |
||||
res2.result && !res2.error, |
||||
'response should have result and no error', |
||||
); |
||||
|
||||
assert.equal( |
||||
res2.result.length, |
||||
2, |
||||
'origin should have single approved permission', |
||||
); |
||||
|
||||
validatePermission( |
||||
res2.result[0], |
||||
PERM_NAMES.eth_accounts, |
||||
DOMAINS.a.origin, |
||||
CAVEATS.eth_accounts(ACCOUNTS.b.permitted), |
||||
); |
||||
|
||||
validatePermission( |
||||
res2.result[1], |
||||
PERM_NAMES.test_method, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const accounts2 = await permController.getAccounts(DOMAINS.a.origin); |
||||
assert.deepEqual( |
||||
accounts2, |
||||
[ACCOUNTS.b.primary], |
||||
'origin should have correct accounts', |
||||
); |
||||
|
||||
assert.equal( |
||||
permController.notifyAccountsChanged.callCount, |
||||
2, |
||||
'should have called notification method 2 times in total', |
||||
); |
||||
|
||||
assert.ok( |
||||
permController.notifyAccountsChanged.lastCall.calledWith( |
||||
DOMAINS.a.origin, |
||||
accounts2, |
||||
), |
||||
'expected notification call should have been made', |
||||
); |
||||
}); |
||||
|
||||
it('rejects permissions on user rejection', async function () { |
||||
createApprovalSpies(permController); |
||||
|
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.requestPermission( |
||||
DOMAINS.a.origin, |
||||
PERM_NAMES.eth_accounts, |
||||
); |
||||
const res = {}; |
||||
|
||||
const expectedError = ERRORS.rejectPermissionsRequest.rejection(); |
||||
|
||||
const userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const requestRejection = assert.rejects( |
||||
aMiddleware(req, res), |
||||
expectedError, |
||||
'request should be rejected with correct error', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
assert.ok( |
||||
permController.approvals._add.calledOnce, |
||||
'should have added single approval request', |
||||
); |
||||
|
||||
const id = getNextApprovalId(permController); |
||||
|
||||
await permController.rejectPermissionsRequest(id); |
||||
await requestRejection; |
||||
|
||||
assert.ok( |
||||
!res.result && res.error && res.error.message === expectedError.message, |
||||
'response should have expected error and no result', |
||||
); |
||||
|
||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
||||
assert.deepEqual( |
||||
aAccounts, |
||||
[], |
||||
'origin should have have correct accounts', |
||||
); |
||||
|
||||
assert.ok( |
||||
permController.notifyAccountsChanged.notCalled, |
||||
'should not have called notification method', |
||||
); |
||||
}); |
||||
|
||||
it('rejects requests with unknown permissions', async function () { |
||||
createApprovalSpies(permController); |
||||
|
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.requestPermissions(DOMAINS.a.origin, { |
||||
...PERMS.requests.does_not_exist(), |
||||
...PERMS.requests.test_method(), |
||||
}); |
||||
const res = {}; |
||||
|
||||
const expectedError = ERRORS.rejectPermissionsRequest.methodNotFound( |
||||
PERM_NAMES.does_not_exist, |
||||
); |
||||
|
||||
await assert.rejects( |
||||
aMiddleware(req, res), |
||||
expectedError, |
||||
'request should be rejected with correct error', |
||||
); |
||||
|
||||
assert.ok( |
||||
permController.approvals._add.notCalled, |
||||
'no approval requests should have been added', |
||||
); |
||||
|
||||
assert.ok( |
||||
!res.result && res.error && res.error.message === expectedError.message, |
||||
'response should have expected error and no result', |
||||
); |
||||
|
||||
assert.ok( |
||||
permController.notifyAccountsChanged.notCalled, |
||||
'should not have called notification method', |
||||
); |
||||
}); |
||||
|
||||
it('accepts only a single pending permissions request per origin', async function () { |
||||
createApprovalSpies(permController); |
||||
|
||||
// two middlewares for two origins
|
||||
|
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
const bMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.b.origin, |
||||
); |
||||
|
||||
// create and start processing first request for first origin
|
||||
|
||||
const reqA1 = RPC_REQUESTS.requestPermission( |
||||
DOMAINS.a.origin, |
||||
PERM_NAMES.test_method, |
||||
); |
||||
const resA1 = {}; |
||||
|
||||
let userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const requestApproval1 = assert.doesNotReject( |
||||
aMiddleware(reqA1, resA1), |
||||
'should not reject permissions request', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
// create and start processing first request for second origin
|
||||
|
||||
const reqB1 = RPC_REQUESTS.requestPermission( |
||||
DOMAINS.b.origin, |
||||
PERM_NAMES.test_method, |
||||
); |
||||
const resB1 = {}; |
||||
|
||||
userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const requestApproval2 = assert.doesNotReject( |
||||
bMiddleware(reqB1, resB1), |
||||
'should not reject permissions request', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
assert.ok( |
||||
permController.approvals._add.calledTwice, |
||||
'should have added two approval requests', |
||||
); |
||||
|
||||
// create and start processing second request for first origin,
|
||||
// which should throw
|
||||
|
||||
const reqA2 = RPC_REQUESTS.requestPermission( |
||||
DOMAINS.a.origin, |
||||
PERM_NAMES.test_method, |
||||
); |
||||
const resA2 = {}; |
||||
|
||||
userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const expectedError = ERRORS.pendingApprovals.requestAlreadyPending( |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const requestApprovalFail = assert.rejects( |
||||
aMiddleware(reqA2, resA2), |
||||
expectedError, |
||||
'request should be rejected with correct error', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
await requestApprovalFail; |
||||
|
||||
assert.ok( |
||||
!resA2.result && |
||||
resA2.error && |
||||
resA2.error.message === expectedError.message, |
||||
'response should have expected error and no result', |
||||
); |
||||
|
||||
assert.equal( |
||||
permController.approvals._add.callCount, |
||||
3, |
||||
'should have attempted to create three pending approvals', |
||||
); |
||||
assert.equal( |
||||
permController.approvals._approvals.size, |
||||
2, |
||||
'should only have created two pending approvals', |
||||
); |
||||
|
||||
// now, remaining pending requests should be approved without issue
|
||||
|
||||
for (const id of permController.approvals._approvals.keys()) { |
||||
await permController.approvePermissionsRequest( |
||||
PERMS.approvedRequest(id, PERMS.requests.test_method()), |
||||
); |
||||
} |
||||
await requestApproval1; |
||||
await requestApproval2; |
||||
|
||||
assert.ok( |
||||
resA1.result && !resA1.error, |
||||
'first response should have result and no error', |
||||
); |
||||
assert.equal( |
||||
resA1.result.length, |
||||
1, |
||||
'first origin should have single approved permission', |
||||
); |
||||
|
||||
assert.ok( |
||||
resB1.result && !resB1.error, |
||||
'second response should have result and no error', |
||||
); |
||||
assert.equal( |
||||
resB1.result.length, |
||||
1, |
||||
'second origin should have single approved permission', |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('restricted methods', function () { |
||||
let permController; |
||||
|
||||
beforeEach(function () { |
||||
permController = initPermController(); |
||||
}); |
||||
|
||||
it('prevents restricted method access for unpermitted domain', async function () { |
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.test_method(DOMAINS.a.origin); |
||||
const res = {}; |
||||
|
||||
const expectedError = ERRORS.rpcCap.unauthorized(); |
||||
|
||||
await assert.rejects( |
||||
aMiddleware(req, res), |
||||
expectedError, |
||||
'request should be rejected with correct error', |
||||
); |
||||
|
||||
assert.ok( |
||||
!res.result && res.error && res.error.code === expectedError.code, |
||||
'response should have expected error and no result', |
||||
); |
||||
}); |
||||
|
||||
it('allows restricted method access for permitted domain', async function () { |
||||
const bMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.b.origin, |
||||
); |
||||
|
||||
grantPermissions( |
||||
permController, |
||||
DOMAINS.b.origin, |
||||
PERMS.finalizedRequests.test_method(), |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.test_method(DOMAINS.b.origin, true); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(bMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok( |
||||
res.result && res.result === 1, |
||||
'response should have correct result', |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('eth_accounts', function () { |
||||
let permController; |
||||
|
||||
beforeEach(function () { |
||||
permController = initPermController(); |
||||
}); |
||||
|
||||
it('returns empty array for non-permitted domain', async function () { |
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.eth_accounts(DOMAINS.a.origin); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(aMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok( |
||||
res.result && !res.error, |
||||
'response should have result and no error', |
||||
); |
||||
assert.deepEqual(res.result, [], 'response should have correct result'); |
||||
}); |
||||
|
||||
it('returns correct accounts for permitted domain', async function () { |
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
grantPermissions( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
PERMS.finalizedRequests.eth_accounts(ACCOUNTS.a.permitted), |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.eth_accounts(DOMAINS.a.origin); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(aMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok( |
||||
res.result && !res.error, |
||||
'response should have result and no error', |
||||
); |
||||
assert.deepEqual( |
||||
res.result, |
||||
[ACCOUNTS.a.primary], |
||||
'response should have correct result', |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('eth_requestAccounts', function () { |
||||
let permController; |
||||
|
||||
beforeEach(function () { |
||||
permController = initPermController(); |
||||
}); |
||||
|
||||
it('requests accounts for unpermitted origin, and approves on user approval', async function () { |
||||
createApprovalSpies(permController); |
||||
|
||||
const userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.a.origin); |
||||
const res = {}; |
||||
|
||||
const pendingApproval = assert.doesNotReject( |
||||
aMiddleware(req, res), |
||||
'should not reject permissions request', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
assert.ok( |
||||
permController.approvals._add.calledOnce, |
||||
'should have added single approval request', |
||||
); |
||||
|
||||
const id = getNextApprovalId(permController); |
||||
const approvedReq = PERMS.approvedRequest( |
||||
id, |
||||
PERMS.requests.eth_accounts(), |
||||
); |
||||
|
||||
await permController.approvePermissionsRequest( |
||||
approvedReq, |
||||
ACCOUNTS.a.permitted, |
||||
); |
||||
|
||||
// wait for permission to be granted
|
||||
await pendingApproval; |
||||
|
||||
const perms = permController.permissions.getPermissionsForDomain( |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
assert.equal( |
||||
perms.length, |
||||
1, |
||||
'domain should have correct number of permissions', |
||||
); |
||||
|
||||
validatePermission( |
||||
perms[0], |
||||
PERM_NAMES.eth_accounts, |
||||
DOMAINS.a.origin, |
||||
CAVEATS.eth_accounts(ACCOUNTS.a.permitted), |
||||
); |
||||
|
||||
// we should also see the accounts on the response
|
||||
assert.ok( |
||||
res.result && !res.error, |
||||
'response should have result and no error', |
||||
); |
||||
|
||||
assert.deepEqual( |
||||
res.result, |
||||
[ACCOUNTS.a.primary], |
||||
'result should have correct accounts', |
||||
); |
||||
|
||||
// we should also be able to get the accounts independently
|
||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
||||
assert.deepEqual( |
||||
aAccounts, |
||||
[ACCOUNTS.a.primary], |
||||
'origin should have have correct accounts', |
||||
); |
||||
}); |
||||
|
||||
it('requests accounts for unpermitted origin, and rejects on user rejection', async function () { |
||||
createApprovalSpies(permController); |
||||
|
||||
const userApprovalPromise = getUserApprovalPromise(permController); |
||||
|
||||
const aMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.a.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.a.origin); |
||||
const res = {}; |
||||
|
||||
const expectedError = ERRORS.rejectPermissionsRequest.rejection(); |
||||
|
||||
const requestRejection = assert.rejects( |
||||
aMiddleware(req, res), |
||||
expectedError, |
||||
'request should be rejected with correct error', |
||||
); |
||||
|
||||
await userApprovalPromise; |
||||
|
||||
assert.ok( |
||||
permController.approvals._add.calledOnce, |
||||
'should have added single approval request', |
||||
); |
||||
|
||||
const id = getNextApprovalId(permController); |
||||
|
||||
await permController.rejectPermissionsRequest(id); |
||||
await requestRejection; |
||||
|
||||
assert.ok( |
||||
!res.result && res.error && res.error.message === expectedError.message, |
||||
'response should have expected error and no result', |
||||
); |
||||
|
||||
const aAccounts = await permController.getAccounts(DOMAINS.a.origin); |
||||
assert.deepEqual( |
||||
aAccounts, |
||||
[], |
||||
'origin should have have correct accounts', |
||||
); |
||||
}); |
||||
|
||||
it('directly returns accounts for permitted domain', async function () { |
||||
const cMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
); |
||||
|
||||
grantPermissions( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok( |
||||
res.result && !res.error, |
||||
'response should have result and no error', |
||||
); |
||||
assert.deepEqual( |
||||
res.result, |
||||
[ACCOUNTS.c.primary], |
||||
'response should have correct result', |
||||
); |
||||
}); |
||||
|
||||
it('rejects new requests when request already pending', async function () { |
||||
let unlock; |
||||
const unlockPromise = new Promise((resolve) => { |
||||
unlock = resolve; |
||||
}); |
||||
|
||||
permController.getUnlockPromise = () => unlockPromise; |
||||
|
||||
const cMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
); |
||||
|
||||
grantPermissions( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
PERMS.finalizedRequests.eth_accounts(ACCOUNTS.c.permitted), |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.eth_requestAccounts(DOMAINS.c.origin); |
||||
const res = {}; |
||||
|
||||
// this will block until we resolve the unlock Promise
|
||||
const requestApproval = assert.doesNotReject( |
||||
cMiddleware(req, res), |
||||
'should not reject', |
||||
); |
||||
|
||||
// this will reject because of the already pending request
|
||||
await assert.rejects( |
||||
cMiddleware({ ...req }, {}), |
||||
ERRORS.eth_requestAccounts.requestAlreadyPending(DOMAINS.c.origin), |
||||
); |
||||
|
||||
// now unlock and let through the first request
|
||||
unlock(); |
||||
|
||||
await requestApproval; |
||||
|
||||
assert.ok( |
||||
res.result && !res.error, |
||||
'response should have result and no error', |
||||
); |
||||
assert.deepEqual( |
||||
res.result, |
||||
[ACCOUNTS.c.primary], |
||||
'response should have correct result', |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('metamask_sendDomainMetadata', function () { |
||||
let permController, clock; |
||||
|
||||
beforeEach(function () { |
||||
permController = initPermController(); |
||||
clock = sinon.useFakeTimers(1); |
||||
}); |
||||
|
||||
afterEach(function () { |
||||
clock.restore(); |
||||
}); |
||||
|
||||
it('records domain metadata', async function () { |
||||
const name = 'BAZ'; |
||||
|
||||
const cMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata( |
||||
DOMAINS.c.origin, |
||||
name, |
||||
); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok(res.result, 'result should be true'); |
||||
|
||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
||||
|
||||
assert.deepEqual( |
||||
metadataStore, |
||||
{ |
||||
[DOMAINS.c.origin]: { |
||||
name, |
||||
host: DOMAINS.c.host, |
||||
lastUpdated: 1, |
||||
}, |
||||
}, |
||||
'metadata should have been added to store', |
||||
); |
||||
}); |
||||
|
||||
it('records domain metadata and preserves extensionId', async function () { |
||||
const extensionId = 'fooExtension'; |
||||
|
||||
const name = 'BAZ'; |
||||
|
||||
const cMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
extensionId, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata( |
||||
DOMAINS.c.origin, |
||||
name, |
||||
); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok(res.result, 'result should be true'); |
||||
|
||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
||||
|
||||
assert.deepEqual( |
||||
metadataStore, |
||||
{ [DOMAINS.c.origin]: { name, extensionId, lastUpdated: 1 } }, |
||||
'metadata should have been added to store', |
||||
); |
||||
}); |
||||
|
||||
it('should not record domain metadata if no name', async function () { |
||||
const name = null; |
||||
|
||||
const cMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata( |
||||
DOMAINS.c.origin, |
||||
name, |
||||
); |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok(res.result, 'result should be true'); |
||||
|
||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
||||
|
||||
assert.deepEqual( |
||||
metadataStore, |
||||
{}, |
||||
'metadata should not have been added to store', |
||||
); |
||||
}); |
||||
|
||||
it('should not record domain metadata if no metadata', async function () { |
||||
const cMiddleware = getPermissionsMiddleware( |
||||
permController, |
||||
DOMAINS.c.origin, |
||||
); |
||||
|
||||
const req = RPC_REQUESTS.metamask_sendDomainMetadata(DOMAINS.c.origin); |
||||
delete req.domainMetadata; |
||||
const res = {}; |
||||
|
||||
await assert.doesNotReject(cMiddleware(req, res), 'should not reject'); |
||||
|
||||
assert.ok(res.result, 'result should be true'); |
||||
|
||||
const metadataStore = permController.store.getState()[METADATA_STORE_KEY]; |
||||
|
||||
assert.deepEqual( |
||||
metadataStore, |
||||
{}, |
||||
'metadata should not have been added to store', |
||||
); |
||||
}); |
||||
}); |
||||
}); |
@ -1,112 +0,0 @@ |
||||
import { createAsyncMiddleware } from 'json-rpc-engine'; |
||||
import { ethErrors } from 'eth-rpc-errors'; |
||||
|
||||
/** |
||||
* Create middleware for handling certain methods and preprocessing permissions requests. |
||||
*/ |
||||
export default function createPermissionsMethodMiddleware({ |
||||
addDomainMetadata, |
||||
getAccounts, |
||||
getUnlockPromise, |
||||
hasPermission, |
||||
notifyAccountsChanged, |
||||
requestAccountsPermission, |
||||
}) { |
||||
let isProcessingRequestAccounts = false; |
||||
|
||||
return createAsyncMiddleware(async (req, res, next) => { |
||||
let responseHandler; |
||||
|
||||
switch (req.method) { |
||||
// Intercepting eth_accounts requests for backwards compatibility:
|
||||
// The getAccounts call below wraps the rpc-cap middleware, and returns
|
||||
// an empty array in case of errors (such as 4100:unauthorized)
|
||||
case 'eth_accounts': { |
||||
res.result = await getAccounts(); |
||||
return; |
||||
} |
||||
|
||||
case 'eth_requestAccounts': { |
||||
if (isProcessingRequestAccounts) { |
||||
res.error = ethErrors.rpc.resourceUnavailable( |
||||
'Already processing eth_requestAccounts. Please wait.', |
||||
); |
||||
return; |
||||
} |
||||
|
||||
if (hasPermission('eth_accounts')) { |
||||
isProcessingRequestAccounts = true; |
||||
await getUnlockPromise(); |
||||
isProcessingRequestAccounts = false; |
||||
} |
||||
|
||||
// first, just try to get accounts
|
||||
let accounts = await getAccounts(); |
||||
if (accounts.length > 0) { |
||||
res.result = accounts; |
||||
return; |
||||
} |
||||
|
||||
// if no accounts, request the accounts permission
|
||||
try { |
||||
await requestAccountsPermission(); |
||||
} catch (err) { |
||||
res.error = err; |
||||
return; |
||||
} |
||||
|
||||
// get the accounts again
|
||||
accounts = await getAccounts(); |
||||
/* istanbul ignore else: too hard to induce, see below comment */ |
||||
if (accounts.length > 0) { |
||||
res.result = accounts; |
||||
} else { |
||||
// this should never happen, because it should be caught in the
|
||||
// above catch clause
|
||||
res.error = ethErrors.rpc.internal( |
||||
'Accounts unexpectedly unavailable. Please report this bug.', |
||||
); |
||||
} |
||||
|
||||
return; |
||||
} |
||||
|
||||
// custom method for getting metadata from the requesting domain,
|
||||
// sent automatically by the inpage provider when it's initialized
|
||||
case 'metamask_sendDomainMetadata': { |
||||
if (typeof req.params?.name === 'string') { |
||||
addDomainMetadata(req.origin, req.params); |
||||
} |
||||
res.result = true; |
||||
return; |
||||
} |
||||
|
||||
// register return handler to send accountsChanged notification
|
||||
case 'wallet_requestPermissions': { |
||||
if ('eth_accounts' in req.params?.[0]) { |
||||
responseHandler = async () => { |
||||
if (Array.isArray(res.result)) { |
||||
for (const permission of res.result) { |
||||
if (permission.parentCapability === 'eth_accounts') { |
||||
notifyAccountsChanged(await getAccounts()); |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
default: |
||||
break; |
||||
} |
||||
|
||||
// when this promise resolves, the response is on its way back
|
||||
// eslint-disable-next-line node/callback-return
|
||||
await next(); |
||||
|
||||
if (responseHandler) { |
||||
responseHandler(); |
||||
} |
||||
}); |
||||
} |
@ -1,174 +0,0 @@ |
||||
import { strict as assert } from 'assert'; |
||||
import pify from 'pify'; |
||||
|
||||
import getRestrictedMethods from './restrictedMethods'; |
||||
|
||||
describe('restricted methods', function () { |
||||
describe('eth_accounts', function () { |
||||
it('should handle getKeyringAccounts error', async function () { |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getKeyringAccounts: async () => { |
||||
throw new Error('foo'); |
||||
}, |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
const fooError = new Error('foo'); |
||||
await assert.rejects( |
||||
ethAccountsMethod(null, res, null), |
||||
fooError, |
||||
'Should reject with expected error', |
||||
); |
||||
|
||||
assert.deepEqual( |
||||
res, |
||||
{ error: fooError }, |
||||
'response should have expected error and no result', |
||||
); |
||||
}); |
||||
|
||||
it('should handle missing identity for first account when sorting', async function () { |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getIdentities: () => { |
||||
return { '0x7e57e2': {} }; |
||||
}, |
||||
getKeyringAccounts: async () => ['0x7e57e2', '0x7e57e3'], |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
await assert.rejects(ethAccountsMethod(null, res, null)); |
||||
assert.ok(res.error instanceof Error, 'result should have error'); |
||||
assert.deepEqual( |
||||
Object.keys(res), |
||||
['error'], |
||||
'result should only contain error', |
||||
); |
||||
}); |
||||
|
||||
it('should handle missing identity for second account when sorting', async function () { |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getIdentities: () => { |
||||
return { '0x7e57e3': {} }; |
||||
}, |
||||
getKeyringAccounts: async () => ['0x7e57e2', '0x7e57e3'], |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
await assert.rejects(ethAccountsMethod(null, res, null)); |
||||
assert.ok(res.error instanceof Error, 'result should have error'); |
||||
assert.deepEqual( |
||||
Object.keys(res), |
||||
['error'], |
||||
'result should only contain error', |
||||
); |
||||
}); |
||||
|
||||
it('should return accounts in keyring order when none are selected', async function () { |
||||
const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getIdentities: () => { |
||||
return keyringAccounts.reduce((identities, address) => { |
||||
identities[address] = {}; |
||||
return identities; |
||||
}, {}); |
||||
}, |
||||
getKeyringAccounts: async () => [...keyringAccounts], |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
await ethAccountsMethod(null, res, null); |
||||
assert.deepEqual( |
||||
res, |
||||
{ result: keyringAccounts }, |
||||
'should return accounts in correct order', |
||||
); |
||||
}); |
||||
|
||||
it('should return accounts in keyring order when all have same last selected time', async function () { |
||||
const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getIdentities: () => { |
||||
return keyringAccounts.reduce((identities, address) => { |
||||
identities[address] = { lastSelected: 1000 }; |
||||
return identities; |
||||
}, {}); |
||||
}, |
||||
getKeyringAccounts: async () => [...keyringAccounts], |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
await ethAccountsMethod(null, res, null); |
||||
assert.deepEqual( |
||||
res, |
||||
{ result: keyringAccounts }, |
||||
'should return accounts in correct order', |
||||
); |
||||
}); |
||||
|
||||
it('should return accounts sorted by last selected (descending)', async function () { |
||||
const keyringAccounts = ['0x7e57e2', '0x7e57e3', '0x7e57e4', '0x7e57e5']; |
||||
const expectedResult = keyringAccounts.slice().reverse(); |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getIdentities: () => { |
||||
return keyringAccounts.reduce((identities, address, index) => { |
||||
identities[address] = { lastSelected: index * 1000 }; |
||||
return identities; |
||||
}, {}); |
||||
}, |
||||
getKeyringAccounts: async () => [...keyringAccounts], |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
await ethAccountsMethod(null, res, null); |
||||
assert.deepEqual( |
||||
res, |
||||
{ result: expectedResult }, |
||||
'should return accounts in correct order', |
||||
); |
||||
}); |
||||
|
||||
it('should return accounts sorted by last selected (descending) with unselected accounts last, in keyring order', async function () { |
||||
const keyringAccounts = [ |
||||
'0x7e57e2', |
||||
'0x7e57e3', |
||||
'0x7e57e4', |
||||
'0x7e57e5', |
||||
'0x7e57e6', |
||||
]; |
||||
const expectedResult = [ |
||||
'0x7e57e4', |
||||
'0x7e57e2', |
||||
'0x7e57e3', |
||||
'0x7e57e5', |
||||
'0x7e57e6', |
||||
]; |
||||
const restrictedMethods = getRestrictedMethods({ |
||||
getIdentities: () => { |
||||
return { |
||||
'0x7e57e2': { lastSelected: 1000 }, |
||||
'0x7e57e3': {}, |
||||
'0x7e57e4': { lastSelected: 2000 }, |
||||
'0x7e57e5': {}, |
||||
'0x7e57e6': {}, |
||||
}; |
||||
}, |
||||
getKeyringAccounts: async () => [...keyringAccounts], |
||||
}); |
||||
const ethAccountsMethod = pify(restrictedMethods.eth_accounts.method); |
||||
|
||||
const res = {}; |
||||
await ethAccountsMethod(null, res, null); |
||||
assert.deepEqual( |
||||
res, |
||||
{ result: expectedResult }, |
||||
'should return accounts in correct order', |
||||
); |
||||
}); |
||||
}); |
||||
}); |
@ -1,40 +0,0 @@ |
||||
export default function getRestrictedMethods({ |
||||
getIdentities, |
||||
getKeyringAccounts, |
||||
}) { |
||||
return { |
||||
eth_accounts: { |
||||
method: async (_, res, __, end) => { |
||||
try { |
||||
const accounts = await getKeyringAccounts(); |
||||
const identities = getIdentities(); |
||||
res.result = accounts.sort((firstAddress, secondAddress) => { |
||||
if (!identities[firstAddress]) { |
||||
throw new Error(`Missing identity for address ${firstAddress}`); |
||||
} else if (!identities[secondAddress]) { |
||||
throw new Error(`Missing identity for address ${secondAddress}`); |
||||
} else if ( |
||||
identities[firstAddress].lastSelected === |
||||
identities[secondAddress].lastSelected |
||||
) { |
||||
return 0; |
||||
} else if (identities[firstAddress].lastSelected === undefined) { |
||||
return 1; |
||||
} else if (identities[secondAddress].lastSelected === undefined) { |
||||
return -1; |
||||
} |
||||
|
||||
return ( |
||||
identities[secondAddress].lastSelected - |
||||
identities[firstAddress].lastSelected |
||||
); |
||||
}); |
||||
end(); |
||||
} catch (err) { |
||||
res.error = err; |
||||
end(err); |
||||
} |
||||
}, |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,84 @@ |
||||
import { createSelector } from 'reselect'; |
||||
import { CaveatTypes } from '../../../../shared/constants/permissions'; |
||||
|
||||
/** |
||||
* This file contains selectors for PermissionController selector event |
||||
* subscriptions, used to detect whenever a subject's accounts change so that |
||||
* we can notify the subject via the `accountsChanged` provider event. |
||||
*/ |
||||
|
||||
/** |
||||
* @param {Record<string, Record<string, unknown>>} state - The |
||||
* PermissionController state. |
||||
* @returns {Record<string, unknown>} The PermissionController subjects. |
||||
*/ |
||||
const getSubjects = (state) => state.subjects; |
||||
|
||||
/** |
||||
* Get the permitted accounts for each subject, keyed by origin. |
||||
* The values of the returned map are immutable values from the |
||||
* PermissionController state. |
||||
* |
||||
* @returns {Map<string, string[]>} The current origin:accounts[] map. |
||||
*/ |
||||
export const getPermittedAccountsByOrigin = createSelector( |
||||
getSubjects, |
||||
(subjects) => { |
||||
return Object.values(subjects).reduce((originToAccountsMap, subject) => { |
||||
const caveat = subject.permissions?.eth_accounts?.caveats.find( |
||||
({ type }) => type === CaveatTypes.restrictReturnedAccounts, |
||||
); |
||||
|
||||
if (caveat) { |
||||
originToAccountsMap.set(subject.origin, caveat.value); |
||||
} |
||||
return originToAccountsMap; |
||||
}, new Map()); |
||||
}, |
||||
); |
||||
|
||||
/** |
||||
* Given the current and previous exposed accounts for each PermissionController |
||||
* subject, returns a new map containing all accounts that have changed. |
||||
* The values of each map must be immutable values directly from the |
||||
* PermissionController state, or an empty array instantiated in this |
||||
* function. |
||||
* |
||||
* @param {Map<string, string[]>} newAccountsMap - The new origin:accounts[] map. |
||||
* @param {Map<string, string[]>} [previousAccountsMap] - The previous origin:accounts[] map. |
||||
* @returns {Map<string, string[]>} The origin:accounts[] map of changed accounts. |
||||
*/ |
||||
export const getChangedAccounts = (newAccountsMap, previousAccountsMap) => { |
||||
if (previousAccountsMap === undefined) { |
||||
return newAccountsMap; |
||||
} |
||||
|
||||
const changedAccounts = new Map(); |
||||
if (newAccountsMap === previousAccountsMap) { |
||||
return changedAccounts; |
||||
} |
||||
|
||||
const newOrigins = new Set([...newAccountsMap.keys()]); |
||||
|
||||
for (const origin of previousAccountsMap.keys()) { |
||||
const newAccounts = newAccountsMap.get(origin) ?? []; |
||||
|
||||
// The values of these maps are references to immutable values, which is why
|
||||
// a strict equality check is enough for diffing. The values are either from
|
||||
// PermissionController state, or an empty array initialized in the previous
|
||||
// call to this function. `newAccountsMap` will never contain any empty
|
||||
// arrays.
|
||||
if (previousAccountsMap.get(origin) !== newAccounts) { |
||||
changedAccounts.set(origin, newAccounts); |
||||
} |
||||
|
||||
newOrigins.delete(origin); |
||||
} |
||||
|
||||
// By now, newOrigins is either empty or contains some number of previously
|
||||
// unencountered origins, and all of their accounts have "changed".
|
||||
for (const origin of newOrigins.keys()) { |
||||
changedAccounts.set(origin, newAccountsMap.get(origin)); |
||||
} |
||||
return changedAccounts; |
||||
}; |
@ -0,0 +1,116 @@ |
||||
import { cloneDeep } from 'lodash'; |
||||
import { getChangedAccounts, getPermittedAccountsByOrigin } from './selectors'; |
||||
|
||||
describe('PermissionController selectors', () => { |
||||
describe('getChangedAccounts', () => { |
||||
it('returns the new value if the previous value is undefined', () => { |
||||
const newAccounts = new Map([['foo.bar', ['0x1']]]); |
||||
expect(getChangedAccounts(newAccounts)).toBe(newAccounts); |
||||
}); |
||||
|
||||
it('returns an empty map if the new and previous values are the same', () => { |
||||
const newAccounts = new Map([['foo.bar', ['0x1']]]); |
||||
expect(getChangedAccounts(newAccounts, newAccounts)).toStrictEqual( |
||||
new Map(), |
||||
); |
||||
}); |
||||
|
||||
it('returns a new map of the changed accounts if the new and previous values differ', () => { |
||||
// We set this on the new and previous value under the key 'foo.bar' to
|
||||
// check that identical values are excluded.
|
||||
const identicalValue = ['0x1']; |
||||
|
||||
const previousAccounts = new Map([ |
||||
['bar.baz', ['0x1']], // included: different accounts
|
||||
['fizz.buzz', ['0x1']], // included: removed in new value
|
||||
]); |
||||
previousAccounts.set('foo.bar', identicalValue); |
||||
|
||||
const newAccounts = new Map([ |
||||
['bar.baz', ['0x1', '0x2']], // included: different accounts
|
||||
['baz.fizz', ['0x3']], // included: brand new
|
||||
]); |
||||
newAccounts.set('foo.bar', identicalValue); |
||||
|
||||
expect(getChangedAccounts(newAccounts, previousAccounts)).toStrictEqual( |
||||
new Map([ |
||||
['bar.baz', ['0x1', '0x2']], |
||||
['fizz.buzz', []], |
||||
['baz.fizz', ['0x3']], |
||||
]), |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('getPermittedAccountsByOrigin', () => { |
||||
it('memoizes and gets permitted accounts by origin', () => { |
||||
const state1 = { |
||||
subjects: { |
||||
'foo.bar': { |
||||
origin: 'foo.bar', |
||||
permissions: { |
||||
eth_accounts: { |
||||
caveats: [{ type: 'restrictReturnedAccounts', value: ['0x1'] }], |
||||
}, |
||||
}, |
||||
}, |
||||
'bar.baz': { |
||||
origin: 'bar.baz', |
||||
permissions: { |
||||
eth_accounts: { |
||||
caveats: [{ type: 'restrictReturnedAccounts', value: ['0x2'] }], |
||||
}, |
||||
}, |
||||
}, |
||||
'baz.bizz': { |
||||
origin: 'baz.fizz', |
||||
permissions: { |
||||
eth_accounts: { |
||||
caveats: [ |
||||
{ type: 'restrictReturnedAccounts', value: ['0x1', '0x2'] }, |
||||
], |
||||
}, |
||||
}, |
||||
}, |
||||
'no.accounts': { |
||||
// we shouldn't see this in the result
|
||||
permissions: { |
||||
foobar: {}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const expected1 = new Map([ |
||||
['foo.bar', ['0x1']], |
||||
['bar.baz', ['0x2']], |
||||
['baz.fizz', ['0x1', '0x2']], |
||||
]); |
||||
|
||||
const selected1 = getPermittedAccountsByOrigin(state1); |
||||
|
||||
expect(selected1).toStrictEqual(expected1); |
||||
// The selector should return the memoized value if state.subjects is
|
||||
// the same object
|
||||
expect(selected1).toBe(getPermittedAccountsByOrigin(state1)); |
||||
|
||||
// If we mutate the state, the selector return value should be different
|
||||
// from the first.
|
||||
const state2 = cloneDeep(state1); |
||||
delete state2.subjects['foo.bar']; |
||||
|
||||
const expected2 = new Map([ |
||||
['bar.baz', ['0x2']], |
||||
['baz.fizz', ['0x1', '0x2']], |
||||
]); |
||||
|
||||
const selected2 = getPermittedAccountsByOrigin(state2); |
||||
|
||||
expect(selected2).toStrictEqual(expected2); |
||||
expect(selected2).not.toBe(selected1); |
||||
// Since we didn't mutate the state at this point, the value should once
|
||||
// again be the memoized.
|
||||
expect(selected2).toBe(getPermittedAccountsByOrigin(state2)); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,258 @@ |
||||
import { constructPermission } from '@metamask/snap-controllers'; |
||||
import { |
||||
CaveatTypes, |
||||
RestrictedMethods, |
||||
} from '../../../../shared/constants/permissions'; |
||||
|
||||
/** |
||||
* This file contains the specifications of the permissions and caveats |
||||
* that are recognized by our permission system. See the PermissionController |
||||
* README in @metamask/snap-controllers for details. |
||||
*/ |
||||
|
||||
/** |
||||
* The "keys" of all of permissions recognized by the PermissionController. |
||||
* Permission keys and names have distinct meanings in the permission system. |
||||
*/ |
||||
const PermissionKeys = Object.freeze({ |
||||
...RestrictedMethods, |
||||
}); |
||||
|
||||
/** |
||||
* Factory functions for all caveat types recognized by the |
||||
* PermissionController. |
||||
*/ |
||||
const CaveatFactories = Object.freeze({ |
||||
[CaveatTypes.restrictReturnedAccounts]: (accounts) => { |
||||
return { type: CaveatTypes.restrictReturnedAccounts, value: accounts }; |
||||
}, |
||||
}); |
||||
|
||||
/** |
||||
* A PreferencesController identity object. |
||||
* |
||||
* @typedef {Object} Identity |
||||
* @property {string} address - The address of the identity. |
||||
* @property {string} name - The name of the identity. |
||||
* @property {number} [lastSelected] - Unix timestamp of when the identity was |
||||
* last selected in the UI. |
||||
*/ |
||||
|
||||
/** |
||||
* Gets the specifications for all caveats that will be recognized by the |
||||
* PermissionController. |
||||
* |
||||
* @param {{ |
||||
* getIdentities: () => Record<string, Identity>, |
||||
* }} options - Options bag. |
||||
*/ |
||||
export const getCaveatSpecifications = ({ getIdentities }) => { |
||||
return { |
||||
[CaveatTypes.restrictReturnedAccounts]: { |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
|
||||
decorator: (method, caveat) => { |
||||
return async (args) => { |
||||
const result = await method(args); |
||||
return result |
||||
.filter((account) => caveat.value.includes(account)) |
||||
.slice(0, 1); |
||||
}; |
||||
}, |
||||
|
||||
validator: (caveat, _origin, _target) => |
||||
validateCaveatAccounts(caveat.value, getIdentities), |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Gets the specifications for all permissions that will be recognized by the |
||||
* PermissionController. |
||||
* |
||||
* @param {{ |
||||
* getAllAccounts: () => Promise<string[]>, |
||||
* getIdentities: () => Record<string, Identity>, |
||||
* }} options - Options bag. |
||||
* @param options.getAllAccounts - A function that returns all Ethereum accounts |
||||
* in the current MetaMask instance. |
||||
* @param options.getIdentities - A function that returns the |
||||
* `PreferencesController` identity objects for all Ethereum accounts in the |
||||
* current MetaMask instance. |
||||
*/ |
||||
export const getPermissionSpecifications = ({ |
||||
getAllAccounts, |
||||
getIdentities, |
||||
}) => { |
||||
return { |
||||
[PermissionKeys.eth_accounts]: { |
||||
targetKey: PermissionKeys.eth_accounts, |
||||
allowedCaveats: [CaveatTypes.restrictReturnedAccounts], |
||||
|
||||
factory: (permissionOptions, requestData) => { |
||||
if (Array.isArray(permissionOptions.caveats)) { |
||||
throw new Error( |
||||
`${PermissionKeys.eth_accounts} error: Received unexpected caveats. Any permitted caveats will be added automatically.`, |
||||
); |
||||
} |
||||
|
||||
// This value will be further validated as part of the caveat.
|
||||
if (!requestData.approvedAccounts) { |
||||
throw new Error( |
||||
`${PermissionKeys.eth_accounts} error: No approved accounts specified.`, |
||||
); |
||||
} |
||||
|
||||
return constructPermission({ |
||||
...permissionOptions, |
||||
caveats: [ |
||||
CaveatFactories[CaveatTypes.restrictReturnedAccounts]( |
||||
requestData.approvedAccounts, |
||||
), |
||||
], |
||||
}); |
||||
}, |
||||
|
||||
methodImplementation: async (_args) => { |
||||
const accounts = await getAllAccounts(); |
||||
const identities = getIdentities(); |
||||
|
||||
return accounts.sort((firstAddress, secondAddress) => { |
||||
if (!identities[firstAddress]) { |
||||
throw new Error(`Missing identity for address: "${firstAddress}".`); |
||||
} else if (!identities[secondAddress]) { |
||||
throw new Error( |
||||
`Missing identity for address: "${secondAddress}".`, |
||||
); |
||||
} else if ( |
||||
identities[firstAddress].lastSelected === |
||||
identities[secondAddress].lastSelected |
||||
) { |
||||
return 0; |
||||
} else if (identities[firstAddress].lastSelected === undefined) { |
||||
return 1; |
||||
} else if (identities[secondAddress].lastSelected === undefined) { |
||||
return -1; |
||||
} |
||||
|
||||
return ( |
||||
identities[secondAddress].lastSelected - |
||||
identities[firstAddress].lastSelected |
||||
); |
||||
}); |
||||
}, |
||||
|
||||
validator: (permission, _origin, _target) => { |
||||
const { caveats } = permission; |
||||
if ( |
||||
!caveats || |
||||
caveats.length !== 1 || |
||||
caveats[0].type !== CaveatTypes.restrictReturnedAccounts |
||||
) { |
||||
throw new Error( |
||||
`${PermissionKeys.eth_accounts} error: Invalid caveats. There must be a single caveat of type "${CaveatTypes.restrictReturnedAccounts}".`, |
||||
); |
||||
} |
||||
}, |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Validates the accounts associated with a caveat. In essence, ensures that |
||||
* the accounts value is an array of non-empty strings, and that each string |
||||
* corresponds to a PreferencesController identity. |
||||
* |
||||
* @param {string[]} accounts - The accounts associated with the caveat. |
||||
* @param {() => Record<string, Identity>} getIdentities - Gets all |
||||
* PreferencesController identities. |
||||
*/ |
||||
function validateCaveatAccounts(accounts, getIdentities) { |
||||
if (!Array.isArray(accounts) || accounts.length === 0) { |
||||
throw new Error( |
||||
`${PermissionKeys.eth_accounts} error: Expected non-empty array of Ethereum addresses.`, |
||||
); |
||||
} |
||||
|
||||
const identities = getIdentities(); |
||||
accounts.forEach((address) => { |
||||
if (!address || typeof address !== 'string') { |
||||
throw new Error( |
||||
`${PermissionKeys.eth_accounts} error: Expected an array of Ethereum addresses. Received: "${address}".`, |
||||
); |
||||
} |
||||
|
||||
if (!identities[address]) { |
||||
throw new Error( |
||||
`${PermissionKeys.eth_accounts} error: Received unrecognized address: "${address}".`, |
||||
); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* All unrestricted methods recognized by the PermissionController. |
||||
* Unrestricted methods are ignored by the permission system, but every |
||||
* JSON-RPC request seen by the permission system must correspond to a |
||||
* restricted or unrestricted method, or the request will be rejected with a |
||||
* "method not found" error. |
||||
*/ |
||||
export const unrestrictedMethods = Object.freeze([ |
||||
'eth_blockNumber', |
||||
'eth_call', |
||||
'eth_chainId', |
||||
'eth_coinbase', |
||||
'eth_decrypt', |
||||
'eth_estimateGas', |
||||
'eth_feeHistory', |
||||
'eth_gasPrice', |
||||
'eth_getBalance', |
||||
'eth_getBlockByHash', |
||||
'eth_getBlockByNumber', |
||||
'eth_getBlockTransactionCountByHash', |
||||
'eth_getBlockTransactionCountByNumber', |
||||
'eth_getCode', |
||||
'eth_getEncryptionPublicKey', |
||||
'eth_getFilterChanges', |
||||
'eth_getFilterLogs', |
||||
'eth_getLogs', |
||||
'eth_getProof', |
||||
'eth_getStorageAt', |
||||
'eth_getTransactionByBlockHashAndIndex', |
||||
'eth_getTransactionByBlockNumberAndIndex', |
||||
'eth_getTransactionByHash', |
||||
'eth_getTransactionCount', |
||||
'eth_getTransactionReceipt', |
||||
'eth_getUncleByBlockHashAndIndex', |
||||
'eth_getUncleByBlockNumberAndIndex', |
||||
'eth_getUncleCountByBlockHash', |
||||
'eth_getUncleCountByBlockNumber', |
||||
'eth_getWork', |
||||
'eth_hashrate', |
||||
'eth_mining', |
||||
'eth_newBlockFilter', |
||||
'eth_newFilter', |
||||
'eth_newPendingTransactionFilter', |
||||
'eth_protocolVersion', |
||||
'eth_sendRawTransaction', |
||||
'eth_sendTransaction', |
||||
'eth_sign', |
||||
'eth_signTypedData', |
||||
'eth_signTypedData_v1', |
||||
'eth_signTypedData_v3', |
||||
'eth_signTypedData_v4', |
||||
'eth_submitHashrate', |
||||
'eth_submitWork', |
||||
'eth_syncing', |
||||
'eth_uninstallFilter', |
||||
'metamask_getProviderState', |
||||
'metamask_watchAsset', |
||||
'net_listening', |
||||
'net_peerCount', |
||||
'net_version', |
||||
'personal_ecRecover', |
||||
'personal_sign', |
||||
'wallet_watchAsset', |
||||
'web3_clientVersion', |
||||
'web3_sha3', |
||||
]); |
@ -0,0 +1,340 @@ |
||||
import { |
||||
CaveatTypes, |
||||
RestrictedMethods, |
||||
} from '../../../../shared/constants/permissions'; |
||||
import { |
||||
getCaveatSpecifications, |
||||
getPermissionSpecifications, |
||||
unrestrictedMethods, |
||||
} from './specifications'; |
||||
|
||||
// Note: This causes Date.now() to return the number 1.
|
||||
jest.useFakeTimers('modern').setSystemTime(1); |
||||
|
||||
describe('PermissionController specifications', () => { |
||||
describe('caveat specifications', () => { |
||||
it('getCaveatSpecifications returns the expected specifications object', () => { |
||||
const caveatSpecifications = getCaveatSpecifications({}); |
||||
expect(Object.keys(caveatSpecifications)).toHaveLength(1); |
||||
expect( |
||||
caveatSpecifications[CaveatTypes.restrictReturnedAccounts].type, |
||||
).toStrictEqual(CaveatTypes.restrictReturnedAccounts); |
||||
}); |
||||
|
||||
describe('restrictReturnedAccounts', () => { |
||||
describe('decorator', () => { |
||||
it('returns the first array member included in the caveat value', async () => { |
||||
const getIdentities = jest.fn(); |
||||
const { decorator } = getCaveatSpecifications({ getIdentities })[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
const method = async () => ['0x1', '0x2', '0x3']; |
||||
const caveat = { |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1', '0x2'], |
||||
}; |
||||
const decorated = decorator(method, caveat); |
||||
expect(await decorated()).toStrictEqual(['0x1']); |
||||
}); |
||||
|
||||
it('returns an empty array if no array members are included in the caveat value', async () => { |
||||
const getIdentities = jest.fn(); |
||||
const { decorator } = getCaveatSpecifications({ getIdentities })[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
const method = async () => ['0x1', '0x2', '0x3']; |
||||
const caveat = { |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x5'], |
||||
}; |
||||
const decorated = decorator(method, caveat); |
||||
expect(await decorated()).toStrictEqual([]); |
||||
}); |
||||
|
||||
it('returns an empty array if the method result is an empty array', async () => { |
||||
const getIdentities = jest.fn(); |
||||
const { decorator } = getCaveatSpecifications({ getIdentities })[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
const method = async () => []; |
||||
const caveat = { |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1', '0x2'], |
||||
}; |
||||
const decorated = decorator(method, caveat); |
||||
expect(await decorated()).toStrictEqual([]); |
||||
}); |
||||
}); |
||||
|
||||
describe('validator', () => { |
||||
it('rejects invalid array values', () => { |
||||
const getIdentities = jest.fn(); |
||||
const { validator } = getCaveatSpecifications({ getIdentities })[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
[null, 'foo', {}, []].forEach((invalidValue) => { |
||||
expect(() => validator({ value: invalidValue })).toThrow( |
||||
/Expected non-empty array of Ethereum addresses\.$/u, |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
it('rejects falsy or non-string addresses', () => { |
||||
const getIdentities = jest.fn(); |
||||
const { validator } = getCaveatSpecifications({ getIdentities })[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
[[{}], [[]], [null], ['']].forEach((invalidValue) => { |
||||
expect(() => validator({ value: invalidValue })).toThrow( |
||||
/Expected an array of Ethereum addresses. Received:/u, |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
it('rejects addresses that have no corresponding identity', () => { |
||||
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||
return { |
||||
'0x1': true, |
||||
'0x3': true, |
||||
}; |
||||
}); |
||||
|
||||
const { validator } = getCaveatSpecifications({ getIdentities })[ |
||||
CaveatTypes.restrictReturnedAccounts |
||||
]; |
||||
|
||||
expect(() => validator({ value: ['0x1', '0x2', '0x3'] })).toThrow( |
||||
/Received unrecognized address:/u, |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('permission specifications', () => { |
||||
it('getPermissionSpecifications returns the expected specifications object', () => { |
||||
const permissionSpecifications = getPermissionSpecifications({}); |
||||
expect(Object.keys(permissionSpecifications)).toHaveLength(1); |
||||
expect( |
||||
permissionSpecifications[RestrictedMethods.eth_accounts].targetKey, |
||||
).toStrictEqual(RestrictedMethods.eth_accounts); |
||||
}); |
||||
|
||||
describe('eth_accounts', () => { |
||||
describe('factory', () => { |
||||
it('constructs a valid eth_accounts permission', () => { |
||||
const getIdentities = jest.fn(); |
||||
const getAllAccounts = jest.fn(); |
||||
const { factory } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
expect( |
||||
factory( |
||||
{ invoker: 'foo.bar', target: 'eth_accounts' }, |
||||
{ approvedAccounts: ['0x1'] }, |
||||
), |
||||
).toStrictEqual({ |
||||
caveats: [ |
||||
{ |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1'], |
||||
}, |
||||
], |
||||
date: 1, |
||||
id: expect.any(String), |
||||
invoker: 'foo.bar', |
||||
parentCapability: 'eth_accounts', |
||||
}); |
||||
}); |
||||
|
||||
it('throws an error if no approvedAccounts are specified', () => { |
||||
const getIdentities = jest.fn(); |
||||
const getAllAccounts = jest.fn(); |
||||
const { factory } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
expect(() => |
||||
factory( |
||||
{ invoker: 'foo.bar', target: 'eth_accounts' }, |
||||
{}, // no approvedAccounts
|
||||
), |
||||
).toThrow(/No approved accounts specified\.$/u); |
||||
}); |
||||
|
||||
it('throws an error if any caveats are specified directly', () => { |
||||
const getIdentities = jest.fn(); |
||||
const getAllAccounts = jest.fn(); |
||||
const { factory } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
expect(() => |
||||
factory( |
||||
{ |
||||
caveats: [ |
||||
{ |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1', '0x2'], |
||||
}, |
||||
], |
||||
invoker: 'foo.bar', |
||||
target: 'eth_accounts', |
||||
}, |
||||
{ approvedAccounts: ['0x1'] }, |
||||
), |
||||
).toThrow(/Received unexpected caveats./u); |
||||
}); |
||||
}); |
||||
|
||||
describe('methodImplementation', () => { |
||||
it('returns the keyring accounts in lastSelected order', async () => { |
||||
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||
return { |
||||
'0x1': { |
||||
lastSelected: 1, |
||||
}, |
||||
'0x2': {}, |
||||
'0x3': { |
||||
lastSelected: 3, |
||||
}, |
||||
'0x4': { |
||||
lastSelected: 3, |
||||
}, |
||||
}; |
||||
}); |
||||
const getAllAccounts = jest |
||||
.fn() |
||||
.mockImplementationOnce(() => ['0x1', '0x2', '0x3', '0x4']); |
||||
|
||||
const { methodImplementation } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
expect(await methodImplementation()).toStrictEqual([ |
||||
'0x3', |
||||
'0x4', |
||||
'0x1', |
||||
'0x2', |
||||
]); |
||||
}); |
||||
|
||||
it('throws if a keyring account is missing an address (case 1)', async () => { |
||||
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||
return { |
||||
'0x2': { |
||||
lastSelected: 3, |
||||
}, |
||||
'0x3': { |
||||
lastSelected: 3, |
||||
}, |
||||
}; |
||||
}); |
||||
const getAllAccounts = jest |
||||
.fn() |
||||
.mockImplementationOnce(() => ['0x1', '0x2', '0x3']); |
||||
|
||||
const { methodImplementation } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
await expect(() => methodImplementation()).rejects.toThrow( |
||||
'Missing identity for address: "0x1".', |
||||
); |
||||
}); |
||||
|
||||
it('throws if a keyring account is missing an address (case 2)', async () => { |
||||
const getIdentities = jest.fn().mockImplementationOnce(() => { |
||||
return { |
||||
'0x1': { |
||||
lastSelected: 1, |
||||
}, |
||||
'0x3': { |
||||
lastSelected: 3, |
||||
}, |
||||
}; |
||||
}); |
||||
const getAllAccounts = jest |
||||
.fn() |
||||
.mockImplementationOnce(() => ['0x1', '0x2', '0x3']); |
||||
|
||||
const { methodImplementation } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
await expect(() => methodImplementation()).rejects.toThrow( |
||||
'Missing identity for address: "0x2".', |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('validator', () => { |
||||
it('accepts valid permissions', () => { |
||||
const getIdentities = jest.fn(); |
||||
const getAllAccounts = jest.fn(); |
||||
const { validator } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
expect(() => |
||||
validator({ |
||||
caveats: [ |
||||
{ |
||||
type: CaveatTypes.restrictReturnedAccounts, |
||||
value: ['0x1', '0x2'], |
||||
}, |
||||
], |
||||
date: 1, |
||||
id: expect.any(String), |
||||
invoker: 'foo.bar', |
||||
parentCapability: 'eth_accounts', |
||||
}), |
||||
).not.toThrow(); |
||||
}); |
||||
|
||||
it('rejects invalid caveats', () => { |
||||
const getIdentities = jest.fn(); |
||||
const getAllAccounts = jest.fn(); |
||||
const { validator } = getPermissionSpecifications({ |
||||
getIdentities, |
||||
getAllAccounts, |
||||
})[RestrictedMethods.eth_accounts]; |
||||
|
||||
[null, [], [1, 2], [{ type: 'foobar' }]].forEach( |
||||
(invalidCaveatsValue) => { |
||||
expect(() => |
||||
validator({ |
||||
caveats: invalidCaveatsValue, |
||||
date: 1, |
||||
id: expect.any(String), |
||||
invoker: 'foo.bar', |
||||
parentCapability: 'eth_accounts', |
||||
}), |
||||
).toThrow(/Invalid caveats./u); |
||||
}, |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('unrestricted methods', () => { |
||||
it('defines the unrestricted methods', () => { |
||||
expect(Array.isArray(unrestrictedMethods)).toBe(true); |
||||
expect(Object.isFrozen(unrestrictedMethods)).toBe(true); |
||||
}); |
||||
}); |
||||
}); |
@ -1,34 +1,36 @@ |
||||
import { strict as assert } from 'assert'; |
||||
import cleanErrorStack from './cleanErrorStack'; |
||||
|
||||
describe('Clean Error Stack', function () { |
||||
describe('Clean Error Stack', () => { |
||||
const testMessage = 'Test Message'; |
||||
const testError = new Error(testMessage); |
||||
const undefinedErrorName = new Error(testMessage); |
||||
const blankErrorName = new Error(testMessage); |
||||
const blankMsgError = new Error(); |
||||
|
||||
beforeEach(function () { |
||||
beforeEach(() => { |
||||
undefinedErrorName.name = undefined; |
||||
blankErrorName.name = ''; |
||||
}); |
||||
|
||||
it('tests error with message', function () { |
||||
assert.equal(cleanErrorStack(testError).toString(), 'Error: Test Message'); |
||||
it('tests error with message', () => { |
||||
expect(cleanErrorStack(testError).toString()).toStrictEqual( |
||||
'Error: Test Message', |
||||
); |
||||
}); |
||||
|
||||
it('tests error with undefined name', function () { |
||||
assert.equal( |
||||
cleanErrorStack(undefinedErrorName).toString(), |
||||
it('tests error with undefined name', () => { |
||||
expect(cleanErrorStack(undefinedErrorName).toString()).toStrictEqual( |
||||
'Error: Test Message', |
||||
); |
||||
}); |
||||
|
||||
it('tests error with blank name', function () { |
||||
assert.equal(cleanErrorStack(blankErrorName).toString(), 'Test Message'); |
||||
it('tests error with blank name', () => { |
||||
expect(cleanErrorStack(blankErrorName).toString()).toStrictEqual( |
||||
'Test Message', |
||||
); |
||||
}); |
||||
|
||||
it('tests error with blank message', function () { |
||||
assert.equal(cleanErrorStack(blankMsgError).toString(), 'Error'); |
||||
it('tests error with blank message', () => { |
||||
expect(cleanErrorStack(blankMsgError).toString()).toStrictEqual('Error'); |
||||
}); |
||||
}); |
||||
|