diff --git a/packages/snaps-controllers/jest.config.js b/packages/snaps-controllers/jest.config.js index 35b1764892..96340fb853 100644 --- a/packages/snaps-controllers/jest.config.js +++ b/packages/snaps-controllers/jest.config.js @@ -7,8 +7,8 @@ module.exports = deepmerge(baseConfig, { global: { branches: 88.74, functions: 97.6, - lines: 96.74, - statements: 96.74, + lines: 97.1, + statements: 97.1, }, }, projects: [ diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 51d3f9dda6..a0fb03d67b 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -41,6 +41,7 @@ "@metamask/snaps-execution-environments": "^0.24.1", "@metamask/snaps-types": "^0.24.1", "@metamask/snaps-utils": "^0.24.1", + "@metamask/subject-metadata-controller": "^1.0.0", "@metamask/utils": "^3.3.1", "@xstate/fsm": "^2.0.0", "concat-stream": "^2.0.0", diff --git a/packages/snaps-controllers/src/multichain/MultiChainController.test.ts b/packages/snaps-controllers/src/multichain/MultiChainController.test.ts index a6df6fcfb6..6fb662e9a2 100644 --- a/packages/snaps-controllers/src/multichain/MultiChainController.test.ts +++ b/packages/snaps-controllers/src/multichain/MultiChainController.test.ts @@ -55,7 +55,7 @@ describe('MultiChainController', () => { }, }, }); - expect(rootMessenger.call).toHaveBeenCalledTimes(11); + expect(rootMessenger.call).toHaveBeenCalledTimes(12); snapController.destroy(); await executionService.terminateAllSnaps(); @@ -99,7 +99,7 @@ describe('MultiChainController', () => { MOCK_SNAP_ID, ); - expect(rootMessenger.call).toHaveBeenCalledTimes(21); + expect(rootMessenger.call).toHaveBeenCalledTimes(23); snapController.destroy(); await executionService.terminateAllSnaps(); @@ -153,7 +153,7 @@ describe('MultiChainController', () => { }, }); - expect(rootMessenger.call).toHaveBeenCalledTimes(9); + expect(rootMessenger.call).toHaveBeenCalledTimes(10); snapController.destroy(); await executionService.terminateAllSnaps(); @@ -255,9 +255,9 @@ describe('MultiChainController', () => { }, }); - expect(rootMessenger.call).toHaveBeenCalledTimes(17); + expect(rootMessenger.call).toHaveBeenCalledTimes(19); expect(rootMessenger.call).toHaveBeenNthCalledWith( - 15, + 17, 'ApprovalController:addRequest', { id: expect.any(String), @@ -326,7 +326,7 @@ describe('MultiChainController', () => { ).rejects.toThrow( 'No installed snaps found for any requested namespace.', ); - expect(rootMessenger.call).toHaveBeenCalledTimes(9); + expect(rootMessenger.call).toHaveBeenCalledTimes(10); snapController.destroy(); await executionService.terminateAllSnaps(); @@ -359,7 +359,7 @@ describe('MultiChainController', () => { }); expect(result).toEqual(['eip155:1:foo']); - expect(rootMessenger.call).toHaveBeenCalledTimes(15); + expect(rootMessenger.call).toHaveBeenCalledTimes(17); snapController.destroy(); await executionService.terminateAllSnaps(); diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.ts b/packages/snaps-controllers/src/snaps/SnapController.test.ts index 72e42df00f..0c86ce1f37 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.test.ts @@ -45,12 +45,16 @@ import { getSnapControllerWithEES, getSnapControllerWithEESOptions, MOCK_BLOCK_NUMBER, + MOCK_DAPP_SUBJECT_METADATA, + MOCK_DAPPS_RPC_ORIGINS_PERMISSION, MOCK_NAMESPACES, + MOCK_RPC_ORIGINS_PERMISSION, + MOCK_SNAP_SUBJECT_METADATA, PERSISTED_MOCK_KEYRING_SNAP, sleep, } from '../test-utils'; import { delay } from '../utils'; -import { SnapEndowments } from './endowments'; +import { handlerEndowments, SnapEndowments } from './endowments'; import { SnapControllerState, SNAP_APPROVAL_UPDATE } from './SnapController'; const { subtle } = new Crypto(); @@ -433,8 +437,8 @@ describe('SnapController', () => { rootMessenger.registerActionHandler( 'PermissionController:hasPermission', - () => { - return false; + (_origin, permission) => { + return permission === SnapEndowments.Rpc; }, ); @@ -878,7 +882,9 @@ describe('SnapController', () => { rootMessenger.registerActionHandler( 'PermissionController:hasPermission', - () => false, + (_origin, permission) => { + return permission === SnapEndowments.Rpc; + }, ); await snapController.startSnap(snap.id); @@ -1286,7 +1292,9 @@ describe('SnapController', () => { rootMessenger.registerActionHandler( 'PermissionController:hasPermission', - () => false, + (_origin, permission) => { + return permission === SnapEndowments.Rpc; + }, ); await snapController.startSnap(snap.id); @@ -1315,34 +1323,143 @@ describe('SnapController', () => { await service.terminateAllSnaps(); }); + describe('handleRequest', () => { + it.each(Object.keys(handlerEndowments) as HandlerType[])( + 'throws if the snap does not have permission for the handler', + async (handler) => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + rootMessenger.registerActionHandler( + 'PermissionController:hasPermission', + () => false, + ); + + const snap = snapController.getExpect(MOCK_SNAP_ID); + await expect( + snapController.handleRequest({ + snapId: snap.id, + origin: 'foo.com', + handler, + request: { jsonrpc: '2.0', method: 'test' }, + }), + ).rejects.toThrow( + `Snap "${snap.id}" is not permitted to use "${handlerEndowments[handler]}".`, + ); + + snapController.destroy(); + }, + ); + + it('throws if the snap does not have permission to handle JSON-RPC requests from dapps', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({ + // Permission to receive JSON-RPC requests from other Snaps. + [SnapEndowments.Rpc]: MOCK_RPC_ORIGINS_PERMISSION, + }), + ); + + rootMessenger.registerActionHandler( + 'SubjectMetadataController:getSubjectMetadata', + () => MOCK_DAPP_SUBJECT_METADATA, + ); + + const snap = snapController.getExpect(MOCK_SNAP_ID); + await expect( + snapController.handleRequest({ + snapId: snap.id, + origin: MOCK_ORIGIN, + handler: HandlerType.OnRpcRequest, + request: { jsonrpc: '2.0', method: 'test' }, + }), + ).rejects.toThrow( + `Snap "${snap.id}" is not permitted to handle JSON-RPC requests from "${MOCK_ORIGIN}".`, + ); + + snapController.destroy(); + }); + + it('throws if the snap does not have permission to handle JSON-RPC requests from snaps', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({ + // Permission to receive JSON-RPC requests from dapps. + [SnapEndowments.Rpc]: MOCK_DAPPS_RPC_ORIGINS_PERMISSION, + }), + ); + + rootMessenger.registerActionHandler( + 'SubjectMetadataController:getSubjectMetadata', + () => MOCK_SNAP_SUBJECT_METADATA, + ); + + const snap = snapController.getExpect(MOCK_SNAP_ID); + await expect( + snapController.handleRequest({ + snapId: snap.id, + origin: MOCK_SNAP_ID, + handler: HandlerType.OnRpcRequest, + request: { jsonrpc: '2.0', method: 'test' }, + }), + ).rejects.toThrow( + `Snap "${snap.id}" is not permitted to handle JSON-RPC requests from "${MOCK_SNAP_ID}".`, + ); + + snapController.destroy(); + }); + }); + describe('getRpcRequestHandler', () => { it('handlers populate the "jsonrpc" property if missing', async () => { - const snapId = 'fooSnap'; + const rootMessenger = getControllerMessenger(); const options = getSnapControllerWithEESOptions({ + rootMessenger, state: { - snaps: { - [snapId]: { - enabled: true, - id: snapId, - status: SnapStatus.Running, - } as any, - }, + snaps: getPersistedSnapsState(), }, }); const [snapController, service] = getSnapControllerWithEES(options); - const mockMessageHandler = jest.fn(); - const spyOnMessengerCall = jest - .spyOn(options.messenger, 'call') - .mockImplementation((method, ..._args: unknown[]) => { - if (method === 'ExecutionService:handleRpcRequest') { - return mockMessageHandler as any; - } - return true; - }); + rootMessenger.registerActionHandler( + 'PermissionController:hasPermission', + (_origin, permission) => { + return permission === SnapEndowments.Rpc; + }, + ); await snapController.handleRequest({ - snapId, + snapId: MOCK_SNAP_ID, origin: 'foo.com', handler: HandlerType.OnRpcRequest, request: { @@ -1353,10 +1470,10 @@ describe('SnapController', () => { }, }); - expect(spyOnMessengerCall).toHaveBeenCalledTimes(2); - expect(spyOnMessengerCall).toHaveBeenCalledWith( + expect(rootMessenger.call).toHaveBeenCalledTimes(7); + expect(rootMessenger.call).toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', - snapId, + MOCK_SNAP_ID, { origin: 'foo.com', handler: HandlerType.OnRpcRequest, @@ -1403,7 +1520,8 @@ describe('SnapController', () => { }); it('handlers will throw if there are too many pending requests before a snap has started', async () => { - const messenger = getSnapControllerMessenger(); + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); const fakeSnap = getPersistedSnapObject({ status: SnapStatus.Stopped }); const snapId = fakeSnap.id; const snapController = getSnapController( @@ -1422,14 +1540,10 @@ describe('SnapController', () => { resolveExecutePromise = res; }); - jest - .spyOn(messenger, 'call') - .mockImplementation((method, ..._args: unknown[]) => { - if (method === 'ExecutionService:executeSnap') { - return deferredExecutePromise; - } - return true; - }); + rootMessenger.registerActionHandler( + 'ExecutionService:executeSnap', + async () => deferredExecutePromise, + ); // Fill up the request queue const finishPromise = Promise.all([ @@ -2073,6 +2187,11 @@ describe('SnapController', () => { }), ); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({}), + ); + jest .spyOn(snapController as any, 'fetchSnap') .mockImplementationOnce(() => { @@ -2155,6 +2274,11 @@ describe('SnapController', () => { getSnapControllerOptions({ messenger }), ); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({}), + ); + const fetchSnapMock = jest .spyOn(controller as any, 'fetchSnap') .mockImplementationOnce(async () => diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 9430c69822..f522b14c26 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -59,6 +59,10 @@ import { validateSnapId, validateSnapShasum, } from '@metamask/snaps-utils'; +import { + GetSubjectMetadata, + SubjectType, +} from '@metamask/subject-metadata-controller'; import { assert, assertExhaustive, @@ -83,9 +87,14 @@ import { SnapErrorJson, TerminateAllSnapsAction, TerminateSnapAction, -} from '../services/ExecutionService'; +} from '../services'; import { hasTimedOut, setDiff, withTimeout } from '../utils'; -import { endowmentCaveatMappers, SnapEndowments } from './endowments'; +import { + endowmentCaveatMappers, + handlerEndowments, + SnapEndowments, +} from './endowments'; +import { getRpcCaveatOrigins } from './endowments/rpc'; import { RequestQueue } from './RequestQueue'; import { Timer } from './Timer'; import { fetchNpmSnap } from './utils'; @@ -410,6 +419,7 @@ export type SnapControllerEvents = export type AllowedActions = | GetEndowments | GetPermissions + | GetSubjectMetadata | HasPermission | HasPermissions | RevokePermissions @@ -2250,12 +2260,51 @@ export class SnapController extends BaseController< handler: handlerType, request, }: SnapRpcHookArgs & { snapId: SnapId }): Promise { + const permissionName = handlerEndowments[handlerType]; + const hasPermission = this.messagingSystem.call( + 'PermissionController:hasPermission', + snapId, + permissionName, + ); + + if (!hasPermission) { + throw new Error( + `Snap "${snapId}" is not permitted to use "${permissionName}".`, + ); + } + + if (permissionName === SnapEndowments.Rpc) { + const subject = this.messagingSystem.call( + 'SubjectMetadataController:getSubjectMetadata', + origin, + ); + const isSnap = subject?.subjectType === SubjectType.Snap; + + const permissions = this.messagingSystem.call( + 'PermissionController:getPermissions', + snapId, + ); + + const rpcPermission = permissions?.[SnapEndowments.Rpc]; + assert(rpcPermission); + + const origins = getRpcCaveatOrigins(rpcPermission); + assert(origins); + + if ((isSnap && !origins.snaps) || (!isSnap && !origins.dapps)) { + throw new Error( + `Snap "${snapId}" is not permitted to handle JSON-RPC requests from "${origin}".`, + ); + } + } + const handler = await this.#getRpcRequestHandler(snapId); if (!handler) { throw new Error( `Snap RPC message handler not found for snap "${snapId}".`, ); } + return handler({ origin, handler: handlerType, request }); } diff --git a/packages/snaps-controllers/src/snaps/endowments/enum.ts b/packages/snaps-controllers/src/snaps/endowments/enum.ts index 33438f8f0e..8029ab4b74 100644 --- a/packages/snaps-controllers/src/snaps/endowments/enum.ts +++ b/packages/snaps-controllers/src/snaps/endowments/enum.ts @@ -5,4 +5,5 @@ export enum SnapEndowments { Keyring = 'endowment:keyring', Cronjob = 'endowment:cronjob', EthereumProvider = 'endowment:ethereum-provider', + Rpc = 'endowment:rpc', } diff --git a/packages/snaps-controllers/src/snaps/endowments/index.ts b/packages/snaps-controllers/src/snaps/endowments/index.ts index 12e217a91a..bd2e67a7e9 100644 --- a/packages/snaps-controllers/src/snaps/endowments/index.ts +++ b/packages/snaps-controllers/src/snaps/endowments/index.ts @@ -1,4 +1,5 @@ import { PermissionConstraint } from '@metamask/permission-controller'; +import { HandlerType } from '@metamask/snaps-utils'; import { Json } from '@metamask/utils'; import { @@ -14,6 +15,11 @@ import { } from './keyring'; import { longRunningEndowmentBuilder } from './long-running'; import { networkAccessEndowmentBuilder } from './network-access'; +import { + getRpcCaveatMapper, + rpcCaveatSpecifications, + rpcEndowmentBuilder, +} from './rpc'; import { getTransactionInsightCaveatMapper, transactionInsightCaveatSpecifications, @@ -29,12 +35,14 @@ export const endowmentPermissionBuilders = { [cronjobEndowmentBuilder.targetKey]: cronjobEndowmentBuilder, [ethereumProviderEndowmentBuilder.targetKey]: ethereumProviderEndowmentBuilder, + [rpcEndowmentBuilder.targetKey]: rpcEndowmentBuilder, } as const; export const endowmentCaveatSpecifications = { ...keyringCaveatSpecifications, ...cronjobCaveatSpecifications, ...transactionInsightCaveatSpecifications, + ...rpcCaveatSpecifications, }; export const endowmentCaveatMappers: Record< @@ -45,6 +53,14 @@ export const endowmentCaveatMappers: Record< [cronjobEndowmentBuilder.targetKey]: getCronjobCaveatMapper, [transactionInsightEndowmentBuilder.targetKey]: getTransactionInsightCaveatMapper, + [rpcEndowmentBuilder.targetKey]: getRpcCaveatMapper, +}; + +export const handlerEndowments: Record = { + [HandlerType.OnRpcRequest]: rpcEndowmentBuilder.targetKey, + [HandlerType.SnapKeyring]: keyringEndowmentBuilder.targetKey, + [HandlerType.OnTransaction]: transactionInsightEndowmentBuilder.targetKey, + [HandlerType.OnCronjob]: cronjobEndowmentBuilder.targetKey, }; export * from './enum'; diff --git a/packages/snaps-controllers/src/snaps/endowments/rpc.test.ts b/packages/snaps-controllers/src/snaps/endowments/rpc.test.ts new file mode 100644 index 0000000000..1267d6eded --- /dev/null +++ b/packages/snaps-controllers/src/snaps/endowments/rpc.test.ts @@ -0,0 +1,129 @@ +import { PermissionType } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +import { SnapEndowments } from '.'; +import { + getRpcCaveatMapper, + getRpcCaveatOrigins, + rpcCaveatSpecifications, + rpcEndowmentBuilder, +} from './rpc'; + +describe('endowment:rpc', () => { + it('builds the expected permission specification', () => { + const specification = rpcEndowmentBuilder.specificationBuilder({}); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetKey: SnapEndowments.Rpc, + endowmentGetter: expect.any(Function), + allowedCaveats: [SnapCaveatType.RpcOrigin], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeUndefined(); + }); + + describe('validator', () => { + it('throws if the caveat is not a single "rpcOrigin"', () => { + const specification = rpcEndowmentBuilder.specificationBuilder({}); + + expect(() => + specification.validator({ + // @ts-expect-error Missing other required permission types. + caveats: undefined, + }), + ).toThrow('Expected a single "rpcOrigin" caveat.'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [{ type: 'foo', value: 'bar' }], + }), + ).toThrow('Expected a single "rpcOrigin" caveat.'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [ + { type: 'rpcOrigin', value: { snaps: true, dapps: false } }, + { type: 'rpcOrigin', value: { snaps: true, dapps: false } }, + ], + }), + ).toThrow('Expected a single "rpcOrigin" caveat.'); + }); + }); +}); + +describe('getRpcCaveatMapper', () => { + it('maps a value to a caveat', () => { + expect(getRpcCaveatMapper({ snaps: true, dapps: false })).toStrictEqual({ + caveats: [ + { + type: SnapCaveatType.RpcOrigin, + value: { snaps: true, dapps: false }, + }, + ], + }); + }); +}); + +describe('getRpcCaveatOrigins', () => { + it('returns the origins from the caveat', () => { + expect( + // @ts-expect-error Missing other required permission types. + getRpcCaveatOrigins({ + caveats: [ + { + type: SnapCaveatType.RpcOrigin, + value: { snaps: true, dapps: false }, + }, + ], + }), + ).toStrictEqual({ snaps: true, dapps: false }); + }); + + it('throws if the caveat is not a single "rpcOrigin"', () => { + expect(() => + // @ts-expect-error Missing other required permission types. + getRpcCaveatOrigins({ + caveats: [{ type: 'foo', value: 'bar' }], + }), + ).toThrow('Assertion failed.'); + + expect(() => + // @ts-expect-error Missing other required permission types. + getRpcCaveatOrigins({ + caveats: [ + { type: 'rpcOrigin', value: { snaps: true, dapps: false } }, + { type: 'rpcOrigin', value: { snaps: true, dapps: false } }, + ], + }), + ).toThrow('Assertion failed.'); + }); +}); + +describe('rpcCaveatSpecifications', () => { + describe('validator', () => { + it('throws if the caveat values are invalid', () => { + expect(() => + rpcCaveatSpecifications[SnapCaveatType.RpcOrigin].validator?.( + // @ts-expect-error Missing value type. + { + type: SnapCaveatType.TransactionOrigin, + }, + ), + ).toThrow('Invalid JSON-RPC origins: Expected a plain object.'); + + expect(() => + rpcCaveatSpecifications[SnapCaveatType.RpcOrigin].validator?.({ + type: SnapCaveatType.TransactionOrigin, + value: { + foo: 'bar', + }, + }), + ).toThrow( + 'Invalid JSON-RPC origins: At path: foo -- Expected a value of type `never`, but received: `"bar"`.', + ); + }); + }); +}); diff --git a/packages/snaps-controllers/src/snaps/endowments/rpc.ts b/packages/snaps-controllers/src/snaps/endowments/rpc.ts new file mode 100644 index 0000000000..0d7b434da0 --- /dev/null +++ b/packages/snaps-controllers/src/snaps/endowments/rpc.ts @@ -0,0 +1,139 @@ +import { + Caveat, + CaveatSpecificationConstraint, + EndowmentGetterParams, + PermissionConstraint, + PermissionSpecificationBuilder, + PermissionType, + PermissionValidatorConstraint, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { + assertIsRpcOrigins, + RpcOrigins, + SnapCaveatType, +} from '@metamask/snaps-utils'; +import { + hasProperty, + isPlainObject, + Json, + NonEmptyArray, + assert, +} from '@metamask/utils'; +import { ethErrors } from 'eth-rpc-errors'; + +import { SnapEndowments } from './enum'; + +const targetKey = SnapEndowments.Rpc; + +type RpcSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetKey: typeof targetKey; + endowmentGetter: (_options?: any) => undefined; + allowedCaveats: Readonly> | null; + validator: PermissionValidatorConstraint; +}>; + +type RpcSpecificationBuilderOptions = { + // Empty for now. +}; + +/** + * The specification builder for the JSON-RPC endowment permission. + * + * @returns The specification for the JSON-RPC endowment permission. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + RpcSpecificationBuilderOptions, + RpcSpecification +> = (): RpcSpecification => { + return { + permissionType: PermissionType.Endowment, + targetKey, + allowedCaveats: [SnapCaveatType.RpcOrigin], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => undefined, + validator: ({ caveats }) => { + if ( + caveats?.length !== 1 || + caveats[0].type !== SnapCaveatType.RpcOrigin + ) { + throw ethErrors.rpc.invalidParams({ + message: `Expected a single "${SnapCaveatType.RpcOrigin}" caveat.`, + }); + } + }, + }; +}; + +export const rpcEndowmentBuilder = Object.freeze({ + targetKey, + specificationBuilder, +} as const); + +/** + * Validate the value of a caveat. This does not validate the type of the + * caveat itself, only the value of the caveat. + * + * @param caveat - The caveat to validate. + * @throws If the caveat value is invalid. + */ +function validateCaveatOrigins(caveat: Caveat) { + if (!hasProperty(caveat, 'value') || !isPlainObject(caveat.value)) { + throw ethErrors.rpc.invalidParams({ + message: 'Invalid JSON-RPC origins: Expected a plain object.', + }); + } + + const { value } = caveat; + assertIsRpcOrigins(value, ethErrors.rpc.invalidParams); +} + +/** + * Map a raw value from the `initialPermissions` to a caveat specification. + * Note that this function does not do any validation, that's handled by the + * PermissionsController when the permission is requested. + * + * @param value - The raw value from the `initialPermissions`. + * @returns The caveat specification. + */ +export function getRpcCaveatMapper( + value: Json, +): Pick { + return { + caveats: [ + { + type: SnapCaveatType.RpcOrigin, + value, + }, + ], + }; +} + +/** + * Getter function to get the {@link RpcOrigins} caveat value from a permission. + * + * @param permission - The permission to get the caveat value from. + * @returns The caveat value. + * @throws If the permission does not have a valid {@link RpcOrigins} caveat. + */ +export function getRpcCaveatOrigins( + permission?: PermissionConstraint, +): RpcOrigins | null { + assert(permission?.caveats); + assert(permission.caveats.length === 1); + assert(permission.caveats[0].type === SnapCaveatType.RpcOrigin); + + const caveat = permission.caveats[0] as Caveat; + return caveat.value; +} + +export const rpcCaveatSpecifications: Record< + SnapCaveatType.RpcOrigin, + CaveatSpecificationConstraint +> = { + [SnapCaveatType.RpcOrigin]: Object.freeze({ + type: SnapCaveatType.RpcOrigin, + validator: (caveat: Caveat) => validateCaveatOrigins(caveat), + }), +}; diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 2e3eeffe8d..961c2a92eb 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -3,10 +3,18 @@ import { ControllerMessenger, EventConstraint, } from '@metamask/base-controller'; +import { PermissionConstraint } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; import { getPersistedSnapObject, getTruncatedSnap, + MOCK_ORIGIN, + MOCK_SNAP_ID, } from '@metamask/snaps-utils/test-utils'; +import { + SubjectMetadata, + SubjectType, +} from '@metamask/subject-metadata-controller'; import { CronjobControllerActions, CronjobControllerEvents } from '../cronjob'; import { @@ -68,6 +76,42 @@ export class MockControllerMessenger< } } +export const MOCK_SNAP_SUBJECT_METADATA: SubjectMetadata = { + origin: MOCK_SNAP_ID, + subjectType: SubjectType.Snap, + name: 'foo', + extensionId: 'bar', + iconUrl: 'baz', +}; + +export const MOCK_DAPP_SUBJECT_METADATA: SubjectMetadata = { + origin: MOCK_ORIGIN, + subjectType: SubjectType.Website, + name: 'foo', + extensionId: 'bar', + iconUrl: 'baz', +}; + +export const MOCK_RPC_ORIGINS_PERMISSION: PermissionConstraint = { + caveats: [ + { type: SnapCaveatType.RpcOrigin, value: { snaps: true, dapps: false } }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Rpc, +}; + +export const MOCK_DAPPS_RPC_ORIGINS_PERMISSION: PermissionConstraint = { + caveats: [ + { type: SnapCaveatType.RpcOrigin, value: { snaps: false, dapps: true } }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Rpc, +}; + export const getControllerMessenger = () => { const messenger = new MockControllerMessenger< SnapControllerActions | AllowedActions, @@ -104,6 +148,11 @@ export const getControllerMessenger = () => { () => ({}), ); + messenger.registerActionHandler( + 'PermissionController:revokePermissions', + () => ({}), + ); + messenger.registerActionHandler( 'PermissionController:revokeAllPermissions', () => ({}), @@ -116,7 +165,14 @@ export const getControllerMessenger = () => { messenger.registerActionHandler( 'PermissionController:getPermissions', - () => ({}), + () => ({ + [SnapEndowments.Rpc]: MOCK_RPC_ORIGINS_PERMISSION, + }), + ); + + messenger.registerActionHandler( + 'SubjectMetadataController:getSubjectMetadata', + () => MOCK_SNAP_SUBJECT_METADATA, ); messenger.registerActionHandler('ExecutionService:executeSnap', asyncNoOp); @@ -161,6 +217,7 @@ export const getSnapControllerMessenger = ( 'PermissionController:hasPermissions', 'PermissionController:getPermissions', 'PermissionController:grantPermissions', + 'PermissionController:revokePermissions', 'PermissionController:revokeAllPermissions', 'PermissionController:revokePermissionForAllSubjects', 'SnapController:get', @@ -179,6 +236,7 @@ export const getSnapControllerMessenger = ( 'SnapController:removeSnapError', 'SnapController:incrementActiveReferences', 'SnapController:decrementActiveReferences', + 'SubjectMetadataController:getSubjectMetadata', ], }); diff --git a/packages/snaps-utils/src/caveats.ts b/packages/snaps-utils/src/caveats.ts index 5c2c10a400..f0d7cc4a9a 100644 --- a/packages/snaps-utils/src/caveats.ts +++ b/packages/snaps-utils/src/caveats.ts @@ -23,4 +23,9 @@ export enum SnapCaveatType { * Caveat specifying access to the transaction origin, used by `endowment:transaction-insight`. */ TransactionOrigin = 'transactionOrigin', + + /** + * The origins that a Snap can receive JSON-RPC messages from. + */ + RpcOrigin = 'rpcOrigin', } diff --git a/packages/snaps-utils/src/json-rpc.test.ts b/packages/snaps-utils/src/json-rpc.test.ts index b74332ebf4..60b7460110 100644 --- a/packages/snaps-utils/src/json-rpc.test.ts +++ b/packages/snaps-utils/src/json-rpc.test.ts @@ -1,4 +1,39 @@ -import { assertIsJsonRpcSuccess } from './json-rpc'; +import { assertIsJsonRpcSuccess, assertIsRpcOrigins } from './json-rpc'; + +describe('assertIsRpcOrigins', () => { + it.each([{ dapps: true }, { snaps: true }, { dapps: true, snaps: true }])( + 'does not throw for %p', + (origins) => { + expect(() => assertIsRpcOrigins(origins)).not.toThrow(); + }, + ); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'foo', + [], + ['foo'], + {}, + { foo: true }, + { dapps: false, snaps: false }, + ])('throws for %p', (origins) => { + expect(() => assertIsRpcOrigins(origins)).toThrow( + 'Invalid JSON-RPC origins:', + ); + }); + + it('throws if neither value is true', () => { + expect(() => assertIsRpcOrigins({ dapps: false, snaps: false })).toThrow( + 'Invalid JSON-RPC origins: Must specify at least one JSON-RPC origin.', + ); + }); +}); describe('assertIsJsonRpcSuccess', () => { it.each([ diff --git a/packages/snaps-utils/src/json-rpc.ts b/packages/snaps-utils/src/json-rpc.ts index 42c45062c3..3b3286ef04 100644 --- a/packages/snaps-utils/src/json-rpc.ts +++ b/packages/snaps-utils/src/json-rpc.ts @@ -3,7 +3,48 @@ import { isJsonRpcSuccess, Json, JsonRpcSuccess, + AssertionErrorConstructor, + assertStruct, } from '@metamask/utils'; +import { boolean, Infer, object, optional, refine } from 'superstruct'; + +export const RpcOriginsStruct = refine( + object({ + dapps: optional(boolean()), + snaps: optional(boolean()), + }), + 'RPC origins', + (value) => { + if (!Object.values(value).some(Boolean)) { + throw new Error('Must specify at least one JSON-RPC origin'); + } + + return true; + }, +); + +export type RpcOrigins = Infer; + +/** + * Asserts that the given value is a valid {@link RpcOrigins} object. + * + * @param value - The value to assert. + * @param ErrorWrapper - An optional error wrapper to use. Defaults to + * {@link AssertionError}. + * @throws If the value is not a valid {@link RpcOrigins} object. + */ +export function assertIsRpcOrigins( + value: unknown, + // eslint-disable-next-line @typescript-eslint/naming-convention + ErrorWrapper?: AssertionErrorConstructor, +): asserts value is RpcOrigins { + assertStruct( + value, + RpcOriginsStruct, + 'Invalid JSON-RPC origins', + ErrorWrapper, + ); +} /** * Assert that the given value is a successful JSON-RPC response. If the value diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index ebfe4e8c92..2a81f920ea 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -19,6 +19,7 @@ import { } from 'superstruct'; import { CronjobSpecificationArrayStruct } from '../cronjob'; +import { RpcOriginsStruct } from '../json-rpc'; import { NamespacesStruct } from '../namespace'; import { NameStruct, NpmSnapFileNames } from '../types'; import { VersionStruct } from '../versions'; @@ -156,6 +157,7 @@ export const PermissionsStruct = type({ 'endowment:cronjob': optional( object({ jobs: CronjobSpecificationArrayStruct }), ), + 'endowment:rpc': optional(RpcOriginsStruct), snap_confirm: optional(object({})), snap_manageState: optional(object({})), snap_notify: optional(object({})), diff --git a/yarn.lock b/yarn.lock index 3775623a51..414412c084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2612,7 +2612,7 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^1.0.0": +"@metamask/permission-controller@npm:^1.0.0, @metamask/permission-controller@npm:~1.0.0": version: 1.0.0 resolution: "@metamask/permission-controller@npm:1.0.0" dependencies: @@ -2830,6 +2830,7 @@ __metadata: "@metamask/snaps-execution-environments": ^0.24.1 "@metamask/snaps-types": ^0.24.1 "@metamask/snaps-utils": ^0.24.1 + "@metamask/subject-metadata-controller": ^1.0.0 "@metamask/template-snap": ^0.7.0 "@metamask/utils": ^3.3.1 "@peculiar/webcrypto": ^1.3.3 @@ -3113,6 +3114,18 @@ __metadata: languageName: unknown linkType: soft +"@metamask/subject-metadata-controller@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/subject-metadata-controller@npm:1.0.0" + dependencies: + "@metamask/base-controller": ~1.0.0 + "@metamask/permission-controller": ~1.0.0 + "@metamask/types": ^1.1.0 + immer: ^9.0.6 + checksum: 5a9ce9c5a99f4f35ea721b7a3da284e785da4f29f063ef7aa8505e56ba59616377eab7c4628899abc5d5f28a26b642a525728b8775931f705e075929577df593 + languageName: node + linkType: hard + "@metamask/template-snap@npm:^0.7.0": version: 0.7.0 resolution: "@metamask/template-snap@npm:0.7.0"