From 62a9880d0925ea422cdc7ccbc6db8488d9c2aead Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 10 Jan 2025 14:29:12 +0100 Subject: [PATCH] feat: persist UserStorage e2e content keys using an encrypted keyStore fixes #5128 --- .gitignore | 3 + packages/profile-sync-controller/package.json | 2 + .../authentication/auth-snap-requests.ts | 37 +++ .../UserStorageController.test.ts | 9 + .../user-storage/UserStorageController.ts | 113 +++++++++- .../controller-integration.test.ts | 1 + .../src/controllers/user-storage/services.ts | 11 +- .../src/shared/encryption/encryption.ts | 49 +++- .../src/shared/encryption/key-storage.ts | 210 ++++++++++++++++++ yarn.lock | 21 +- 10 files changed, 434 insertions(+), 22 deletions(-) create mode 100644 packages/profile-sync-controller/src/shared/encryption/key-storage.ts diff --git a/.gitignore b/.gitignore index 5043addaa41..1f6024b7e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ scripts/coverage # typescript packages/*/*.tsbuildinfo + +# jetbrains IDE local files +/.idea diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index b1ab48f856e..87637839851 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -106,7 +106,9 @@ "@metamask/network-controller": "^22.1.1", "@metamask/snaps-sdk": "^6.7.0", "@metamask/snaps-utils": "^8.3.0", + "@metamask/utils": "^11.0.1", "@noble/ciphers": "^0.5.2", + "@noble/curves": "^1.7.0", "@noble/hashes": "^1.4.0", "immer": "^9.0.6", "loglevel": "^1.8.1", diff --git a/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts b/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts index 347e79800aa..fb3e7e02404 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; +import type { Eip1024EncryptedData } from '@metamask/utils'; type SnapRPCRequest = Parameters[0]; @@ -22,6 +23,22 @@ export function createSnapPublicKeyRequest(): SnapRPCRequest { }; } +/** + * Constructs Request to Message Signing Snap to get the Encryption Public Key + * + * @returns Snap Encryption Public Key Request + */ +export function createSnapEncryptionPublicKeyRequest(): SnapRPCRequest { + return { + snapId, + origin: '', + handler: 'onRpcRequest' as any, + request: { + method: 'getEncryptionPublicKey', + }, + }; +} + /** * Constructs Request to get Message Signing Snap to sign a message. * @@ -41,3 +58,23 @@ export function createSnapSignMessageRequest( }, }; } + +/** + * Constructs Request to get Message Signing Snap to decrypt a message. + * + * @param data - message to decrypt + * @returns Snap Sign Message Request + */ +export function createSnapDecryptMessageRequest( + data: Eip1024EncryptedData, +): SnapRPCRequest { + return { + snapId, + origin: '', + handler: 'onRpcRequest' as any, + request: { + method: 'decryptMessage', + params: { data }, + }, + }; +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index 5aa1f3b8a12..cd33b13de10 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -109,6 +109,7 @@ describe('user-storage/user-storage-controller - performGetStorage() tests', () isAccountSyncingInProgress: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + encryptedContentKeys: {}, }, }); @@ -191,6 +192,7 @@ describe('user-storage/user-storage-controller - performGetStorageAllFeatureEntr hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -273,6 +275,7 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -379,6 +382,7 @@ describe('user-storage/user-storage-controller - performBatchSetStorage() tests' hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -482,6 +486,7 @@ describe('user-storage/user-storage-controller - performBatchDeleteStorage() tes hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -586,6 +591,7 @@ describe('user-storage/user-storage-controller - performDeleteStorage() tests', hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -687,6 +693,7 @@ describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureE hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -780,6 +787,7 @@ describe('user-storage/user-storage-controller - getStorageKey() tests', () => { hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); @@ -827,6 +835,7 @@ describe('user-storage/user-storage-controller - enableProfileSyncing() tests', hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }, }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 99c5606bdbd..f4e5cb8f3be 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -1,8 +1,8 @@ import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRenamedEvent, AccountsControllerListAccountsAction, AccountsControllerUpdateAccountMetadataAction, - AccountsControllerAccountRenamedEvent, - AccountsControllerAccountAddedEvent, } from '@metamask/accounts-controller'; import type { ControllerGetStateAction, @@ -12,10 +12,10 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { + type KeyringControllerAddNewAccountAction, type KeyringControllerGetStateAction, type KeyringControllerLockEvent, type KeyringControllerUnlockEvent, - type KeyringControllerAddNewAccountAction, } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { @@ -26,22 +26,29 @@ import type { NetworkControllerUpdateNetworkAction, } from '@metamask/network-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; +import type { Eip1024EncryptedData } from '@metamask/utils'; import { createSHA256Hash } from '../../shared/encryption'; +import type { KeyStore } from '../../shared/encryption/key-storage'; +import { ERC1024WrappedKeyStore } from '../../shared/encryption/key-storage'; import type { UserStorageFeatureKeys } from '../../shared/storage-schema'; import { type UserStoragePathWithFeatureAndKey, type UserStoragePathWithFeatureOnly, } from '../../shared/storage-schema'; import type { NativeScrypt } from '../../shared/types/encryption'; -import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests'; +import { + createSnapDecryptMessageRequest, + createSnapEncryptionPublicKeyRequest, + createSnapSignMessageRequest, +} from '../authentication/auth-snap-requests'; import type { AuthenticationControllerGetBearerToken, AuthenticationControllerGetSessionProfile, AuthenticationControllerIsSignedIn, AuthenticationControllerPerformSignIn, AuthenticationControllerPerformSignOut, -} from '../authentication/AuthenticationController'; +} from '../authentication'; import { saveInternalAccountToUserStorage, syncInternalAccountsWithUserStorage, @@ -102,6 +109,10 @@ export type UserStorageControllerState = { * Condition used to ensure that we do not perform any network sync mutations until we have synced at least once */ hasNetworkSyncingSyncedAtLeastOnce?: boolean; + /** + * Content keys used to encrypt/decrypt user storage content. These are wrapped while at rest. + */ + encryptedContentKeys: Record; }; export const defaultState: UserStorageControllerState = { @@ -110,6 +121,7 @@ export const defaultState: UserStorageControllerState = { hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }; const metadata: StateMetadata = { @@ -137,6 +149,10 @@ const metadata: StateMetadata = { persist: true, anonymous: false, }, + encryptedContentKeys: { + persist: true, + anonymous: false, + }, }; type ControllerConfig = { @@ -313,6 +329,79 @@ export default class UserStorageController extends BaseController< isNetworkSyncingEnabled: false, }; + #_snapPublicKeyCache: string | null = null; + + #keyWrapping = { + /** + * Returns the snap Encryption public key. + * + * @returns The snap Encryption public key. + */ + snapGetEncryptionPublicKey: async (): Promise => { + if (this.#_snapPublicKeyCache) { + return this.#_snapPublicKeyCache; + } + + if (!this.#isUnlocked) { + throw new Error( + '#snapGetEncryptionPublicKey - unable to call snap, wallet is locked', + ); + } + + const result = (await this.messagingSystem.call( + 'SnapController:handleRequest', + createSnapEncryptionPublicKeyRequest(), + )) as string; + + this.#_snapPublicKeyCache = result; + + return result; + }, + + /** + * Decrypts a message using the message signing snap. + * + * @param data - Eip1024EncryptedData - The encrypted data. + * @returns The decrypted message, if it was intended for this wallet, null otherwise. TODO: check error scenarios + */ + snapDecryptMessage: async (data: Eip1024EncryptedData): Promise => { + if (!this.#isUnlocked) { + throw new Error( + '#snapDecryptMessage - unable to call snap, wallet is locked', + ); + } + + const result = (await this.messagingSystem.call( + 'SnapController:handleRequest', + createSnapDecryptMessageRequest(data), + )) as string; + + return result; + }, + + loadWrappedKey: async (keyRef: string): Promise => { + return this.state.encryptedContentKeys[keyRef] ?? null; + }, + + storeWrappedKey: (keyRef: string, wrappedKey: string): Promise => { + return new Promise((resolve) => { + this.update((state) => { + state.encryptedContentKeys[keyRef] = wrappedKey; + resolve(); + }); + }); + }, + + getWrappedKeyStore: (): KeyStore => { + return new ERC1024WrappedKeyStore({ + decryptMessage: this.#keyWrapping.snapDecryptMessage, + getPublicKey: this.#keyWrapping.snapGetEncryptionPublicKey, + getItem: this.#keyWrapping.loadWrappedKey, + setItem: this.#keyWrapping.storeWrappedKey, + }); + }, + }; + #auth = { getBearerToken: async () => { return await this.messagingSystem.call( @@ -374,7 +463,9 @@ export default class UserStorageController extends BaseController< }, }; - #nativeScryptCrypto: NativeScrypt | undefined = undefined; + #nativeScryptCrypto: NativeScrypt | undefined; + + #keyStore: KeyStore | undefined; getMetaMetricsState: () => boolean; @@ -385,6 +476,7 @@ export default class UserStorageController extends BaseController< config, getMetaMetricsState, nativeScryptCrypto, + keyStore, }: { messenger: UserStorageControllerMessenger; state?: UserStorageControllerState; @@ -395,6 +487,7 @@ export default class UserStorageController extends BaseController< }; getMetaMetricsState: () => boolean; nativeScryptCrypto?: NativeScrypt; + keyStore?: KeyStore; }) { super({ messenger, @@ -432,6 +525,8 @@ export default class UserStorageController extends BaseController< !this.state.hasNetworkSyncingSyncedAtLeastOnce, }); } + + this.#keyStore = keyStore ?? this.#keyWrapping.getWrappedKeyStore(); } /** @@ -491,6 +586,7 @@ export default class UserStorageController extends BaseController< storageKey, bearerToken, nativeScryptCrypto: this.#nativeScryptCrypto, + keyStore: this.#keyStore, }; } @@ -581,6 +677,7 @@ export default class UserStorageController extends BaseController< bearerToken, storageKey, nativeScryptCrypto: this.#nativeScryptCrypto, + keyStore: this.#keyStore, }); return result; @@ -606,6 +703,7 @@ export default class UserStorageController extends BaseController< bearerToken, storageKey, nativeScryptCrypto: this.#nativeScryptCrypto, + keyStore: this.#keyStore, }); return result; @@ -633,6 +731,7 @@ export default class UserStorageController extends BaseController< bearerToken, storageKey, nativeScryptCrypto: this.#nativeScryptCrypto, + keyStore: this.#keyStore, }); } @@ -660,6 +759,7 @@ export default class UserStorageController extends BaseController< bearerToken, storageKey, nativeScryptCrypto: this.#nativeScryptCrypto, + keyStore: this.#keyStore, }); } @@ -730,6 +830,7 @@ export default class UserStorageController extends BaseController< bearerToken, storageKey, nativeScryptCrypto: this.#nativeScryptCrypto, + keyStore: this.#keyStore, }); } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts index d0a9bf109ec..82f2fb8b9bd 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts @@ -32,6 +32,7 @@ const baseState = { hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + encryptedContentKeys: {}, }; const arrangeMocks = async ({ diff --git a/packages/profile-sync-controller/src/controllers/user-storage/services.ts b/packages/profile-sync-controller/src/controllers/user-storage/services.ts index 1ccf1fd10cb..2b997d654d4 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/services.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/services.ts @@ -2,6 +2,7 @@ import log from 'loglevel'; import encryption, { createSHA256Hash } from '../../shared/encryption'; import { SHARED_SALT } from '../../shared/encryption/constants'; +import type { KeyStore } from '../../shared/encryption/key-storage'; import { Env, getEnvUrls } from '../../shared/env'; import type { UserStoragePathWithFeatureAndKey, @@ -36,6 +37,7 @@ export type UserStorageBaseOptions = { bearerToken: string; storageKey: string; nativeScryptCrypto?: NativeScrypt; + keyStore?: KeyStore; }; export type UserStorageOptions = UserStorageBaseOptions & { @@ -58,7 +60,8 @@ export async function getUserStorage( opts: UserStorageOptions, ): Promise { try { - const { bearerToken, path, storageKey, nativeScryptCrypto } = opts; + const { bearerToken, path, storageKey, nativeScryptCrypto, keyStore } = + opts; const encryptedPath = createEntryPath(path, storageKey); const url = new URL(`${USER_STORAGE_ENDPOINT}/${encryptedPath}`); @@ -94,6 +97,7 @@ export async function getUserStorage( encryptedData, opts.storageKey, nativeScryptCrypto, + keyStore, ); // Re-encrypt and re-upload the entry if the salt is random @@ -119,7 +123,7 @@ export async function getUserStorageAllFeatureEntries( opts: UserStorageAllFeatureEntriesOptions, ): Promise { try { - const { bearerToken, path, nativeScryptCrypto } = opts; + const { bearerToken, path, nativeScryptCrypto, keyStore } = opts; const url = new URL(`${USER_STORAGE_ENDPOINT}/${path}`); const userStorageResponse = await fetch(url.toString(), { @@ -161,6 +165,7 @@ export async function getUserStorageAllFeatureEntries( entry.Data, opts.storageKey, nativeScryptCrypto, + keyStore, ); decryptedData.push(data); @@ -173,6 +178,7 @@ export async function getUserStorageAllFeatureEntries( data, opts.storageKey, nativeScryptCrypto, + keyStore, ), ]); } @@ -212,6 +218,7 @@ export async function upsertUserStorage( data, opts.storageKey, nativeScryptCrypto, + opts.keyStore, ); const encryptedPath = createEntryPath(path, storageKey); const url = new URL(`${USER_STORAGE_ENDPOINT}/${encryptedPath}`); diff --git a/packages/profile-sync-controller/src/shared/encryption/encryption.ts b/packages/profile-sync-controller/src/shared/encryption/encryption.ts index 7b36a15e64c..d4732855668 100644 --- a/packages/profile-sync-controller/src/shared/encryption/encryption.ts +++ b/packages/profile-sync-controller/src/shared/encryption/encryption.ts @@ -5,11 +5,7 @@ import { sha256 } from '@noble/hashes/sha256'; import { utf8ToBytes, concatBytes, bytesToHex } from '@noble/hashes/utils'; import type { NativeScrypt } from '../types/encryption'; -import { - getCachedKeyBySalt, - getCachedKeyGeneratedWithSharedSalt, - setCachedKey, -} from './cache'; +import { getCachedKeyBySalt, setCachedKey } from './cache'; import { ALGORITHM_KEY_SIZE, ALGORITHM_NONCE_SIZE, @@ -19,6 +15,7 @@ import { SCRYPT_SALT_SIZE, SHARED_SALT, } from './constants'; +import type { KeyStore } from './key-storage'; import { base64ToByteArray, byteArrayToBase64, @@ -26,6 +23,11 @@ import { stringToByteArray, } from './utils'; +/** + * Describes the structure of an encrypted payload for user storage. + * + * The data is encrypted using AES-GCM, and the key is derived from the profile storage_key using scrypt. + */ export type EncryptedPayload = { // version v: '1'; @@ -36,7 +38,7 @@ export type EncryptedPayload = { // data d: string; - // encryption options - scrypt + // derivation options - scrypt o: { // eslint-disable-next-line @typescript-eslint/naming-convention N: number; @@ -54,12 +56,14 @@ class EncryptorDecryptor { plaintext: string, password: string, nativeScryptCrypto?: NativeScrypt, + keyStore?: KeyStore, ): Promise { try { return await this.#encryptStringV1( plaintext, password, nativeScryptCrypto, + keyStore, ); } catch (e) { const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); @@ -71,6 +75,7 @@ class EncryptorDecryptor { encryptedDataStr: string, password: string, nativeScryptCrypto?: NativeScrypt, + keyStore?: KeyStore, ): Promise { try { const encryptedData: EncryptedPayload = JSON.parse(encryptedDataStr); @@ -80,6 +85,7 @@ class EncryptorDecryptor { encryptedData, password, nativeScryptCrypto, + keyStore, ); } } @@ -96,6 +102,7 @@ class EncryptorDecryptor { plaintext: string, password: string, nativeScryptCrypto?: NativeScrypt, + keyStore?: KeyStore, ): Promise { const { key, salt } = await this.#getOrGenerateScryptKey( password, @@ -107,6 +114,7 @@ class EncryptorDecryptor { }, undefined, nativeScryptCrypto, + keyStore, ); // Encrypt and prepend salt. @@ -139,6 +147,7 @@ class EncryptorDecryptor { data: EncryptedPayload, password: string, nativeScryptCrypto?: NativeScrypt, + keyStore?: KeyStore, ): Promise { const { o, d: base64CiphertextAndNonceAndSalt, saltLen } = data; @@ -165,6 +174,7 @@ class EncryptorDecryptor { }, salt, nativeScryptCrypto, + keyStore, ); // Decrypt and return result. @@ -238,11 +248,13 @@ class EncryptorDecryptor { o: EncryptedPayload['o'], salt?: Uint8Array, nativeScryptCrypto?: NativeScrypt, + keyStore?: KeyStore, ) { const hashedPassword = createSHA256Hash(password); + const cachedKey = salt ? getCachedKeyBySalt(hashedPassword, salt) - : getCachedKeyGeneratedWithSharedSalt(hashedPassword); + : getCachedKeyBySalt(hashedPassword, SHARED_SALT); if (cachedKey) { return { @@ -252,9 +264,23 @@ class EncryptorDecryptor { } const newSalt = salt ?? SHARED_SALT; + const keyRef = `${hashedPassword}${bytesToHex(newSalt)}`; + if (keyStore) { + try { + const storedKey = await keyStore.loadKey(keyRef); + if (storedKey) { + setCachedKey(hashedPassword, newSalt, storedKey); + return { + key: storedKey, + salt: newSalt, + }; + } + } catch (e) { + // nop. couldn't decrypt key, proceed to deriving it + } + } let newKey: Uint8Array; - if (nativeScryptCrypto) { newKey = await nativeScryptCrypto( stringToByteArray(password), @@ -274,6 +300,13 @@ class EncryptorDecryptor { } setCachedKey(hashedPassword, newSalt, newKey); + if (keyStore) { + try { + await keyStore.storeKey(keyRef, newKey); + } catch (e) { + // nop. couldn't store key, proceed to just returning it + } + } return { key: newKey, diff --git a/packages/profile-sync-controller/src/shared/encryption/key-storage.ts b/packages/profile-sync-controller/src/shared/encryption/key-storage.ts new file mode 100644 index 00000000000..2d4af228050 --- /dev/null +++ b/packages/profile-sync-controller/src/shared/encryption/key-storage.ts @@ -0,0 +1,210 @@ +import type { Eip1024EncryptedData, Hex } from '@metamask/utils'; +import { base64ToBytes, bytesToBase64, hexToBytes } from '@metamask/utils'; +import { randomBytes } from '@noble/ciphers/crypto'; +import { hsalsa, secretbox } from '@noble/ciphers/salsa'; +import { u8, u32 } from '@noble/ciphers/utils'; +import { x25519 } from '@noble/curves/ed25519'; +import { utf8ToBytes } from '@noble/hashes/utils'; + +/** + * A decryption interface for ERC-1024 encrypted payloads. + */ +export type CryptoLayer = { + /** + * Gets a public key in hex encoding. This is meant to be used to encrypt keys at rest. + * Initial implementations provide a x25519 key as that is the only known variant for the ERC1024 implementation. + * But this interface does not specify the type of public key. + */ + getPublicKey(): Promise; + + /** + * Decrypts an EIP-1024 encrypted message using the private key corresponding to `getPublicKey`. + */ + decryptMessage(message: Eip1024EncryptedData): Promise; +}; + +/** + * An interface for a storage layer. This is meant to be used to store encrypted keys. + */ +export type StorageLayer = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +}; + +/** + * A storage layer for keys that should be encrypted at rest. + */ +export type KeyStore = { + /** + * Wraps a key and stores it in the key store. + * @param keyRef - The key ID to use for later fetching. + * @param key - The key material to be stored. + * @returns Whether the operation was successful. + */ + storeKey(keyRef: string, key: Uint8Array): Promise; + + /** + * Loads a key from the key store(if it exists) and unwraps it. + * @param keyRef - The ID of the key to fetch. + * @returns The unwrapped key, or null if the key does not exist. + */ + loadKey(keyRef: string): Promise; +}; + +/** + * A helper class for creating ERC-1024 encrypted payloads. + */ +class ERC1024Helper { + #publicKeyLength = 32; + + #secretKeyLength = 32; + + #nonceLength = 24; + + #expandedKeyLength = 32; + + // hardcoded value of `u32(new TextEncoder().encode('expand 32-byte k'));` + #_sigma = new Uint32Array([1634760805, 857760878, 2036477234, 1797285236]); + + #computeSharedKey = (pk: Uint8Array, sk: Uint8Array): Uint8Array => { + const s = x25519.getSharedSecret(sk, pk); + const k32 = new Uint32Array(this.#expandedKeyLength / 4); + hsalsa(this.#_sigma, u32(s), new Uint32Array(4), k32); + return u8(k32); + }; + + #checkArrayTypes = (publicKey: unknown, secretKey: unknown) => { + if (!(publicKey instanceof Uint8Array)) { + throw new TypeError('publicKey must be a Uint8Array'); + } + if (!(secretKey instanceof Uint8Array)) { + throw new TypeError('secretKey must be a Uint8Array'); + } + }; + + #checkKeyLengths = (publicKey: Uint8Array, secretKey: Uint8Array) => { + if (publicKey.length !== this.#publicKeyLength) { + throw new TypeError( + `publicKey must be ${this.#publicKeyLength} bytes long`, + ); + } + if (secretKey.length !== this.#secretKeyLength) { + throw new TypeError( + `secretKey must be ${this.#secretKeyLength} bytes long`, + ); + } + }; + + #boxSeal = ( + message: Uint8Array, + nonce: Uint8Array, + pk: Uint8Array, + sk: Uint8Array, + ): Uint8Array => { + this.#checkArrayTypes(pk, sk); + this.#checkKeyLengths(pk, sk); + const k = this.#computeSharedKey(pk, sk); + return secretbox(k, nonce).seal(message); + }; + + // // Provided for symmetry, but not used. + // + // #boxOpen = ( + // box: Uint8Array, + // nonce: Uint8Array, + // pk: Uint8Array, + // sk: Uint8Array, + // ): Uint8Array => { + // this.#checkArrayTypes(pk, sk); + // this.#checkKeyLengths(pk, sk); + // const k = this.#computeSharedKey(pk, sk); + // return secretbox(k, nonce).open(box); + // }; + + encrypt = ( + receiverPublicKey: Hex | Uint8Array, + message: string, + version = 'x25519-xsalsa20-poly1305', + ): Eip1024EncryptedData => { + switch (version) { + case 'x25519-xsalsa20-poly1305': { + // generate ephemeral keypair + const ephemeralSecret = randomBytes(this.#secretKeyLength); + const ephemeralPublic = x25519.getPublicKey(ephemeralSecret); + + let publicKeyBytes; + // assemble encryption parameters + if (receiverPublicKey instanceof Uint8Array) { + publicKeyBytes = receiverPublicKey; + } else { + try { + publicKeyBytes = hexToBytes(receiverPublicKey); + } catch (error) { + throw new Error('Bad public key'); + } + } + + const messageBytes = utf8ToBytes(message); + const nonce = randomBytes(this.#nonceLength); + + // encrypt + const encryptedMessage = this.#boxSeal( + messageBytes, + nonce, + publicKeyBytes, + ephemeralSecret, + ); + + // return encrypted data + return { + version: 'x25519-xsalsa20-poly1305', + nonce: bytesToBase64(nonce), + ephemPublicKey: bytesToBase64(ephemeralPublic), + ciphertext: bytesToBase64(encryptedMessage), + } as Eip1024EncryptedData; + } + default: + throw new Error(`Encryption type/version not supported ${version}`); + } + }; +} + +/** + * A key store that wraps keys in an ERC-1024 compatible envelope while at rest. + * @see https://github.com/ethereum/EIPs/pull/1098 + */ +export class ERC1024WrappedKeyStore implements KeyStore { + #config: CryptoLayer & StorageLayer; + + constructor(config: CryptoLayer & StorageLayer) { + this.#config = config; + } + + async storeKey(keyRef: string, key: Uint8Array): Promise { + try { + const publicKeyHex = hexToBytes(await this.#config.getPublicKey()); + const encryptedKey = new ERC1024Helper().encrypt( + publicKeyHex, + bytesToBase64(key), + ); + await this.#config.setItem(keyRef, JSON.stringify(encryptedKey)); + return true; + } catch { + return false; + } + } + + async loadKey(keyRef: string): Promise { + try { + const wrappedKeySerialized = await this.#config.getItem(keyRef); + const wrappedKey = JSON.parse( + wrappedKeySerialized as string, + ) as Eip1024EncryptedData; + const contentKeyBase64 = await this.#config.decryptMessage(wrappedKey); + return base64ToBytes(contentKeyBase64); + } catch { + // this is meant to be a silent error, as the key might not exist in the cache + return null; + } + } +} diff --git a/yarn.lock b/yarn.lock index c92dafbd90a..4155996523f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3645,7 +3645,9 @@ __metadata: "@metamask/snaps-controllers": "npm:^9.10.0" "@metamask/snaps-sdk": "npm:^6.7.0" "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/utils": "npm:^11.0.1" "@noble/ciphers": "npm:^0.5.2" + "@noble/curves": "npm:^1.7.0" "@noble/hashes": "npm:^1.4.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -4180,12 +4182,12 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0": - version: 1.5.0 - resolution: "@noble/curves@npm:1.5.0" +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.7.0": + version: 1.7.0 + resolution: "@noble/curves@npm:1.7.0" dependencies: - "@noble/hashes": "npm:1.4.0" - checksum: 10/d7707d756a887a0daf9eba709526017ac6905d4be58760947e0f0652961926295ba62a5a699d9a9f0bf2a2e0c6803381373e14542be5ff3885b3434bb59be86c + "@noble/hashes": "npm:1.6.0" + checksum: 10/2a11ef4895907d0b241bd3b72f9e6ebe56f0e705949bfd5efe003f25233549f620d287550df2d24ad56a1f953b82ec5f7cf4bd7cb78b1b2e76eb6dd516d44cf8 languageName: node linkType: hard @@ -4196,13 +4198,20 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": +"@noble/hashes@npm:1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" checksum: 10/e156e65794c473794c52fa9d06baf1eb20903d0d96719530f523cc4450f6c721a957c544796e6efd0197b2296e7cd70efeb312f861465e17940a3e3c7e0febc6 languageName: node linkType: hard +"@noble/hashes@npm:1.6.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0": + version: 1.6.0 + resolution: "@noble/hashes@npm:1.6.0" + checksum: 10/b44b043b02adbecd33596adeed97d9f9864c24a2410f7ac3b847986c2ecf1f6f0df76024b3f1b14d6ea954932960d88898fe551fb9d39844a8b870e9f9044ea1 + languageName: node + linkType: hard + "@noble/hashes@npm:~1.3.2": version: 1.3.3 resolution: "@noble/hashes@npm:1.3.3"