diff --git a/packages/gator-permissions-snap/src/index.ts b/packages/gator-permissions-snap/src/index.ts index 5cde3b62..2c24af08 100644 --- a/packages/gator-permissions-snap/src/index.ts +++ b/packages/gator-permissions-snap/src/index.ts @@ -141,6 +141,7 @@ const profileSyncManager = createProfileSyncManager({ storage: profileSyncOptions.keyStorageOptions, }, ), + ethereumProvider: ethereum, }); const userEventDispatcher = new UserEventDispatcher(); @@ -189,6 +190,9 @@ const boundRpcHandlers: { rpcHandler.getPermissionOffers.bind(rpcHandler), [RpcMethod.PermissionsProviderGetGrantedPermissions]: rpcHandler.getGrantedPermissions.bind(rpcHandler), + [RpcMethod.PermissionsProviderSubmitRevocation]: async ( + params?: JsonRpcParams, + ) => rpcHandler.submitRevocation(params as Json), }; /** diff --git a/packages/gator-permissions-snap/src/profileSync/profileSync.ts b/packages/gator-permissions-snap/src/profileSync/profileSync.ts index cc5a2607..1beb2cd9 100644 --- a/packages/gator-permissions-snap/src/profileSync/profileSync.ts +++ b/packages/gator-permissions-snap/src/profileSync/profileSync.ts @@ -21,6 +21,7 @@ import { LimitExceededError, ParseError, UnsupportedMethodError, + type SnapsEthereumProvider, } from '@metamask/snaps-sdk'; import { z } from 'zod'; @@ -31,6 +32,7 @@ const MAX_STORAGE_SIZE_BYTES = 400 * 1024; // 400kb limit as documented const zStoredGrantedPermission = z.object({ permissionResponse: zPermissionResponse, siteOrigin: z.string().min(1, 'Site origin cannot be empty'), + isRevoked: z.boolean().default(false), }); /** @@ -100,17 +102,46 @@ export type ProfileSyncManager = { storeGrantedPermissionBatch: ( storedGrantedPermission: StoredGrantedPermission[], ) => Promise; + updatePermissionRevocationStatus: ( + permissionContext: Hex, + isRevoked: boolean, + ) => Promise; + updatePermissionRevocationStatusWithPermission: ( + existingPermission: StoredGrantedPermission, + isRevoked: boolean, + ) => Promise; + checkDelegationDisabledOnChain: ( + delegationHash: Hex, + chainId: Hex, + delegationManagerAddress: Hex, + ) => Promise; }; export type StoredGrantedPermission = { permissionResponse: PermissionResponse; siteOrigin: string; + isRevoked: boolean; }; +/** + * Generates an object key for the permission response stored in profile sync. + * @param permissionContext - The encoded delegation(ie. permissions context). + * @returns The object key by concatenating the delegation hashes. + */ +export function generateObjectKey(permissionContext: Hex): Hex { + const delegations = decodeDelegations(permissionContext); + const hashes = delegations.map((delegation) => + hashDelegation(delegation).slice(2), + ); + + return `0x${hashes.join('')}`; +} + export type ProfileSyncManagerConfig = { auth: JwtBearerAuth; userStorage: UserStorage; isFeatureEnabled: boolean; + ethereumProvider: SnapsEthereumProvider; }; /** @@ -122,7 +153,7 @@ export function createProfileSyncManager( config: ProfileSyncManagerConfig, ): ProfileSyncManager { const FEATURE = 'gator_7715_permissions'; - const { auth, userStorage, isFeatureEnabled } = config; + const { auth, userStorage, isFeatureEnabled, ethereumProvider } = config; const unConfiguredProfileSyncManager = { getAllGrantedPermissions: async () => { logger.debug('unConfiguredProfileSyncManager.getAllGrantedPermissions()'); @@ -143,22 +174,27 @@ export function createProfileSyncManager( 'unConfiguredProfileSyncManager.storeGrantedPermissionBatch()', ); }, + updatePermissionRevocationStatus: async (_: Hex, __: boolean) => { + logger.debug( + 'unConfiguredProfileSyncManager.updatePermissionRevocationStatus()', + ); + }, + updatePermissionRevocationStatusWithPermission: async ( + _: StoredGrantedPermission, + __: boolean, + ) => { + logger.debug( + 'unConfiguredProfileSyncManager.updatePermissionRevocationStatusWithPermission()', + ); + }, + checkDelegationDisabledOnChain: async (_: Hex, __: Hex, ___: Hex) => { + logger.debug( + 'unConfiguredProfileSyncManager.checkDelegationDisabledOnChain()', + ); + return false; // Default to not disabled when feature is disabled + }, }; - /** - * Generates an object key for the permission response stored in profile sync. - * @param permissionContext - The encoded delegation(ie. permissions context). - * @returns The object key by concatenating the delegation hashes. - */ - function generateObjectKey(permissionContext: Hex): Hex { - const delegations = decodeDelegations(permissionContext); - const hashes = delegations.map((delegation) => - hashDelegation(delegation).slice(2), - ); - - return `0x${hashes.join('')}`; - } - /** * Authenticates the user with profile sync. * @@ -167,7 +203,7 @@ export function createProfileSyncManager( try { await auth.getAccessToken(); } catch (error) { - logger.error('Error fetching access token'); + logger.error('Error fetching access token:', error); throw error; } } @@ -306,6 +342,131 @@ export function createProfileSyncManager( } } + /** + * Updates the revocation status of a granted permission in profile sync. + * + * @param permissionContext - The context of the granted permission to update. + * @param isRevoked - The new revocation status. + * @throws InvalidInputError if the permission is not found. + */ + async function updatePermissionRevocationStatus( + permissionContext: Hex, + isRevoked: boolean, + ): Promise { + try { + await authenticate(); + + const existingPermission = await getGrantedPermission(permissionContext); + if (!existingPermission) { + throw new InvalidInputError( + `Permission not found for permission context: ${permissionContext}`, + ); + } + + await updatePermissionRevocationStatusWithPermission( + existingPermission, + isRevoked, + ); + } catch (error) { + logger.error('Error updating permission revocation status'); + throw error; + } + } + + /** + * Updates the revocation status of a granted permission when you already have the permission object. + * This is an optimized version that avoids re-fetching the permission. + * + * @param existingPermission - The existing permission object. + * @param isRevoked - The new revocation status. + */ + async function updatePermissionRevocationStatusWithPermission( + existingPermission: StoredGrantedPermission, + isRevoked: boolean, + ): Promise { + try { + logger.debug('Profile Sync: Updating permission revocation status:', { + existingPermission, + isRevoked, + }); + + await authenticate(); + + const updatedPermission: StoredGrantedPermission = { + ...existingPermission, + isRevoked, + }; + + logger.debug( + 'Profile Sync: Created updated permission object:', + updatedPermission, + ); + + await storeGrantedPermission(updatedPermission); + logger.debug('Profile Sync: Successfully stored updated permission'); + } catch (error) { + logger.error( + 'Error updating permission revocation status with existing permission:', + error, + ); + throw error; + } + } + + /** + * Checks if a delegation is disabled on-chain by calling the DelegationManager contract. + * @param delegationHash - The hash of the delegation to check. + * @param chainId - The chain ID in hex format. + * @param delegationManagerAddress - The address of the DelegationManager contract. + * @returns True if the delegation is disabled, false otherwise. + */ + async function checkDelegationDisabledOnChain( + delegationHash: Hex, + chainId: Hex, + delegationManagerAddress: Hex, + ): Promise { + try { + logger.debug('Checking delegation disabled status on-chain', { + delegationHash, + chainId, + delegationManagerAddress, + }); + + // Encode the function call data for disabledDelegations(bytes32) + const functionSelector = '0x2d40d052'; // keccak256("disabledDelegations(bytes32)").slice(0, 10) + const encodedParams = delegationHash.slice(2).padStart(64, '0'); // Remove 0x and pad to 32 bytes + const callData = `${functionSelector}${encodedParams}`; + + const result = await ethereumProvider.request({ + method: 'eth_call', + params: [ + { + to: delegationManagerAddress, + data: callData, + }, + 'latest', + ], + }); + + if (!result) { + logger.warn('No result from contract call'); + return false; + } + + // Parse the boolean result (32 bytes, last byte is the boolean value) + const isDisabled = + result !== + '0x0000000000000000000000000000000000000000000000000000000000000000'; + + logger.debug('Delegation disabled status result', { isDisabled }); + return isDisabled; + } catch (error) { + logger.error('Error checking delegation disabled status on-chain', error); + // In case of error, assume not disabled to avoid blocking legitimate operations + return false; + } + } + /** * Feature flag to disable profile sync feature until message-signing-snap v1.1.2 released in MM 12.18: https://github.com/MetaMask/metamask-extension/pull/32521. */ @@ -315,6 +476,9 @@ export function createProfileSyncManager( getGrantedPermission, storeGrantedPermission, storeGrantedPermissionBatch, + updatePermissionRevocationStatus, + updatePermissionRevocationStatusWithPermission, + checkDelegationDisabledOnChain, } : unConfiguredProfileSyncManager; } diff --git a/packages/gator-permissions-snap/src/rpc/permissions.ts b/packages/gator-permissions-snap/src/rpc/permissions.ts index 4b3e30c1..f4ef18df 100644 --- a/packages/gator-permissions-snap/src/rpc/permissions.ts +++ b/packages/gator-permissions-snap/src/rpc/permissions.ts @@ -9,7 +9,10 @@ const allowedPermissionsByOrigin: { [origin: string]: string[] } = { RpcMethod.PermissionsProviderGetPermissionOffers, ], }), - metamask: [RpcMethod.PermissionsProviderGetGrantedPermissions], + metamask: [ + RpcMethod.PermissionsProviderGetGrantedPermissions, + RpcMethod.PermissionsProviderSubmitRevocation, + ], }; /** @@ -22,5 +25,8 @@ export const isMethodAllowedForOrigin = ( origin: string, method: string, ): boolean => { - return allowedPermissionsByOrigin[origin]?.includes(method) ?? false; + const isAllowed = + allowedPermissionsByOrigin[origin]?.includes(method) ?? false; + + return isAllowed; }; diff --git a/packages/gator-permissions-snap/src/rpc/rpcHandler.ts b/packages/gator-permissions-snap/src/rpc/rpcHandler.ts index 02530b1b..5a7a68c7 100644 --- a/packages/gator-permissions-snap/src/rpc/rpcHandler.ts +++ b/packages/gator-permissions-snap/src/rpc/rpcHandler.ts @@ -1,5 +1,5 @@ -import type { PermissionResponse } from '@metamask/7715-permissions-shared/types'; import { logger } from '@metamask/7715-permissions-shared/utils'; +import { decodeDelegations, hashDelegation } from '@metamask/delegation-core'; import { InvalidInputError, UserRejectedRequestError, @@ -9,8 +9,14 @@ import { hexToNumber } from '@metamask/utils'; import type { PermissionHandlerFactory } from '../core/permissionHandlerFactory'; import { DEFAULT_GATOR_PERMISSION_TO_OFFER } from '../permissions/permissionOffers'; -import type { ProfileSyncManager } from '../profileSync'; -import { validatePermissionRequestParam } from '../utils/validate'; +import type { + ProfileSyncManager, + StoredGrantedPermission, +} from '../profileSync/profileSync'; +import { + validatePermissionRequestParam, + validateRevocationParams, +} from '../utils/validate'; /** * Type for the RPC handler methods. @@ -34,9 +40,18 @@ export type RpcHandler = { /** * Handles get granted permissions requests. * - * @returns The granted permissions. + * @param params - Optional parameters for filtering permissions. + * @returns The granted permissions, optionally filtered. + */ + getGrantedPermissions(params?: Json): Promise; + + /** + * Handles submit revocation requests. + * + * @param params - The parameters for the revocation. + * @returns Success confirmation. */ - getGrantedPermissions(): Promise; + submitRevocation(params: Json): Promise; }; /** @@ -80,10 +95,7 @@ export function createRpcHandler({ ); } - const permissionsToStore: { - permissionResponse: PermissionResponse; - siteOrigin: string; - }[] = []; + const permissionsToStore: StoredGrantedPermission[] = []; // First, process all permissions to collect responses and validate all are approved for (const request of permissionsRequest) { @@ -96,10 +108,12 @@ export function createRpcHandler({ throw new UserRejectedRequestError(permissionResponse.reason); } - permissionsToStore.push({ + const storedPermission: StoredGrantedPermission = { permissionResponse: permissionResponse.response, siteOrigin, - }); + isRevoked: false, + }; + permissionsToStore.push(storedPermission); } // Only after all permissions have been successfully processed, store them all in batch @@ -126,18 +140,148 @@ export function createRpcHandler({ /** * Handles get granted permissions requests. * - * @returns The granted permissions. + * @param params - Optional parameters for filtering permissions. + * @returns The granted permissions, optionally filtered. */ - const getGrantedPermissions = async (): Promise => { - logger.debug('getGrantedPermissions()'); - const grantedPermission = - await profileSyncManager.getAllGrantedPermissions(); - return grantedPermission as Json[]; + const getGrantedPermissions = async (params?: Json): Promise => { + logger.debug('getGrantedPermissions()', params); + + // Get all permissions + const allPermissions = await profileSyncManager.getAllGrantedPermissions(); + + // If no params provided, return all permissions (backward compatibility) + if (!params || typeof params !== 'object') { + return allPermissions as Json[]; + } + + // Parse filtering options + const { isRevoked, siteOrigin, chainId, delegationManager } = params as { + isRevoked?: boolean; + siteOrigin?: string; + chainId?: string; + delegationManager?: string; + }; + + // Apply filters + let filteredPermissions = allPermissions; + + if (typeof isRevoked === 'boolean') { + filteredPermissions = filteredPermissions.filter( + (permission) => permission.isRevoked === isRevoked, + ); + } + + if (typeof siteOrigin === 'string') { + filteredPermissions = filteredPermissions.filter( + (permission) => permission.siteOrigin === siteOrigin, + ); + } + + if (typeof chainId === 'string') { + filteredPermissions = filteredPermissions.filter( + (permission) => permission.permissionResponse.chainId === chainId, + ); + } + + if (typeof delegationManager === 'string') { + filteredPermissions = filteredPermissions.filter( + (permission) => + permission.permissionResponse.signerMeta.delegationManager === + delegationManager, + ); + } + + return filteredPermissions as Json[]; + }; + + /** + * Handles submit revocation requests. + * + * @param params - The parameters for the revocation. + * @returns Success confirmation. + */ + const submitRevocation = async (params: Json): Promise => { + logger.debug('submitRevocation() called with params:', params); + + const { permissionContext } = validateRevocationParams(params); + + // First, get the existing permission to validate it exists + logger.debug( + 'Looking up existing permission for permissionContext:', + permissionContext, + ); + const existingPermission = + await profileSyncManager.getGrantedPermission(permissionContext); + + if (!existingPermission) { + throw new InvalidInputError( + `Permission not found for permission context: ${permissionContext}`, + ); + } + + // Extract delegationManager and chainId from the permission response for logging + const { chainId: permissionChainId, signerMeta } = + existingPermission.permissionResponse; + const { delegationManager } = signerMeta; + + logger.debug('Permission details extracted:', { + chainId: permissionChainId, + delegationManager: delegationManager ?? 'undefined', + signerMeta, + }); + + // Check if the delegation is actually disabled on-chain + if (!delegationManager) { + throw new InvalidInputError( + `No delegation manager found for permission context: ${permissionContext}`, + ); + } + + // For on-chain validation, we need to check each delegation in the context + const delegations = decodeDelegations(permissionContext); + + // Check if any delegation is disabled on-chain + // For now, we'll check the first delegation. This might need adjustment based on business logic + const firstDelegation = delegations[0]; + if (!firstDelegation) { + throw new InvalidInputError( + `No delegations found in permission context: ${permissionContext}`, + ); + } + + const delegationHash = hashDelegation(firstDelegation); + const isDelegationDisabled = + await profileSyncManager.checkDelegationDisabledOnChain( + delegationHash, + permissionChainId, + delegationManager, + ); + + logger.debug('On-chain check result:', { isDelegationDisabled }); + + if (!isDelegationDisabled) { + throw new InvalidInputError( + `Delegation ${delegationHash} is not disabled on-chain. Cannot process revocation.`, + ); + } + + logger.debug( + '✅ Delegation is disabled on-chain, proceeding with revocation', + ); + + logger.debug('Updating permission revocation status to true...'); + await profileSyncManager.updatePermissionRevocationStatusWithPermission( + existingPermission, + true, + ); + + return { success: true }; }; return { grantPermission, getPermissionOffers, getGrantedPermissions, + submitRevocation, }; } diff --git a/packages/gator-permissions-snap/src/rpc/rpcMethod.ts b/packages/gator-permissions-snap/src/rpc/rpcMethod.ts index 7513e0a7..a0dd3403 100644 --- a/packages/gator-permissions-snap/src/rpc/rpcMethod.ts +++ b/packages/gator-permissions-snap/src/rpc/rpcMethod.ts @@ -16,4 +16,9 @@ export enum RpcMethod { * This method is used by Metamask clients to retrieve granted permissions for all sites. */ PermissionsProviderGetGrantedPermissions = 'permissionsProvider_getGrantedPermissions', + + /** + * This method is used by MetaMask origin to submit a revocation and update the isRevoked flag. + */ + PermissionsProviderSubmitRevocation = 'permissionsProvider_submitRevocation', } diff --git a/packages/gator-permissions-snap/src/utils/validate.ts b/packages/gator-permissions-snap/src/utils/validate.ts index c96076d4..34af46da 100644 --- a/packages/gator-permissions-snap/src/utils/validate.ts +++ b/packages/gator-permissions-snap/src/utils/validate.ts @@ -3,7 +3,9 @@ import { zRequestExecutionPermissionsParam, } from '@metamask/7715-permissions-shared/types'; import { extractZodError } from '@metamask/7715-permissions-shared/utils'; -import { InvalidInputError } from '@metamask/snaps-sdk'; +import type { Hex } from '@metamask/delegation-core'; +import { InvalidInputError, type Json } from '@metamask/snaps-sdk'; +import { z } from 'zod'; export const validatePermissionRequestParam = ( params: unknown, @@ -18,3 +20,40 @@ export const validatePermissionRequestParam = ( return validateGrantAttenuatedPermissionsParams.data; }; + +// Validation schema for revocation parameters +const zRevocationParams = z.object({ + permissionContext: z + .string() + .regex( + /^0x[a-fA-F0-9]+$/u, + 'Invalid permission context format - must be a hex string', + ), +}); + +/** + * Validates the revocation parameters. + * @param params - The parameters to validate. + * @returns The validated parameters. + * @throws InvalidInputError if validation fails. + */ +export function validateRevocationParams(params: Json): { + permissionContext: Hex; +} { + try { + if (!params || typeof params !== 'object') { + throw new InvalidInputError('Parameters are required'); + } + + const validated = zRevocationParams.parse(params); + + return { + permissionContext: validated.permissionContext as Hex, + }; + } catch (error) { + if (error instanceof z.ZodError) { + throw new InvalidInputError(extractZodError(error.errors)); + } + throw error; + } +} diff --git a/packages/gator-permissions-snap/test/end-to-end/index.test.tsx b/packages/gator-permissions-snap/test/end-to-end/index.test.tsx index 0fd85ed2..80de756d 100644 --- a/packages/gator-permissions-snap/test/end-to-end/index.test.tsx +++ b/packages/gator-permissions-snap/test/end-to-end/index.test.tsx @@ -43,5 +43,29 @@ describe('Kernel Snap', () => { }); }); }); + + describe('success', () => { + it('should return empty array when no permissions are granted', async () => { + const response = await snapRequest({ + method: 'permissionsProvider_getGrantedPermissions', + origin: 'metamask', + }); + + expect(response).toRespondWith([]); + }); + + it('should accept filtering parameters', async () => { + const response = await snapRequest({ + method: 'permissionsProvider_getGrantedPermissions', + origin: 'metamask', + params: { + isRevoked: false, + siteOrigin: 'https://example.com', + }, + }); + + expect(response).toRespondWith([]); + }); + }); }); }); diff --git a/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts b/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts index e573ee35..49e041e6 100644 --- a/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts +++ b/packages/gator-permissions-snap/test/profileSync/profileSync.test.ts @@ -11,6 +11,7 @@ import type { import { createProfileSyncManager, + generateObjectKey, type ProfileSyncManager, type StoredGrantedPermission, } from '../../src/profileSync'; @@ -31,6 +32,9 @@ describe('profileSync', () => { setItem: jest.fn(), batchSetItems: jest.fn(), } as unknown as jest.Mocked; + const ethereumProviderMock = { + request: jest.fn(), + } as unknown as jest.Mocked; const mockDelegation: Delegation = { delegate: sessionAccount, @@ -95,6 +99,7 @@ describe('profileSync', () => { rules: [expiryRule], }, siteOrigin: 'https://example.com', + isRevoked: false, }; const mockPassAuth = () => { jwtBearerAuthMock.getAccessToken.mockResolvedValueOnce('aaa.bbb.ccc'); @@ -116,6 +121,7 @@ describe('profileSync', () => { isFeatureEnabled: true, auth: jwtBearerAuthMock, userStorage: userStorageMock, + ethereumProvider: ethereumProviderMock, }); }); @@ -214,7 +220,7 @@ describe('profileSync', () => { expect(userStorageMock.setItem).toHaveBeenCalledWith( `gator_7715_permissions.${mockDelegationHash}`, expect.stringMatching( - /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com"\}$/u, + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":false\}$/u, ), ); // Verify the stored data can be parsed and contains expected fields @@ -245,7 +251,7 @@ describe('profileSync', () => { expect(userStorageMock.setItem).toHaveBeenCalledWith( `gator_7715_permissions.${mockDelegationHash}${mockDelegationHashTwo.slice(2)}`, expect.stringMatching( - /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com"\}$/u, + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":false\}$/u, ), ); // Verify the stored data can be parsed and contains expected fields @@ -311,6 +317,7 @@ describe('profileSync', () => { rules: [expiryRule], }, siteOrigin: 'https://example.com', + isRevoked: false, }, ]; mockPassAuth(); @@ -324,13 +331,13 @@ describe('profileSync', () => { [ mockDelegationHash, expect.stringMatching( - /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com"\}$/u, + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":false\}$/u, ), ], [ mockDelegationHashTwo, expect.stringMatching( - /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com"\}$/u, + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":false\}$/u, ), ], ], @@ -373,6 +380,7 @@ describe('profileSync', () => { isFeatureEnabled: true, auth: jwtBearerAuthMock, userStorage: userStorageMock, + ethereumProvider: ethereumProviderMock, }); }); @@ -442,6 +450,7 @@ describe('profileSync', () => { const invalidPermission = { permissionResponse: { chainId: '0xaa36a7' }, // Missing required fields siteOrigin: 'https://example.com', + isRevoked: false, } as any; mockPassAuth(); @@ -457,7 +466,7 @@ describe('profileSync', () => { [ mockDelegationHash, expect.stringMatching( - /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com"\}$/u, + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":false\}$/u, ), ], ], @@ -473,6 +482,119 @@ describe('profileSync', () => { ); expect(storedData.siteOrigin).toBe('https://example.com'); }); + + describe('updatePermissionRevocationStatus', () => { + it('should update permission revocation status successfully', async () => { + // Mock getting the existing permission + userStorageMock.getItem.mockResolvedValueOnce( + JSON.stringify(mockStoredGrantedPermission), + ); + + mockPassAuth(); + await profileSyncManager.updatePermissionRevocationStatus( + mockStoredGrantedPermission.permissionResponse.context, + true, + ); + + // Should first get the existing permission + expect(userStorageMock.getItem).toHaveBeenCalledWith( + `gator_7715_permissions.${mockDelegationHash}`, + ); + + // Should then store the updated permission with isRevoked=true + expect(userStorageMock.setItem).toHaveBeenCalledWith( + `gator_7715_permissions.${mockDelegationHash}`, + expect.stringMatching( + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":true\}$/u, + ), + ); + + // Verify the stored data has isRevoked=true + const storedData = userStorageMock.setItem.mock.calls[0]?.[1]; + expect(storedData).toBeDefined(); + const parsed = JSON.parse(storedData as string); + expect(parsed.isRevoked).toBe(true); + expect(parsed.siteOrigin).toBe('https://example.com'); + }); + + it('should set permission revocation status to false', async () => { + // Create a mock permission that's already revoked + const revokedPermission = { + ...mockStoredGrantedPermission, + isRevoked: true, + }; + + // Mock getting the existing revoked permission + userStorageMock.getItem.mockResolvedValueOnce( + JSON.stringify(revokedPermission), + ); + + mockPassAuth(); + await profileSyncManager.updatePermissionRevocationStatus( + mockStoredGrantedPermission.permissionResponse.context, + false, + ); + + // Should store the updated permission with isRevoked=false + expect(userStorageMock.setItem).toHaveBeenCalledWith( + `gator_7715_permissions.${mockDelegationHash}`, + expect.stringMatching( + /^\{"permissionResponse":\{.*\},"siteOrigin":"https:\/\/example\.com","isRevoked":false\}$/u, + ), + ); + + // Verify the stored data has isRevoked=false + const storedData = userStorageMock.setItem.mock.calls[0]?.[1]; + expect(storedData).toBeDefined(); + const parsed = JSON.parse(storedData as string); + expect(parsed.isRevoked).toBe(false); + }); + + it('should throw error when permission not found', async () => { + // Use a valid delegation hash format that won't exist + const nonExistentDelegationHash = encodeDelegations([ + { + ...mockDelegation, + signature: '0x999', + }, + ]); + + // Mock no permission found + userStorageMock.getItem.mockResolvedValueOnce(null); + + mockPassAuth(); + + await expect( + profileSyncManager.updatePermissionRevocationStatus( + nonExistentDelegationHash, + true, + ), + ).rejects.toThrow( + `Permission not found for permission context: ${nonExistentDelegationHash}`, + ); + + const expectedObjectKey = generateObjectKey(nonExistentDelegationHash); + expect(userStorageMock.getItem).toHaveBeenCalledWith( + `gator_7715_permissions.${expectedObjectKey}`, + ); + + // Should not attempt to store anything + expect(userStorageMock.setItem).not.toHaveBeenCalled(); + }); + + it('should handle authentication errors', async () => { + jwtBearerAuthMock.getAccessToken.mockRejectedValueOnce( + new Error('Auth failed'), + ); + + await expect( + profileSyncManager.updatePermissionRevocationStatus( + mockStoredGrantedPermission.permissionResponse.context, + true, + ), + ).rejects.toThrow('Auth failed'); + }); + }); }); describe('Profile Sync feature disabled using unConfigured profile sync manager', () => { @@ -481,6 +603,7 @@ describe('profileSync', () => { isFeatureEnabled: false, auth: jwtBearerAuthMock, userStorage: userStorageMock, + ethereumProvider: ethereumProviderMock, }); }); @@ -520,5 +643,16 @@ describe('profileSync', () => { expect(userStorageMock.batchSetItems).not.toHaveBeenCalled(); }); }); + + describe('updatePermissionRevocationStatus', () => { + it('should not update permission revocation status when profile sync feature is disabled', async () => { + await profileSyncManager.updatePermissionRevocationStatus( + mockStoredGrantedPermission.permissionResponse.context, + true, + ); + expect(userStorageMock.getItem).not.toHaveBeenCalled(); + expect(userStorageMock.setItem).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts b/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts index a869f44b..2dd7778d 100644 --- a/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts +++ b/packages/gator-permissions-snap/test/rpc/rpcHandler.test.ts @@ -3,6 +3,7 @@ import type { PermissionRequest, PermissionResponse, } from '@metamask/7715-permissions-shared/types'; +import { decodeDelegations, hashDelegation } from '@metamask/delegation-core'; import type { Json } from '@metamask/snaps-sdk'; import type { PermissionHandlerFactory } from '../../src/core/permissionHandlerFactory'; @@ -10,6 +11,12 @@ import type { PermissionHandlerType } from '../../src/core/types'; import type { ProfileSyncManager } from '../../src/profileSync'; import { createRpcHandler, type RpcHandler } from '../../src/rpc/rpcHandler'; +// Mock the delegation-core functions +jest.mock('@metamask/delegation-core', () => ({ + decodeDelegations: jest.fn(), + hashDelegation: jest.fn(), +})); + const TEST_ADDRESS = '0x1234567890123456789012345678901234567890' as const; const TEST_SITE_ORIGIN = 'https://example.com'; const TEST_CHAIN_ID = '0x1' as const; @@ -88,6 +95,23 @@ describe('RpcHandler', () => { let mockProfileSyncManager: jest.Mocked; beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Setup delegation-core mocks + ( + decodeDelegations as jest.MockedFunction + ).mockReturnValue([ + { + /* mock delegation */ + }, + ] as any); + ( + hashDelegation as jest.MockedFunction + ).mockReturnValue( + '0x1234567890123456789012345678901234567890123456789012345678901234' as any, + ); + mockHandler = { handlePermissionRequest: jest.fn(), } as unknown as jest.Mocked; @@ -102,6 +126,9 @@ describe('RpcHandler', () => { getGrantedPermission: jest.fn(), getAllGrantedPermissions: jest.fn(), getUserProfile: jest.fn(), + updatePermissionRevocationStatus: jest.fn(), + checkDelegationDisabledOnChain: jest.fn(), + updatePermissionRevocationStatusWithPermission: jest.fn(), } as unknown as jest.Mocked; handler = createRpcHandler({ @@ -579,7 +606,15 @@ describe('RpcHandler', () => { { permissionResponse: { chainId: TEST_CHAIN_ID, - expiry: TEST_EXPIRY, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], signer: { type: 'account' as const, data: { address: TEST_ADDRESS }, @@ -596,6 +631,7 @@ describe('RpcHandler', () => { }, }, siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, }, { permissionResponse: { @@ -620,6 +656,7 @@ describe('RpcHandler', () => { }, }, siteOrigin: 'https://another-example.com', + isRevoked: false, }, ]; @@ -659,5 +696,527 @@ describe('RpcHandler', () => { mockProfileSyncManager.getAllGrantedPermissions, ).toHaveBeenCalledTimes(1); }); + + describe('filtering options', () => { + const mockGrantedPermissions = [ + { + permissionResponse: { + chainId: TEST_CHAIN_ID, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { address: TEST_ADDRESS }, + }, + permission: { + type: 'test-permission', + data: { justification: 'Testing permission request' }, + isAdjustmentAllowed: true, + }, + context: TEST_CONTEXT, + dependencyInfo: [], + signerMeta: { + delegationManager: TEST_ADDRESS, + }, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }, + { + permissionResponse: { + chainId: '0x2' as const, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY + 1000, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { + address: '0x0987654321098765432109876543210987654321' as const, + }, + }, + permission: { + type: 'different-permission', + data: { justification: 'Another permission' }, + isAdjustmentAllowed: true, + }, + context: '0xefgh' as const, + dependencyInfo: [], + signerMeta: { + delegationManager: + '0x0987654321098765432109876543210987654321' as const, + }, + }, + siteOrigin: 'https://another-example.com', + isRevoked: true, + }, + { + permissionResponse: { + chainId: TEST_CHAIN_ID, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { address: TEST_ADDRESS }, + }, + permission: { + type: 'third-permission', + data: { justification: 'Third permission' }, + isAdjustmentAllowed: true, + }, + context: '0xijkl' as const, + dependencyInfo: [], + signerMeta: { + delegationManager: + '0x1111111111111111111111111111111111111111' as const, + }, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }, + ]; + + beforeEach(() => { + mockProfileSyncManager.getAllGrantedPermissions.mockResolvedValue( + mockGrantedPermissions, + ); + }); + + it('should filter by isRevoked=true', async () => { + const result = await handler.getGrantedPermissions({ isRevoked: true }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect((result as any[])[0].isRevoked).toBe(true); + }); + + it('should filter by isRevoked=false', async () => { + const result = await handler.getGrantedPermissions({ + isRevoked: false, + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect( + (result as any[]).every( + (permission) => permission.isRevoked === false, + ), + ).toBe(true); + }); + + it('should filter by siteOrigin', async () => { + const result = await handler.getGrantedPermissions({ + siteOrigin: TEST_SITE_ORIGIN, + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect( + (result as any[]).every( + (permission) => permission.siteOrigin === TEST_SITE_ORIGIN, + ), + ).toBe(true); + }); + + it('should filter by chainId', async () => { + const result = await handler.getGrantedPermissions({ + chainId: TEST_CHAIN_ID, + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect( + (result as any[]).every( + (permission) => + permission.permissionResponse.chainId === TEST_CHAIN_ID, + ), + ).toBe(true); + }); + + it('should filter by delegationManager', async () => { + const result = await handler.getGrantedPermissions({ + delegationManager: TEST_ADDRESS, + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect( + (result as any[])[0].permissionResponse.signerMeta.delegationManager, + ).toBe(TEST_ADDRESS); + }); + + it('should combine multiple filters', async () => { + const result = await handler.getGrantedPermissions({ + isRevoked: false, + siteOrigin: TEST_SITE_ORIGIN, + chainId: TEST_CHAIN_ID, + delegationManager: TEST_ADDRESS, + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + const permission = (result as any[])[0]; + expect(permission.isRevoked).toBe(false); + expect(permission.siteOrigin).toBe(TEST_SITE_ORIGIN); + expect(permission.permissionResponse.chainId).toBe(TEST_CHAIN_ID); + expect(permission.permissionResponse.signerMeta.delegationManager).toBe( + TEST_ADDRESS, + ); + }); + + it('should return empty array when no permissions match filters', async () => { + const result = await handler.getGrantedPermissions({ + isRevoked: true, + siteOrigin: 'https://nonexistent.com', + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(0); + }); + + it('should ignore invalid filter values', async () => { + const result = await handler.getGrantedPermissions({ + isRevoked: 'invalid' as any, + siteOrigin: 123 as any, + chainId: null as any, + delegationManager: undefined as any, + }); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); // All permissions returned since filters are ignored + }); + + it('should handle empty params object', async () => { + const result = await handler.getGrantedPermissions({}); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); // All permissions returned + }); + + it('should handle null params', async () => { + const result = await handler.getGrantedPermissions(null as any); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); // All permissions returned + }); + + it('should handle undefined params', async () => { + const result = await handler.getGrantedPermissions(undefined as any); + + expect( + mockProfileSyncManager.getAllGrantedPermissions, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); // All permissions returned + }); + }); + }); + + describe('submitRevocation', () => { + const validRevocationParams = { + permissionContext: TEST_CONTEXT, + }; + + it('should successfully submit revocation with valid parameters', async () => { + const mockPermission = { + permissionResponse: { + chainId: TEST_CHAIN_ID, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { address: TEST_ADDRESS }, + }, + permission: { + type: 'test-permission', + data: { justification: 'Testing permission request' }, + isAdjustmentAllowed: true, + }, + context: TEST_CONTEXT, + dependencyInfo: [], + signerMeta: { + delegationManager: TEST_ADDRESS, + }, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }; + + mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( + mockPermission, + ); + mockProfileSyncManager.checkDelegationDisabledOnChain.mockResolvedValueOnce( + true, + ); + mockProfileSyncManager.updatePermissionRevocationStatusWithPermission.mockResolvedValueOnce( + undefined, + ); + + const result = await handler.submitRevocation(validRevocationParams); + + expect(result).toStrictEqual({ success: true }); + expect(mockProfileSyncManager.getGrantedPermission).toHaveBeenCalledWith( + validRevocationParams.permissionContext, + ); + expect( + mockProfileSyncManager.checkDelegationDisabledOnChain, + ).toHaveBeenCalled(); + expect( + mockProfileSyncManager.updatePermissionRevocationStatusWithPermission, + ).toHaveBeenCalledWith(mockPermission, true); + }); + + it('should throw InvalidInputError when permissionContext is invalid', async () => { + const invalidParams = { + ...validRevocationParams, + permissionContext: 'invalid-context', + }; + + await expect(handler.submitRevocation(invalidParams)).rejects.toThrow( + 'Invalid permission context', + ); + }); + + it('should throw InvalidInputError when permissionContext is wrong format', async () => { + const invalidParams = { + ...validRevocationParams, + permissionContext: 'not-hex-string', // Invalid format + }; + + await expect(handler.submitRevocation(invalidParams)).rejects.toThrow( + 'Invalid permission context', + ); + }); + + it('should throw InvalidInputError when params is null', async () => { + await expect(handler.submitRevocation(null)).rejects.toThrow( + 'Parameters are required', + ); + }); + + it('should throw InvalidInputError when params is undefined', async () => { + await expect(handler.submitRevocation(undefined as any)).rejects.toThrow( + 'Parameters are required', + ); + }); + + it('should throw InvalidInputError when params is not an object', async () => { + await expect(handler.submitRevocation('invalid')).rejects.toThrow( + 'Parameters are required', + ); + }); + + it('should throw InvalidInputError when permissionContext is missing', async () => { + const invalidParams = {}; + + await expect(handler.submitRevocation(invalidParams)).rejects.toThrow( + 'Required', + ); + }); + + it('should propagate errors from updatePermissionRevocationStatusWithPermission', async () => { + const mockPermission = { + permissionResponse: { + chainId: TEST_CHAIN_ID, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { address: TEST_ADDRESS }, + }, + permission: { + type: 'test-permission', + data: { justification: 'Testing permission request' }, + isAdjustmentAllowed: true, + }, + context: TEST_CONTEXT, + dependencyInfo: [], + signerMeta: { + delegationManager: TEST_ADDRESS, + }, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }; + + const profileSyncError = new Error('Update failed'); + mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( + mockPermission, + ); + mockProfileSyncManager.checkDelegationDisabledOnChain.mockResolvedValueOnce( + true, + ); + mockProfileSyncManager.updatePermissionRevocationStatusWithPermission.mockRejectedValueOnce( + profileSyncError, + ); + + await expect( + handler.submitRevocation(validRevocationParams), + ).rejects.toThrow('Update failed'); + + expect( + mockProfileSyncManager.updatePermissionRevocationStatusWithPermission, + ).toHaveBeenCalledWith(mockPermission, true); + }); + + it('should handle hex values with uppercase letters', async () => { + const upperCaseParams = { + permissionContext: '0x1234567890ABCDEF1234567890ABCDEF', + }; + + const mockPermission = { + permissionResponse: { + chainId: '0xAA36A7' as const, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { address: TEST_ADDRESS }, + }, + permission: { + type: 'test-permission', + data: { justification: 'Testing permission request' }, + isAdjustmentAllowed: true, + }, + context: TEST_CONTEXT, + dependencyInfo: [], + signerMeta: { + delegationManager: TEST_ADDRESS, + }, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }; + + mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( + mockPermission, + ); + mockProfileSyncManager.checkDelegationDisabledOnChain.mockResolvedValueOnce( + true, + ); + mockProfileSyncManager.updatePermissionRevocationStatusWithPermission.mockResolvedValueOnce( + undefined, + ); + + const result = await handler.submitRevocation(upperCaseParams); + + expect(result).toStrictEqual({ success: true }); + expect(mockProfileSyncManager.getGrantedPermission).toHaveBeenCalledWith( + upperCaseParams.permissionContext, + ); + }); + + it('should handle different chain configurations', async () => { + const testParams = { + ...validRevocationParams, + }; + + const mockPermission = { + permissionResponse: { + chainId: '0xaa36a7' as const, + rules: [ + { + type: 'expiry', + data: { + timestamp: TEST_EXPIRY, + }, + isAdjustmentAllowed: true, + }, + ], + signer: { + type: 'account' as const, + data: { address: TEST_ADDRESS }, + }, + permission: { + type: 'test-permission', + data: { justification: 'Testing permission request' }, + isAdjustmentAllowed: true, + }, + context: TEST_CONTEXT, + dependencyInfo: [], + signerMeta: { + delegationManager: TEST_ADDRESS, + }, + }, + siteOrigin: TEST_SITE_ORIGIN, + isRevoked: false, + }; + + mockProfileSyncManager.getGrantedPermission.mockResolvedValueOnce( + mockPermission, + ); + mockProfileSyncManager.checkDelegationDisabledOnChain.mockResolvedValueOnce( + true, + ); + mockProfileSyncManager.updatePermissionRevocationStatusWithPermission.mockResolvedValueOnce( + undefined, + ); + + const result = await handler.submitRevocation(testParams); + + expect(result).toStrictEqual({ success: true }); + expect(mockProfileSyncManager.getGrantedPermission).toHaveBeenCalledWith( + testParams.permissionContext, + ); + }); }); });