import { cloneDeep } from 'lodash' import { CAVEAT_NAMES, HISTORY_STORE_KEY, LOG_IGNORE_METHODS, LOG_LIMIT, LOG_METHOD_TYPES, LOG_STORE_KEY, WALLET_PREFIX, } from './enums' /** * Controller with middleware for logging requests and responses to restricted * and permissions-related methods. */ export default class PermissionsLogController { constructor({ restrictedMethods, store }) { this.restrictedMethods = restrictedMethods this.store = store } /** * Get the activity log. * * @returns {Array} The activity log. */ getActivityLog() { return this.store.getState()[LOG_STORE_KEY] || [] } /** * Update the activity log. * * @param {Array} logs - The new activity log array. */ updateActivityLog(logs) { this.store.updateState({ [LOG_STORE_KEY]: logs }) } /** * Get the permissions history log. * * @returns {Object} The permissions history log. */ getHistory() { return this.store.getState()[HISTORY_STORE_KEY] || {} } /** * Update the permissions history log. * * @param {Object} history - The new permissions history log object. */ updateHistory(history) { this.store.updateState({ [HISTORY_STORE_KEY]: history }) } /** * Updates the exposed account history for the given origin. * Sets the 'last seen' time to Date.now() for the given accounts. * * @param {string} origin - The origin that the accounts are exposed to. * @param {Array} accounts - The accounts. */ updateAccountsHistory(origin, accounts) { if (accounts.length === 0) { return } const accountToTimeMap = getAccountToTimeMap(accounts, Date.now()) this.commitNewHistory(origin, { eth_accounts: { accounts: accountToTimeMap, }, }) } /** * Create a permissions log middleware. Records permissions activity and history: * * Activity: requests and responses for restricted and most wallet_ methods. * * History: for each origin, the last time a permission was granted, including * which accounts were exposed, if any. * * @returns {JsonRpcEngineMiddleware} The permissions log middleware. */ createMiddleware() { return (req, res, next, _end) => { let activityEntry, requestedMethods const { origin, method } = req const isInternal = method.startsWith(WALLET_PREFIX) // we only log certain methods if ( !LOG_IGNORE_METHODS.includes(method) && (isInternal || this.restrictedMethods.includes(method)) ) { activityEntry = this.logRequest(req, isInternal) if (method === `${WALLET_PREFIX}requestPermissions`) { // get the corresponding methods from the requested permissions so // that we can record permissions history requestedMethods = this.getRequestedMethods(req) } } else if (method === 'eth_requestAccounts') { // eth_requestAccounts is a special case; we need to extract the accounts // from it activityEntry = this.logRequest(req, isInternal) requestedMethods = ['eth_accounts'] } else { // no-op next() return } // call next with a return handler for capturing the response next((cb) => { const time = Date.now() this.logResponse(activityEntry, res, time) if (requestedMethods && !res.error && res.result) { // any permissions or accounts changes will be recorded on the response, // so we only log permissions history here this.logPermissionsHistory( requestedMethods, origin, res.result, time, method === 'eth_requestAccounts', ) } cb() }) } } /** * Creates and commits an activity log entry, without response data. * * @param {Object} request - The request object. * @param {boolean} isInternal - Whether the request is internal. */ logRequest(request, isInternal) { const activityEntry = { id: request.id, method: request.method, methodType: isInternal ? LOG_METHOD_TYPES.internal : LOG_METHOD_TYPES.restricted, origin: request.origin, request: cloneDeep(request), requestTime: Date.now(), response: null, responseTime: null, success: null, } this.commitNewActivity(activityEntry) return activityEntry } /** * Adds response data to an existing activity log entry. * Entry assumed already committed (i.e., in the log). * * @param {Object} entry - The entry to add a response to. * @param {Object} response - The response object. * @param {number} time - Output from Date.now() */ logResponse(entry, response, time) { if (!entry || !response) { return } entry.response = cloneDeep(response) entry.responseTime = time entry.success = !response.error } /** * Commit a new entry to the activity log. * Removes the oldest entry from the log if it exceeds the log limit. * * @param {Object} entry - The activity log entry. */ commitNewActivity(entry) { const logs = this.getActivityLog() // add new entry to end of log logs.push(entry) // remove oldest log if exceeding size limit if (logs.length > LOG_LIMIT) { logs.shift() } this.updateActivityLog(logs) } /** * Create new permissions history log entries, if any, and commit them. * * @param {Array} requestedMethods - The method names corresponding to the requested permissions. * @param {string} origin - The origin of the permissions request. * @param {Array { if (perm.parentCapability === 'eth_accounts') { accounts = this.getAccountsFromPermission(perm) } return perm.parentCapability }) .reduce((acc, method) => { // all approved permissions will be included in the response, // not just the newly requested ones if (requestedMethods.includes(method)) { if (method === 'eth_accounts') { const accountToTimeMap = getAccountToTimeMap(accounts, time) acc[method] = { lastApproved: time, accounts: accountToTimeMap, } } else { acc[method] = { lastApproved: time } } } return acc }, {}) } if (Object.keys(newEntries).length > 0) { this.commitNewHistory(origin, newEntries) } } /** * Commit new entries to the permissions history log. * Merges the history for the given origin, overwriting existing entries * with the same key (permission name). * * @param {string} origin - The requesting origin. * @param {Object} newEntries - The new entries to commit. */ commitNewHistory(origin, newEntries) { // a simple merge updates most permissions const history = this.getHistory() const newOriginHistory = { ...history[origin], ...newEntries, } // eth_accounts requires special handling, because of information // we store about the accounts const existingEthAccountsEntry = history[origin] && history[origin].eth_accounts const newEthAccountsEntry = newEntries.eth_accounts if (existingEthAccountsEntry && newEthAccountsEntry) { // we may intend to update just the accounts, not the permission // itself const lastApproved = newEthAccountsEntry.lastApproved || existingEthAccountsEntry.lastApproved // merge old and new eth_accounts history entries newOriginHistory.eth_accounts = { lastApproved, accounts: { ...existingEthAccountsEntry.accounts, ...newEthAccountsEntry.accounts, }, } } history[origin] = newOriginHistory this.updateHistory(history) } /** * Get all requested methods from a permissions request. * * @param {Object} request - The request object. * @returns {Array} The names of the requested permissions. */ getRequestedMethods(request) { if ( !request.params || !request.params[0] || typeof request.params[0] !== 'object' || Array.isArray(request.params[0]) ) { return null } return Object.keys(request.params[0]) } /** * Get the permitted accounts from an eth_accounts permissions object. * Returns an empty array if the permission is not eth_accounts. * * @param {Object} perm - The permissions object. * @returns {Array} The permitted accounts. */ getAccountsFromPermission(perm) { if (perm.parentCapability !== 'eth_accounts' || !perm.caveats) { return [] } const accounts = new Set() for (const caveat of perm.caveats) { if ( caveat.name === CAVEAT_NAMES.exposedAccounts && Array.isArray(caveat.value) ) { for (const value of caveat.value) { accounts.add(value) } } } return [...accounts] } } // helper functions /** * Get a map from account addresses to the given time. * * @param {Array} accounts - An array of addresses. * @param {number} time - A time, e.g. Date.now(). * @returns {Object} A string:number map of addresses to time. */ function getAccountToTimeMap(accounts, time) { return accounts.reduce((acc, account) => ({ ...acc, [account]: time }), {}) }