mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-23 18:41:38 +01:00
5c5c90e70a
Attempts to approve or reject a permissions request that is no longer pending will now emit a warning instead of throwing an exception. I _think_ this can happen by clicking 'Submit' on the Permission Connect screen twice, though I've been unable to reproduce that. I know that it can be done if using multiple windows though. While it is possible we have a UI bug somewhere (e.g. maybe we're not preventing 'Submit' from being clicked twice), I don't think it's possible to eliminate the chance of this happening altogether, so we'd best prepare for it.
391 lines
10 KiB
JavaScript
391 lines
10 KiB
JavaScript
const JsonRpcEngine = require('json-rpc-engine')
|
|
const asMiddleware = require('json-rpc-engine/src/asMiddleware')
|
|
const ObservableStore = require('obs-store')
|
|
const log = require('loglevel')
|
|
const RpcCap = require('rpc-cap').CapabilitiesController
|
|
const { ethErrors } = require('eth-json-rpc-errors')
|
|
|
|
const getRestrictedMethods = require('./restrictedMethods')
|
|
const createMethodMiddleware = require('./methodMiddleware')
|
|
const createLoggerMiddleware = require('./loggerMiddleware')
|
|
|
|
// Methods that do not require any permissions to use:
|
|
const SAFE_METHODS = require('./permissions-safe-methods.json')
|
|
|
|
// some constants
|
|
const METADATA_STORE_KEY = 'domainMetadata'
|
|
const LOG_STORE_KEY = 'permissionsLog'
|
|
const HISTORY_STORE_KEY = 'permissionsHistory'
|
|
const WALLET_METHOD_PREFIX = 'wallet_'
|
|
const CAVEAT_NAMES = {
|
|
exposedAccounts: 'exposedAccounts',
|
|
}
|
|
const ACCOUNTS_CHANGED_NOTIFICATION = 'wallet_accountsChanged'
|
|
|
|
class PermissionsController {
|
|
|
|
constructor (
|
|
{
|
|
platform, notifyDomain, notifyAllDomains, keyringController,
|
|
} = {},
|
|
restoredPermissions = {},
|
|
restoredState = {}) {
|
|
this.store = new ObservableStore({
|
|
[METADATA_STORE_KEY]: restoredState[METADATA_STORE_KEY] || {},
|
|
[LOG_STORE_KEY]: restoredState[LOG_STORE_KEY] || [],
|
|
[HISTORY_STORE_KEY]: restoredState[HISTORY_STORE_KEY] || {},
|
|
})
|
|
this.notifyDomain = notifyDomain
|
|
this.notifyAllDomains = notifyAllDomains
|
|
this.keyringController = keyringController
|
|
this._platform = platform
|
|
this._restrictedMethods = getRestrictedMethods(this)
|
|
this._initializePermissions(restoredPermissions)
|
|
}
|
|
|
|
createMiddleware ({ origin, extensionId }) {
|
|
|
|
if (extensionId) {
|
|
this.store.updateState({
|
|
[METADATA_STORE_KEY]: {
|
|
...this.store.getState()[METADATA_STORE_KEY],
|
|
[origin]: { extensionId },
|
|
},
|
|
})
|
|
}
|
|
|
|
const engine = new JsonRpcEngine()
|
|
|
|
engine.push(createLoggerMiddleware({
|
|
walletPrefix: WALLET_METHOD_PREFIX,
|
|
restrictedMethods: Object.keys(this._restrictedMethods),
|
|
ignoreMethods: [ 'wallet_sendDomainMetadata' ],
|
|
store: this.store,
|
|
logStoreKey: LOG_STORE_KEY,
|
|
historyStoreKey: HISTORY_STORE_KEY,
|
|
}))
|
|
|
|
engine.push(createMethodMiddleware({
|
|
store: this.store,
|
|
storeKey: METADATA_STORE_KEY,
|
|
getAccounts: this.getAccounts.bind(this, origin),
|
|
requestAccountsPermission: this._requestPermissions.bind(
|
|
this, origin, { eth_accounts: {} }
|
|
),
|
|
}))
|
|
|
|
engine.push(this.permissions.providerMiddlewareFunction.bind(
|
|
this.permissions, { origin }
|
|
))
|
|
return asMiddleware(engine)
|
|
}
|
|
|
|
/**
|
|
* 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, () => {}, _end
|
|
)
|
|
|
|
function _end () {
|
|
if (res.error || !Array.isArray(res.result)) {
|
|
resolve([])
|
|
} else {
|
|
resolve(res.result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Submits a permissions request to rpc-cap. Internal use only.
|
|
*
|
|
* @param {string} origin - The origin string.
|
|
* @param {IRequestedPermissions} permissions - The requested permissions.
|
|
*/
|
|
_requestPermissions (origin, permissions) {
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const req = { method: 'wallet_requestPermissions', params: [permissions] }
|
|
const res = {}
|
|
this.permissions.providerMiddlewareFunction(
|
|
{ origin }, req, res, () => {}, _end
|
|
)
|
|
|
|
function _end (err) {
|
|
if (err || res.error) {
|
|
reject(err || res.error)
|
|
} else {
|
|
resolve(res.result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* User approval callback. The request can fail if the request is invalid.
|
|
*
|
|
* @param {object} approved - the approved request object
|
|
* @param {Array} accounts - The accounts to expose, if any
|
|
*/
|
|
async approvePermissionsRequest (approved, accounts) {
|
|
|
|
const { id } = approved.metadata
|
|
const approval = this.pendingApprovals[id]
|
|
|
|
if (!approval) {
|
|
log.warn(`Permissions request with id '${id}' not found`)
|
|
return
|
|
}
|
|
|
|
try {
|
|
|
|
// attempt to finalize the request and resolve it
|
|
await this.finalizePermissionsRequest(approved.permissions, accounts)
|
|
approval.resolve(approved.permissions)
|
|
|
|
} catch (err) {
|
|
|
|
// if finalization fails, reject the request
|
|
approval.reject(ethErrors.rpc.invalidRequest({
|
|
message: err.message, data: err,
|
|
}))
|
|
}
|
|
|
|
delete this.pendingApprovals[id]
|
|
}
|
|
|
|
/**
|
|
* User rejection callback.
|
|
*
|
|
* @param {string} id the id of the rejected request
|
|
*/
|
|
async rejectPermissionsRequest (id) {
|
|
const approval = this.pendingApprovals[id]
|
|
|
|
if (!approval) {
|
|
log.warn(`Permissions request with id '${id}' not found`)
|
|
return
|
|
}
|
|
|
|
approval.reject(ethErrors.provider.userRejectedRequest())
|
|
delete this.pendingApprovals[id]
|
|
}
|
|
|
|
/**
|
|
* Grants the given origin the eth_accounts permission for the given account(s).
|
|
* This method should ONLY be called as a result of direct user action in the UI,
|
|
* with the intention of supporting legacy dapps that don't support EIP 1102.
|
|
*
|
|
* @param {string} origin - The origin to expose the account(s) to.
|
|
* @param {Array<string>} accounts - The account(s) to expose.
|
|
*/
|
|
async legacyExposeAccounts (origin, accounts) {
|
|
|
|
const permissions = {
|
|
eth_accounts: {},
|
|
}
|
|
|
|
await this.finalizePermissionsRequest(permissions, accounts)
|
|
|
|
let error
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
this.permissions.grantNewPermissions(origin, permissions, {}, err => (err ? resolve() : reject(err)))
|
|
})
|
|
} catch (err) {
|
|
error = err
|
|
}
|
|
|
|
if (error) {
|
|
if (error.code === 4001) {
|
|
throw error
|
|
} else {
|
|
throw ethErrors.rpc.internal({
|
|
message: `Failed to add 'eth_accounts' to '${origin}'.`,
|
|
data: {
|
|
originalError: error,
|
|
accounts,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the accounts exposed to the given origin.
|
|
* Throws error if the update fails.
|
|
*
|
|
* @param {string} origin - The origin to change the exposed accounts for.
|
|
* @param {string[]} accounts - The new account(s) to expose.
|
|
*/
|
|
async updateExposedAccounts (origin, accounts) {
|
|
|
|
await this.validateExposedAccounts(accounts)
|
|
|
|
this.permissions.updateCaveatFor(
|
|
origin, 'eth_accounts', CAVEAT_NAMES.exposedAccounts, accounts
|
|
)
|
|
|
|
this.notifyDomain(origin, {
|
|
method: ACCOUNTS_CHANGED_NOTIFICATION,
|
|
result: accounts,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Finalizes a permissions request.
|
|
* Throws if request validation fails.
|
|
*
|
|
* @param {Object} requestedPermissions - The requested permissions.
|
|
* @param {string[]} accounts - The accounts to expose, if any.
|
|
*/
|
|
async finalizePermissionsRequest (requestedPermissions, accounts) {
|
|
|
|
const { eth_accounts: ethAccounts } = requestedPermissions
|
|
|
|
if (ethAccounts) {
|
|
|
|
await this.validateExposedAccounts(accounts)
|
|
|
|
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
|
|
))
|
|
|
|
ethAccounts.caveats.push(
|
|
{
|
|
type: 'filterResponse',
|
|
value: accounts,
|
|
name: CAVEAT_NAMES.exposedAccounts,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async validateExposedAccounts (accounts) {
|
|
|
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
throw new Error('Must provide non-empty array of account(s).')
|
|
}
|
|
|
|
// assert accounts exist
|
|
const allAccounts = await this.keyringController.getAccounts()
|
|
accounts.forEach(acc => {
|
|
if (!allAccounts.includes(acc)) {
|
|
throw new Error(`Unknown account: ${acc}`)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Removes the given permissions for the given domain.
|
|
* @param {object} domains { origin: [permissions] }
|
|
*/
|
|
removePermissionsFor (domains) {
|
|
|
|
Object.entries(domains).forEach(([origin, perms]) => {
|
|
|
|
this.permissions.removePermissionsFor(
|
|
origin,
|
|
perms.map(methodName => {
|
|
|
|
if (methodName === 'eth_accounts') {
|
|
this.notifyDomain(
|
|
origin,
|
|
{ method: ACCOUNTS_CHANGED_NOTIFICATION, result: [] }
|
|
)
|
|
}
|
|
|
|
return { parentCapability: methodName }
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Removes all known domains and their related permissions.
|
|
*/
|
|
clearPermissions () {
|
|
this.permissions.clearDomains()
|
|
this.notifyAllDomains({
|
|
method: ACCOUNTS_CHANGED_NOTIFICATION,
|
|
result: [],
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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.pendingApprovals = {}
|
|
|
|
this.permissions = new RpcCap({
|
|
|
|
// Supports passthrough methods:
|
|
safeMethods: SAFE_METHODS,
|
|
|
|
// optional prefix for internal methods
|
|
methodPrefix: WALLET_METHOD_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 } } = req
|
|
|
|
this._platform.openExtensionInBrowser('connect')
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.pendingApprovals[id] = { resolve, reject }
|
|
})
|
|
},
|
|
}, initState)
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
PermissionsController,
|
|
addInternalMethodPrefix: prefix,
|
|
CAVEAT_NAMES,
|
|
}
|
|
|
|
|
|
function prefix (method) {
|
|
return WALLET_METHOD_PREFIX + method
|
|
}
|