diff --git a/app/scripts/controllers/authentication/authentication-controller.test.ts b/app/scripts/controllers/authentication/authentication-controller.test.ts index 52cac9329a04..db20a5a85043 100644 --- a/app/scripts/controllers/authentication/authentication-controller.test.ts +++ b/app/scripts/controllers/authentication/authentication-controller.test.ts @@ -1,7 +1,6 @@ import { ControllerMessenger } from '@metamask/base-controller'; -import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import AuthenticationController, { - AuthenticationControllerMessenger, + AllowedActions, AuthenticationControllerState, } from './authentication-controller'; import { @@ -233,11 +232,11 @@ describe('authentication/authentication-controller - getSessionProfile() tests', }); function createAuthenticationMessenger() { - const messenger = new ControllerMessenger(); + const messenger = new ControllerMessenger(); return messenger.getRestricted({ name: 'AuthenticationController', allowedActions: [`SnapController:handleRequest`], - }) as AuthenticationControllerMessenger; + }); } function createMockAuthenticationMessenger() { @@ -247,25 +246,29 @@ function createMockAuthenticationMessenger() { const mockSnapSignMessage = jest .fn() .mockResolvedValue('MOCK_SIGNED_MESSAGE'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockCall.mockImplementation(((actionType: any, params: any) => { - if ( - actionType === 'SnapController:handleRequest' && - params?.request.method === 'getPublicKey' - ) { - return mockSnapGetPublicKey(); + + mockCall.mockImplementation((...args) => { + const [actionType, params] = args; + if (actionType === 'SnapController:handleRequest') { + if (params?.request.method === 'getPublicKey') { + return mockSnapGetPublicKey(); + } + + if (params?.request.method === 'signMessage') { + return mockSnapSignMessage(); + } + + throw new Error( + `MOCK_FAIL - unsupported SnapController:handleRequest call: ${params?.request.method}`, + ); } - if ( - actionType === 'SnapController:handleRequest' && - params?.request.method === 'signMessage' - ) { - return mockSnapSignMessage(); + function exhaustedMessengerMocks(action: never) { + throw new Error(`MOCK_FAIL - unsupported messenger call: ${action}`); } - return ''; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any); + return exhaustedMessengerMocks(actionType); + }); return { messenger, mockSnapGetPublicKey, mockSnapSignMessage }; } diff --git a/app/scripts/controllers/authentication/authentication-controller.ts b/app/scripts/controllers/authentication/authentication-controller.ts index a8a3bb15c7bf..b8a0b964827b 100644 --- a/app/scripts/controllers/authentication/authentication-controller.ts +++ b/app/scripts/controllers/authentication/authentication-controller.ts @@ -63,7 +63,11 @@ type CreateActionsObj = { }; }; type ActionsObj = CreateActionsObj< - 'performSignIn' | 'performSignOut' | 'getBearerToken' | 'getSessionProfile' + | 'performSignIn' + | 'performSignOut' + | 'getBearerToken' + | 'getSessionProfile' + | 'isSignedIn' >; export type Actions = ActionsObj[keyof ActionsObj]; export type AuthenticationControllerPerformSignIn = ActionsObj['performSignIn']; @@ -73,9 +77,10 @@ export type AuthenticationControllerGetBearerToken = ActionsObj['getBearerToken']; export type AuthenticationControllerGetSessionProfile = ActionsObj['getSessionProfile']; +export type AuthenticationControllerIsSignedIn = ActionsObj['isSignedIn']; // Allowed Actions -type AllowedActions = HandleSnapRequest; +export type AllowedActions = HandleSnapRequest; // Messenger export type AuthenticationControllerMessenger = RestrictedControllerMessenger< @@ -108,6 +113,39 @@ export default class AuthenticationController extends BaseController< name: controllerName, state: { ...defaultState, ...state }, }); + + this.#registerMessageHandlers(); + } + + /** + * Constructor helper for registering this controller's messaging system + * actions. + */ + #registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + 'AuthenticationController:getBearerToken', + this.getBearerToken.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'AuthenticationController:getSessionProfile', + this.getSessionProfile.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'AuthenticationController:isSignedIn', + this.isSignedIn.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'AuthenticationController:performSignIn', + this.performSignIn.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'AuthenticationController:performSignOut', + this.performSignOut.bind(this), + ); } public async performSignIn(): Promise { @@ -152,6 +190,10 @@ export default class AuthenticationController extends BaseController< return profile; } + public isSignedIn(): boolean { + return this.state.isSignedIn; + } + #assertLoggedIn(): void { if (!this.state.isSignedIn) { throw new Error( diff --git a/app/scripts/controllers/user-storage/encryption.test.ts b/app/scripts/controllers/user-storage/encryption.test.ts new file mode 100644 index 000000000000..31e1aa151367 --- /dev/null +++ b/app/scripts/controllers/user-storage/encryption.test.ts @@ -0,0 +1,38 @@ +import encryption, { createSHA256Hash } from './encryption'; + +describe('encryption tests', () => { + const PASSWORD = '123'; + const DATA1 = 'Hello World'; + const DATA2 = JSON.stringify({ foo: 'bar' }); + + it('Should encrypt and decrypt data', () => { + function actEncryptDecrypt(data: string) { + const encryptedString = encryption.encryptString(data, PASSWORD); + const decryptString = encryption.decryptString(encryptedString, PASSWORD); + return decryptString; + } + + expect(actEncryptDecrypt(DATA1)).toBe(DATA1); + + expect(actEncryptDecrypt(DATA2)).toBe(DATA2); + }); + + it('Should decrypt some existing data', () => { + const encryptedData = `{"v":"1","d":"R+sCbzS6clo5iLbSzBr889miNfHhCBmOCk2CFwTH55IkbOIL9f5Nm2t0nmWOVtFbjLpnj6cKyw==","iterations":900000}`; + const result = encryption.decryptString(encryptedData, PASSWORD); + expect(result).toBe(DATA1); + }); + + it('Should sha-256 hash a value and should be deterministic', () => { + const DATA = 'Hello World'; + const EXPECTED_HASH = + 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'; + + const hash1 = createSHA256Hash(DATA); + expect(hash1).toBe(EXPECTED_HASH); + + // Hash should be deterministic (same output with same input) + const hash2 = createSHA256Hash(DATA); + expect(hash1).toBe(hash2); + }); +}); diff --git a/app/scripts/controllers/user-storage/encryption.ts b/app/scripts/controllers/user-storage/encryption.ts new file mode 100644 index 000000000000..f8081e97587a --- /dev/null +++ b/app/scripts/controllers/user-storage/encryption.ts @@ -0,0 +1,138 @@ +import { pbkdf2 } from '@noble/hashes/pbkdf2'; +import { sha256 } from '@noble/hashes/sha256'; +import { utf8ToBytes, concatBytes, bytesToHex } from '@noble/hashes/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { randomBytes } from '@noble/ciphers/webcrypto'; + +export type EncryptedPayload = { + v: '1'; // version + d: string; // data + iterations: number; +}; + +function byteArrayToBase64(byteArray: Uint8Array) { + return Buffer.from(byteArray).toString('base64'); +} + +function base64ToByteArray(base64: string) { + return new Uint8Array(Buffer.from(base64, 'base64')); +} + +function bytesToUtf8(byteArray: Uint8Array) { + const decoder = new TextDecoder('utf-8'); + return decoder.decode(byteArray); +} + +class EncryptorDecryptor { + #ALGORITHM_NONCE_SIZE: number = 12; // 12 bytes + + #ALGORITHM_KEY_SIZE: number = 16; // 16 bytes + + #PBKDF2_SALT_SIZE: number = 16; // 16 bytes + + #PBKDF2_ITERATIONS: number = 900_000; + + encryptString(plaintext: string, password: string): string { + try { + return this.#encryptStringV1(plaintext, password); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : e; + throw new Error(`Unable to encrypt string - ${errorMessage}`); + } + } + + decryptString(encryptedDataStr: string, password: string): string { + try { + const encryptedData: EncryptedPayload = JSON.parse(encryptedDataStr); + if (encryptedData.v === '1') { + return this.#decryptStringV1(encryptedData, password); + } + throw new Error(`Unsupported encrypted data payload - ${encryptedData}`); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : e; + throw new Error(`Unable to decrypt string - ${errorMessage}`); + } + } + + #encryptStringV1(plaintext: string, password: string): string { + const salt = randomBytes(this.#PBKDF2_SALT_SIZE); + + // Derive a key using PBKDF2. + const key = pbkdf2(sha256, password, salt, { + c: this.#PBKDF2_ITERATIONS, + dkLen: this.#ALGORITHM_KEY_SIZE, + }); + + // Encrypt and prepend salt. + const plaintextRaw = utf8ToBytes(plaintext); + const ciphertextAndNonceAndSalt = concatBytes( + salt, + this.#encrypt(plaintextRaw, key), + ); + + // Convert to Base64 + const encryptedData = byteArrayToBase64(ciphertextAndNonceAndSalt); + + const encryptedPayload: EncryptedPayload = { + v: '1', + d: encryptedData, + iterations: this.#PBKDF2_ITERATIONS, + }; + + return JSON.stringify(encryptedPayload); + } + + #decryptStringV1(data: EncryptedPayload, password: string): string { + const { iterations, d: base64CiphertextAndNonceAndSalt } = data; + + // Decode the base64. + const ciphertextAndNonceAndSalt = base64ToByteArray( + base64CiphertextAndNonceAndSalt, + ); + + // Create buffers of salt and ciphertextAndNonce. + const salt = ciphertextAndNonceAndSalt.slice(0, this.#PBKDF2_SALT_SIZE); + const ciphertextAndNonce = ciphertextAndNonceAndSalt.slice( + this.#PBKDF2_SALT_SIZE, + ciphertextAndNonceAndSalt.length, + ); + + // Derive the key using PBKDF2. + const key = pbkdf2(sha256, password, salt, { + c: iterations, + dkLen: this.#ALGORITHM_KEY_SIZE, + }); + + // Decrypt and return result. + return bytesToUtf8(this.#decrypt(ciphertextAndNonce, key)); + } + + #encrypt(plaintext: Uint8Array, key: Uint8Array): Uint8Array { + const nonce = randomBytes(this.#ALGORITHM_NONCE_SIZE); + + // Encrypt and prepend nonce. + const ciphertext = gcm(key, nonce).encrypt(plaintext); + + return concatBytes(nonce, ciphertext); + } + + #decrypt(ciphertextAndNonce: Uint8Array, key: Uint8Array): Uint8Array { + // Create buffers of nonce and ciphertext. + const nonce = ciphertextAndNonce.slice(0, this.#ALGORITHM_NONCE_SIZE); + const ciphertext = ciphertextAndNonce.slice( + this.#ALGORITHM_NONCE_SIZE, + ciphertextAndNonce.length, + ); + + // Decrypt and return result. + return gcm(key, nonce).decrypt(ciphertext); + } +} + +const encryption = new EncryptorDecryptor(); +export default encryption; + +export function createSHA256Hash(data: string): string { + const hashedData = sha256(data); + return bytesToHex(hashedData); +} diff --git a/app/scripts/controllers/user-storage/mocks/mockServices.ts b/app/scripts/controllers/user-storage/mocks/mockServices.ts new file mode 100644 index 000000000000..97ffa703dc9c --- /dev/null +++ b/app/scripts/controllers/user-storage/mocks/mockServices.ts @@ -0,0 +1,40 @@ +import nock from 'nock'; +import { USER_STORAGE_ENDPOINT, GetUserStorageResponse } from '../services'; +import { createEntryPath } from '../schema'; +import { MOCK_ENCRYPTED_STORAGE_DATA, MOCK_STORAGE_KEY } from './mockStorage'; + +export const MOCK_USER_STORAGE_NOTIFICATIONS_ENDPOINT = `${USER_STORAGE_ENDPOINT}${createEntryPath( + 'notification_settings', + MOCK_STORAGE_KEY, +)}`; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +const MOCK_GET_USER_STORAGE_RESPONSE: GetUserStorageResponse = { + HashedKey: 'HASHED_KEY', + Data: MOCK_ENCRYPTED_STORAGE_DATA, +}; +export function mockEndpointGetUserStorage(mockReply?: MockReply) { + const reply = mockReply ?? { + status: 200, + body: MOCK_GET_USER_STORAGE_RESPONSE, + }; + + const mockEndpoint = nock(MOCK_USER_STORAGE_NOTIFICATIONS_ENDPOINT) + .get('') + .reply(reply.status, reply.body); + + return mockEndpoint; +} + +export function mockEndpointUpsertUserStorage( + mockReply?: Pick, +) { + const mockEndpoint = nock(MOCK_USER_STORAGE_NOTIFICATIONS_ENDPOINT) + .put('') + .reply(mockReply?.status ?? 204); + return mockEndpoint; +} diff --git a/app/scripts/controllers/user-storage/mocks/mockStorage.ts b/app/scripts/controllers/user-storage/mocks/mockStorage.ts new file mode 100644 index 000000000000..4a43a80556e1 --- /dev/null +++ b/app/scripts/controllers/user-storage/mocks/mockStorage.ts @@ -0,0 +1,9 @@ +import encryption, { createSHA256Hash } from '../encryption'; + +export const MOCK_STORAGE_KEY_SIGNATURE = 'mockStorageKey'; +export const MOCK_STORAGE_KEY = createSHA256Hash(MOCK_STORAGE_KEY_SIGNATURE); +export const MOCK_STORAGE_DATA = JSON.stringify({ hello: 'world' }); +export const MOCK_ENCRYPTED_STORAGE_DATA = encryption.encryptString( + MOCK_STORAGE_DATA, + MOCK_STORAGE_KEY, +); diff --git a/app/scripts/controllers/user-storage/schema.test.ts b/app/scripts/controllers/user-storage/schema.test.ts new file mode 100644 index 000000000000..d08b0802bf49 --- /dev/null +++ b/app/scripts/controllers/user-storage/schema.test.ts @@ -0,0 +1,21 @@ +import { USER_STORAGE_ENTRIES, createEntryPath } from './schema'; + +describe('schema.ts - createEntryPath()', () => { + const MOCK_STORAGE_KEY = 'MOCK_STORAGE_KEY'; + + test('creates a valid entry path', () => { + const result = createEntryPath('notification_settings', MOCK_STORAGE_KEY); + + // Ensures that the path and the entry name are correct. + // If this differs then indicates a potential change on how this path is computed + const expected = `/${USER_STORAGE_ENTRIES.notification_settings.path}/50f65447980018849b991e038d7ad87de5cf07fbad9736b0280e93972e17bac8`; + expect(result).toBe(expected); + }); + + test('Should throw if using an entry that does not exist', () => { + expect(() => { + // @ts-expect-error mocking a fake entry for testing. + createEntryPath('fake_entry'); + }).toThrow(); + }); +}); diff --git a/app/scripts/controllers/user-storage/schema.ts b/app/scripts/controllers/user-storage/schema.ts new file mode 100644 index 000000000000..19bc0ccfae52 --- /dev/null +++ b/app/scripts/controllers/user-storage/schema.ts @@ -0,0 +1,38 @@ +import { createSHA256Hash } from './encryption'; + +type UserStorageEntry = { path: string; entryName: string }; + +/** + * The User Storage Endpoint requires a path and an entry name. + * Developers can provide additional paths by extending this variable below + */ +export const USER_STORAGE_ENTRIES = { + notification_settings: { + path: 'notifications', + entryName: 'notification_settings', + }, +} satisfies Record; + +export type UserStorageEntryKeys = keyof typeof USER_STORAGE_ENTRIES; + +/** + * Constructs a unique entry path for a user. + * This can be done due to the uniqueness of the storage key (no users will share the same storage key). + * The users entry is a unique hash that cannot be reversed. + * + * @param entryKey + * @param storageKey + * @returns + */ +export function createEntryPath( + entryKey: UserStorageEntryKeys, + storageKey: string, +): string { + const entry = USER_STORAGE_ENTRIES[entryKey]; + if (!entry) { + throw new Error(`user-storage - invalid entry provided: ${entryKey}`); + } + + const hashedKey = createSHA256Hash(entry.entryName + storageKey); + return `/${entry.path}/${hashedKey}`; +} diff --git a/app/scripts/controllers/user-storage/services.test.ts b/app/scripts/controllers/user-storage/services.test.ts new file mode 100644 index 000000000000..a746dcee858f --- /dev/null +++ b/app/scripts/controllers/user-storage/services.test.ts @@ -0,0 +1,89 @@ +import { + mockEndpointGetUserStorage, + mockEndpointUpsertUserStorage, +} from './mocks/mockServices'; +import { + MOCK_ENCRYPTED_STORAGE_DATA, + MOCK_STORAGE_DATA, + MOCK_STORAGE_KEY, +} from './mocks/mockStorage'; +import { + GetUserStorageResponse, + getUserStorage, + upsertUserStorage, +} from './services'; + +describe('user-storage/services.ts - getUserStorage() tests', () => { + test('returns user storage data', async () => { + const mockGetUserStorage = mockEndpointGetUserStorage(); + const result = await actCallGetUserStorage(); + + mockGetUserStorage.done(); + expect(result).toBe(MOCK_STORAGE_DATA); + }); + + test('returns null if endpoint does not have entry', async () => { + const mockGetUserStorage = mockEndpointGetUserStorage({ status: 404 }); + const result = await actCallGetUserStorage(); + + mockGetUserStorage.done(); + expect(result).toBe(null); + }); + + test('returns null if endpoint fails', async () => { + const mockGetUserStorage = mockEndpointGetUserStorage({ status: 500 }); + const result = await actCallGetUserStorage(); + + mockGetUserStorage.done(); + expect(result).toBe(null); + }); + + test('returns null if unable to decrypt data', async () => { + const badResponseData: GetUserStorageResponse = { + HashedKey: 'MOCK_HASH', + Data: 'Bad Encrypted Data', + }; + const mockGetUserStorage = mockEndpointGetUserStorage({ + status: 200, + body: badResponseData, + }); + const result = await actCallGetUserStorage(); + + mockGetUserStorage.done(); + expect(result).toBe(null); + }); + + function actCallGetUserStorage() { + return getUserStorage({ + bearerToken: 'MOCK_BEARER_TOKEN', + entryKey: 'notification_settings', + storageKey: MOCK_STORAGE_KEY, + }); + } +}); + +describe('user-storage/services.ts - upsertUserStorage() tests', () => { + test('invokes upsert endpoint with no errors', async () => { + const mockUpsertUserStorage = mockEndpointUpsertUserStorage(); + await actCallUpsertUserStorage(); + + expect(mockUpsertUserStorage.isDone()).toBe(true); + }); + + test('throws error if unable to upsert user storage', async () => { + const mockUpsertUserStorage = mockEndpointUpsertUserStorage({ + status: 500, + }); + + await expect(actCallUpsertUserStorage()).rejects.toThrow(); + mockUpsertUserStorage.done(); + }); + + function actCallUpsertUserStorage() { + return upsertUserStorage(MOCK_ENCRYPTED_STORAGE_DATA, { + bearerToken: 'MOCK_BEARER_TOKEN', + entryKey: 'notification_settings', + storageKey: MOCK_STORAGE_KEY, + }); + } +}); diff --git a/app/scripts/controllers/user-storage/services.ts b/app/scripts/controllers/user-storage/services.ts new file mode 100644 index 000000000000..269009850079 --- /dev/null +++ b/app/scripts/controllers/user-storage/services.ts @@ -0,0 +1,83 @@ +import log from 'loglevel'; + +import encryption from './encryption'; +import { UserStorageEntryKeys, createEntryPath } from './schema'; + +export const USER_STORAGE_API = process.env.USER_STORAGE_API || ''; +export const USER_STORAGE_ENDPOINT = `${USER_STORAGE_API}/api/v1/userstorage`; + +export type GetUserStorageResponse = { + HashedKey: string; + Data: string; +}; + +export type UserStorageOptions = { + bearerToken: string; + entryKey: UserStorageEntryKeys; + storageKey: string; +}; + +export async function getUserStorage( + opts: UserStorageOptions, +): Promise { + try { + const path = createEntryPath(opts.entryKey, opts.storageKey); + const url = new URL(`${USER_STORAGE_ENDPOINT}${path}`); + + const userStorageResponse = await fetch(url.toString(), { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${opts.bearerToken}`, + }, + }); + + // Acceptable error - since indicates entry does not exist. + if (userStorageResponse.status === 404) { + return null; + } + + if (userStorageResponse.status !== 200) { + throw new Error('Unable to get User Storage'); + } + + const userStorage: GetUserStorageResponse | null = + await userStorageResponse.json(); + const encryptedData = userStorage?.Data ?? null; + + if (!encryptedData) { + return null; + } + + const decryptedData = encryption.decryptString( + encryptedData, + opts.storageKey, + ); + + return decryptedData; + } catch (e) { + log.error('Failed to get user storage', e); + return null; + } +} + +export async function upsertUserStorage( + data: string, + opts: UserStorageOptions, +): Promise { + const encryptedData = encryption.encryptString(data, opts.storageKey); + const path = createEntryPath(opts.entryKey, opts.storageKey); + const url = new URL(`${USER_STORAGE_ENDPOINT}${path}`); + + const res = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${opts.bearerToken}`, + }, + body: JSON.stringify({ data: encryptedData }), + }); + + if (!res.ok) { + throw new Error('user-storage - unable to upsert data'); + } +} diff --git a/app/scripts/controllers/user-storage/user-storage-controller.test.ts b/app/scripts/controllers/user-storage/user-storage-controller.test.ts new file mode 100644 index 000000000000..efb0cd4f4ef7 --- /dev/null +++ b/app/scripts/controllers/user-storage/user-storage-controller.test.ts @@ -0,0 +1,333 @@ +import nock from 'nock'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { + AuthenticationControllerGetBearerToken, + AuthenticationControllerGetSessionProfile, + AuthenticationControllerIsSignedIn, + AuthenticationControllerPerformSignIn, +} from '../authentication/authentication-controller'; +import { + MOCK_STORAGE_DATA, + MOCK_STORAGE_KEY, + MOCK_STORAGE_KEY_SIGNATURE, +} from './mocks/mockStorage'; +import UserStorageController, { + AllowedActions, +} from './user-storage-controller'; +import { + mockEndpointGetUserStorage, + mockEndpointUpsertUserStorage, +} from './mocks/mockServices'; + +const typedMockFn = unknown>() => + jest.fn, Parameters>(); + +describe('user-storage/user-storage-controller - constructor() tests', () => { + test('Creates UserStorage with default state', () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + expect(controller.state.isProfileSyncingEnabled).toBe(true); + }); + + function arrangeMocks() { + return { + messengerMocks: mockUserStorageMessenger(), + }; + } +}); + +describe('user-storage/user-storage-controller - performGetStorage() tests', () => { + test('returns users notification storage', async () => { + const { messengerMocks, mockAPI } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + const result = await controller.performGetStorage('notification_settings'); + mockAPI.done(); + expect(result).toBe(MOCK_STORAGE_DATA); + }); + + test('rejects if UserStorage is not enabled', async () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + state: { isProfileSyncingEnabled: false }, + }); + + await expect( + controller.performGetStorage('notification_settings'), + ).rejects.toThrow(); + }); + + test.each([ + [ + 'fails when no bearer token is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetBearerToken.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + [ + 'fails when no session identifier is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetSessionProfile.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + ])('rejects on auth failure - %s', async (_, arrangeFailureCase) => { + const { messengerMocks } = arrangeMocks(); + arrangeFailureCase(messengerMocks); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + await expect( + controller.performGetStorage('notification_settings'), + ).rejects.toThrow(); + }); + + function arrangeMocks() { + return { + messengerMocks: mockUserStorageMessenger(), + mockAPI: mockEndpointGetUserStorage(), + }; + } +}); + +describe('user-storage/user-storage-controller - performSetStorage() tests', () => { + test('saves users storage', async () => { + const { messengerMocks, mockAPI } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + await controller.performSetStorage('notification_settings', 'new data'); + mockAPI.done(); + }); + + test('rejects if UserStorage is not enabled', async () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + state: { isProfileSyncingEnabled: false }, + }); + + await expect( + controller.performSetStorage('notification_settings', 'new data'), + ).rejects.toThrow(); + }); + + test.each([ + [ + 'fails when no bearer token is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetBearerToken.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + [ + 'fails when no session identifier is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetSessionProfile.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + ])('rejects on auth failure - %s', async (_, arrangeFailureCase) => { + const { messengerMocks } = arrangeMocks(); + arrangeFailureCase(messengerMocks); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + await expect( + controller.performSetStorage('notification_settings', 'new data'), + ).rejects.toThrow(); + }); + + test('rejects if api call fails', async () => { + const { messengerMocks } = arrangeMocks({ + mockAPI: mockEndpointUpsertUserStorage({ status: 500 }), + }); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + await expect( + controller.performSetStorage('notification_settings', 'new data'), + ).rejects.toThrow(); + }); + + function arrangeMocks(overrides?: { mockAPI?: nock.Scope }) { + return { + messengerMocks: mockUserStorageMessenger(), + mockAPI: overrides?.mockAPI ?? mockEndpointUpsertUserStorage(), + }; + } +}); + +describe('user-storage/user-storage-controller - performSetStorage() tests', () => { + test('Should return a storage key', async () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + const result = await controller.getStorageKey(); + expect(result).toBe(MOCK_STORAGE_KEY); + }); + + test('rejects if UserStorage is not enabled', async () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + state: { isProfileSyncingEnabled: false }, + }); + + await expect(controller.getStorageKey()).rejects.toThrow(); + }); + + function arrangeMocks() { + return { + messengerMocks: mockUserStorageMessenger(), + }; + } +}); + +describe('user-storage/user-storage-controller - disableProfileSyncing() tests', () => { + test('should disable user storage / profile syncing when called', async () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + }); + + expect(controller.state.isProfileSyncingEnabled).toBe(true); + await controller.disableProfileSyncing(); + expect(controller.state.isProfileSyncingEnabled).toBe(false); + }); + + function arrangeMocks() { + return { + messengerMocks: mockUserStorageMessenger(), + }; + } +}); + +describe('user-storage/user-storage-controller - enableProfileSyncing() tests', () => { + test('should enable user storage / profile syncing', async () => { + const { messengerMocks } = arrangeMocks(); + messengerMocks.mockAuthIsSignedIn.mockReturnValue(false); // mock that auth is not enabled + + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + state: { isProfileSyncingEnabled: false }, + }); + + expect(controller.state.isProfileSyncingEnabled).toBe(false); + await controller.enableProfileSyncing(); + expect(controller.state.isProfileSyncingEnabled).toBe(true); + expect(messengerMocks.mockAuthIsSignedIn).toBeCalled(); + expect(messengerMocks.mockAuthPerformSignIn).toBeCalled(); + }); + + function arrangeMocks() { + return { + messengerMocks: mockUserStorageMessenger(), + }; + } +}); + +function mockUserStorageMessenger() { + const messenger = new ControllerMessenger< + AllowedActions, + never + >().getRestricted({ + name: 'UserStorageController', + allowedActions: [ + 'SnapController:handleRequest', + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + 'AuthenticationController:isSignedIn', + 'AuthenticationController:performSignIn', + ], + }); + + const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); + const mockSnapSignMessage = jest + .fn() + .mockResolvedValue(MOCK_STORAGE_KEY_SIGNATURE); + + const mockAuthGetBearerToken = + typedMockFn< + AuthenticationControllerGetBearerToken['handler'] + >().mockResolvedValue('MOCK_BEARER_TOKEN'); + + const mockAuthGetSessionProfile = typedMockFn< + AuthenticationControllerGetSessionProfile['handler'] + >().mockResolvedValue({ + identifierId: '', + metametricsId: '', + profileId: 'MOCK_PROFILE_ID', + }); + + const mockAuthPerformSignIn = + typedMockFn< + AuthenticationControllerPerformSignIn['handler'] + >().mockResolvedValue('New Access Token'); + + const mockAuthIsSignedIn = + typedMockFn< + AuthenticationControllerIsSignedIn['handler'] + >().mockReturnValue(true); + + jest.spyOn(messenger, 'call').mockImplementation((...args) => { + const [actionType, params] = args; + if (actionType === 'SnapController:handleRequest') { + if (params?.request.method === 'getPublicKey') { + return mockSnapGetPublicKey(); + } + + if (params?.request.method === 'signMessage') { + return mockSnapSignMessage(); + } + + throw new Error( + `MOCK_FAIL - unsupported SnapController:handleRequest call: ${params?.request.method}`, + ); + } + + if (actionType === 'AuthenticationController:getBearerToken') { + return mockAuthGetBearerToken(); + } + + if (actionType === 'AuthenticationController:getSessionProfile') { + return mockAuthGetSessionProfile(); + } + + if (actionType === 'AuthenticationController:performSignIn') { + return mockAuthPerformSignIn(); + } + + if (actionType === 'AuthenticationController:isSignedIn') { + return mockAuthIsSignedIn(); + } + + function exhaustedMessengerMocks(action: never) { + throw new Error(`MOCK_FAIL - unsupported messenger call: ${action}`); + } + + return exhaustedMessengerMocks(actionType); + }); + + return { + messenger, + mockSnapGetPublicKey, + mockSnapSignMessage, + mockAuthGetBearerToken, + mockAuthGetSessionProfile, + mockAuthPerformSignIn, + mockAuthIsSignedIn, + }; +} diff --git a/app/scripts/controllers/user-storage/user-storage-controller.ts b/app/scripts/controllers/user-storage/user-storage-controller.ts new file mode 100644 index 000000000000..bb2c66cfd222 --- /dev/null +++ b/app/scripts/controllers/user-storage/user-storage-controller.ts @@ -0,0 +1,303 @@ +import { + BaseController, + RestrictedControllerMessenger, + StateMetadata, +} from '@metamask/base-controller'; +import { HandleSnapRequest } from '@metamask/snaps-controllers'; +import { + AuthenticationControllerGetBearerToken, + AuthenticationControllerGetSessionProfile, + AuthenticationControllerIsSignedIn, + AuthenticationControllerPerformSignIn, +} from '../authentication/authentication-controller'; +import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests'; +import { getUserStorage, upsertUserStorage } from './services'; +import { UserStorageEntryKeys } from './schema'; +import { createSHA256Hash } from './encryption'; + +const controllerName = 'UserStorageController'; + +// State +export type UserStorageControllerState = { + /** + * Condition used by UI and to determine if we can use some of the User Storage methods. + */ + isProfileSyncingEnabled: boolean; +}; +const defaultState: UserStorageControllerState = { + isProfileSyncingEnabled: true, +}; +const metadata: StateMetadata = { + isProfileSyncingEnabled: { + persist: true, + anonymous: true, + }, +}; + +// Messenger Actions +type CreateActionsObj = { + [K in T]: { + type: `${typeof controllerName}:${K}`; + handler: UserStorageController[K]; + }; +}; +type ActionsObj = CreateActionsObj< + | 'performGetStorage' + | 'performSetStorage' + | 'getStorageKey' + | 'enableProfileSyncing' + | 'disableProfileSyncing' +>; +export type Actions = ActionsObj[keyof ActionsObj]; +export type UserStorageControllerPerformGetStorage = + ActionsObj['performGetStorage']; +export type UserStorageControllerPerformSetStorage = + ActionsObj['performSetStorage']; +export type UserStorageControllerGetStorageKey = ActionsObj['getStorageKey']; +export type UserStorageControllerEnableProfileSyncing = + ActionsObj['enableProfileSyncing']; +export type UserStorageControllerDisableProfileSyncing = + ActionsObj['disableProfileSyncing']; + +// Allowed Actions +export type AllowedActions = + // Snap Requests + | HandleSnapRequest + // Auth Requests + | AuthenticationControllerGetBearerToken + | AuthenticationControllerGetSessionProfile + | AuthenticationControllerPerformSignIn + | AuthenticationControllerIsSignedIn; + +// Messenger +export type UserStorageControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + Actions | AllowedActions, + never, + AllowedActions['type'], + never +>; + +/** + * Reusable controller that allows any team to store synchronized data for a given user. + * These can be settings shared cross MetaMask clients, or data we want to persist when uninstalling/reinstalling. + * + * NOTE: + * - data stored on UserStorage is FULLY encrypted, with the only keys stored/managed on the client. + * - No one can access this data unless they are have the SRP and are able to run the signing snap. + */ +export default class UserStorageController extends BaseController< + typeof controllerName, + UserStorageControllerState, + UserStorageControllerMessenger +> { + #auth = { + getBearerToken: async () => { + return await this.messagingSystem.call( + 'AuthenticationController:getBearerToken', + ); + }, + getProfileId: async () => { + const sessionProfile = await this.messagingSystem.call( + 'AuthenticationController:getSessionProfile', + ); + return sessionProfile?.profileId; + }, + isAuthEnabled: () => { + return this.messagingSystem.call('AuthenticationController:isSignedIn'); + }, + signIn: async () => { + return await this.messagingSystem.call( + 'AuthenticationController:performSignIn', + ); + }, + }; + + constructor(params: { + messenger: UserStorageControllerMessenger; + state?: UserStorageControllerState; + }) { + super({ + messenger: params.messenger, + metadata, + name: controllerName, + state: { ...defaultState, ...params.state }, + }); + + this.#registerMessageHandlers(); + } + + /** + * Constructor helper for registering this controller's messaging system + * actions. + */ + #registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + 'UserStorageController:performGetStorage', + this.performGetStorage.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'UserStorageController:performSetStorage', + this.performSetStorage.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'UserStorageController:getStorageKey', + this.getStorageKey.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'UserStorageController:enableProfileSyncing', + this.enableProfileSyncing.bind(this), + ); + + this.messagingSystem.registerActionHandler( + 'UserStorageController:disableProfileSyncing', + this.disableProfileSyncing.bind(this), + ); + } + + public async enableProfileSyncing(): Promise { + const isAlreadyEnabled = this.state.isProfileSyncingEnabled; + if (isAlreadyEnabled) { + return; + } + + try { + const authEnabled = this.#auth.isAuthEnabled(); + if (!authEnabled) { + await this.#auth.signIn(); + } + + this.update((state) => { + state.isProfileSyncingEnabled = true; + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : e; + throw new Error( + `${controllerName} - failed to enable profile syncing - ${errorMessage}`, + ); + } + } + + public async disableProfileSyncing(): Promise { + const isAlreadyDisabled = !this.state.isProfileSyncingEnabled; + if (isAlreadyDisabled) { + return; + } + + this.update((state) => { + state.isProfileSyncingEnabled = false; + }); + } + + /** + * Allows retrieval of stored data. Data stored is string formatted. + * Developers can extend the entry path and entry name through the `schema.ts` file. + * + * @param entryKey + * @returns the decrypted string contents found from user storage (or null if not found) + */ + public async performGetStorage( + entryKey: UserStorageEntryKeys, + ): Promise { + this.#assertProfileSyncingEnabled(); + const { bearerToken, storageKey } = + await this.#getStorageKeyAndBearerToken(); + const result = await getUserStorage({ + entryKey, + bearerToken, + storageKey, + }); + + return result; + } + + /** + * Allows storage of user data. Data stored must be string formatted. + * Developers can extend the entry path and entry name through the `schema.ts` file. + * + * @param entryKey + * @param value - The string data you want to store. + * @returns nothing. NOTE that an error is thrown if fails to store data. + */ + public async performSetStorage( + entryKey: UserStorageEntryKeys, + value: string, + ): Promise { + this.#assertProfileSyncingEnabled(); + const { bearerToken, storageKey } = + await this.#getStorageKeyAndBearerToken(); + + await upsertUserStorage(value, { + entryKey, + bearerToken, + storageKey, + }); + } + + /** + * Retrieves the storage key, for internal use only! + * + * @returns the storage key + */ + public async getStorageKey(): Promise { + this.#assertProfileSyncingEnabled(); + const storageKey = await this.#createStorageKey(); + return storageKey; + } + + #assertProfileSyncingEnabled(): void { + if (!this.state.isProfileSyncingEnabled) { + throw new Error( + `${controllerName}: Unable to call method, user is not authenticated`, + ); + } + } + + /** + * Utility to get the bearer token and storage key + */ + async #getStorageKeyAndBearerToken(): Promise<{ + bearerToken: string; + storageKey: string; + }> { + const bearerToken = await this.#auth.getBearerToken(); + if (!bearerToken) { + throw new Error('UserStorageController - unable to get bearer token'); + } + const storageKey = await this.#createStorageKey(); + + return { bearerToken, storageKey }; + } + + /** + * Rather than storing the storage key, we can compute the storage key when needed. + * + * @returns the storage key + */ + async #createStorageKey(): Promise { + const id = await this.#auth.getProfileId(); + if (!id) { + throw new Error('UserStorageController - unable to create storage key'); + } + + const storageKeySignature = await this.#snapSignMessage(`metamask:${id}`); + const storageKey = createSHA256Hash(storageKeySignature); + return storageKey; + } + + /** + * Signs a specific message using an underlying auth snap. + * + * @param message - A specific tagged message to sign. + * @returns A Signature created by the snap. + */ + #snapSignMessage(message: `metamask:${string}`): Promise { + return this.messagingSystem.call( + 'SnapController:handleRequest', + createSnapSignMessageRequest(message), + ) as Promise; + } +} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index bd5ff7b5fedd..d5ca79296198 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -316,6 +316,7 @@ import { LatticeKeyringOffscreen } from './lib/offscreen-bridge/lattice-offscree import PREINSTALLED_SNAPS from './snaps/preinstalled-snaps'; ///: END:ONLY_INCLUDE_IF import AuthenticationController from './controllers/authentication/authentication-controller'; +import UserStorageController from './controllers/user-storage/user-storage-controller'; import { WeakRefObjectMap } from './lib/WeakRefObjectMap'; export const METAMASK_CONTROLLER_EVENTS = { @@ -1405,7 +1406,20 @@ export default class MetamaskController extends EventEmitter { state: initState.AuthenticationController, messenger: this.controllerMessenger.getRestricted({ name: 'AuthenticationController', - allowedActions: [`${this.snapController.name}:handleRequest`], + allowedActions: ['SnapController:handleRequest'], + }), + }); + this.userStorageController = new UserStorageController({ + state: initState.UserStorageController, + messenger: this.controllerMessenger.getRestricted({ + name: 'UserStorageController', + allowedActions: [ + 'SnapController:handleRequest', + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + 'AuthenticationController:isSignedIn', + 'AuthenticationController:performSignIn', + ], }), }); diff --git a/builds.yml b/builds.yml index 1f58f601e0d6..68e8fb9c2a34 100644 --- a/builds.yml +++ b/builds.yml @@ -204,6 +204,7 @@ env: - OIDC_API: https://oidc.api.cx.metamask.io - OIDC_CLIENT_ID: 1132f10a-b4e5-4390-a5f2-d9c6022db564 - OIDC_GRANT_TYPE: urn:ietf:params:oauth:grant-type:jwt-bearer + - USER_STORAGE_API: https://user-storage.api.cx.metamask.io ### # API keys to 3rd party services diff --git a/jest.config.js b/jest.config.js index bd8d58f867ed..a3d34d0d6495 100644 --- a/jest.config.js +++ b/jest.config.js @@ -52,6 +52,7 @@ module.exports = { '/app/scripts/controllers/sign.test.ts', '/app/scripts/controllers/decrypt-message.test.ts', '/app/scripts/controllers/authentication/**/*.test.ts', + '/app/scripts/controllers/user-storage/**/*.test.ts', '/app/scripts/flask/**/*.test.js', '/app/scripts/lib/**/*.test.(js|ts)', '/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js', diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 59fabbea012a..2ef6fa68f64b 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2493,6 +2493,13 @@ "define": true } }, + "@noble/ciphers": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "crypto": true + } + }, "@noble/hashes": { "globals": { "TextEncoder": true, diff --git a/lavamoat/browserify/desktop/policy.json b/lavamoat/browserify/desktop/policy.json index 614cc9fc728b..04f6ed2c44c3 100644 --- a/lavamoat/browserify/desktop/policy.json +++ b/lavamoat/browserify/desktop/policy.json @@ -2806,6 +2806,13 @@ "define": true } }, + "@noble/ciphers": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "crypto": true + } + }, "@noble/hashes": { "globals": { "TextEncoder": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index fca1e15deb6d..56924f35f4ee 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -2858,6 +2858,13 @@ "define": true } }, + "@noble/ciphers": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "crypto": true + } + }, "@noble/hashes": { "globals": { "TextEncoder": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 441bb65aa9b5..ef602840706e 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2773,6 +2773,13 @@ "define": true } }, + "@noble/ciphers": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "crypto": true + } + }, "@noble/hashes": { "globals": { "TextEncoder": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 72f9adec0765..3024a88ab9d0 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -2912,6 +2912,13 @@ "define": true } }, + "@noble/ciphers": { + "globals": { + "TextDecoder": true, + "TextEncoder": true, + "crypto": true + } + }, "@noble/hashes": { "globals": { "TextEncoder": true, diff --git a/package.json b/package.json index 99dfd7b1548b..1bcf64f557ac 100644 --- a/package.json +++ b/package.json @@ -327,6 +327,7 @@ "@metamask/user-operation-controller": "^6.0.0", "@metamask/utils": "^8.2.1", "@ngraveio/bc-ur": "^1.1.12", + "@noble/ciphers": "^0.5.2", "@noble/hashes": "^1.3.3", "@popperjs/core": "^2.4.0", "@reduxjs/toolkit": "^1.6.2", diff --git a/test/env.js b/test/env.js index 6eb467931825..8fc00388edbf 100644 --- a/test/env.js +++ b/test/env.js @@ -5,3 +5,4 @@ process.env.IFRAME_EXECUTION_ENVIRONMENT_URL = 'https://execution.metamask.io/0.36.1-flask.1/index.html'; process.env.AUTH_API = 'https://mock-test-auth-api.metamask.io'; process.env.OIDC_API = 'https://mock-test-oidc-api.metamask.io'; +process.env.USER_STORAGE_API = 'https://mock-test-user-storage.metamask.io'; diff --git a/yarn.lock b/yarn.lock index 35104179c8a6..8ed697cbb8c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5906,7 +5906,7 @@ __metadata: languageName: node linkType: hard -"@noble/ciphers@npm:^0.5.1": +"@noble/ciphers@npm:^0.5.1, @noble/ciphers@npm:^0.5.2": version: 0.5.2 resolution: "@noble/ciphers@npm:0.5.2" checksum: 47a5958954249d5edb49aff48ae6fbfff4d4e5a6bb221c010ebc8e7470c410e9208a2f3f6bf8b7eca83057277478f4ccbdbdcf1bfd324608b334b9f9d28a9fbb @@ -24778,6 +24778,7 @@ __metadata: "@metamask/user-operation-controller": "npm:^6.0.0" "@metamask/utils": "npm:^8.2.1" "@ngraveio/bc-ur": "npm:^1.1.12" + "@noble/ciphers": "npm:^0.5.2" "@noble/hashes": "npm:^1.3.3" "@playwright/test": "npm:^1.39.0" "@popperjs/core": "npm:^2.4.0"