mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-28 05:12:18 +01:00
63bd422840
The connect route now takes a route parameter: the permissions request id. This id is set whenever the permissions connect screen is opened, ensuring that that tab is for that specific request alone. This makes handling of multiple permissions requests a bit more intuitive. Previously whenever opening multiple permissions requests, the first one would be shown on each successive tab, whereas you would expect each tab to show the request that prompted the tab to open. Users may now address permissions request in whichever order they'd like to, rather than being forced to deal with them chronologically.
391 lines
11 KiB
JavaScript
391 lines
11 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/${id}`)
|
|
|
|
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
|
|
}
|