diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 9b27e131240..0238aebc637 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,8 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Derive auth and user-storage message-signing keys natively via SIP-6 from HD keyring seeds instead of calling `@metamask/message-signing-snap` through `SnapController`. `AuthenticationController` and `UserStorageController` now require `KeyringController:withKeyringV2Unsafe` and no longer call `SnapController:handleRequest`. The message-signing snap remains for Portfolio / external origins ([#9824](https://github.com/MetaMask/core/pull/9824)) + - Derive native SIP-6 keys with `@noble/hashes` HMAC-SHA-512 instead of Web Crypto, so auth works on React Native, whose SubtleCrypto cannot HMAC. - Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) +- Add `@metamask/key-tree` and `@noble/curves`; remove unused `@metamask/snaps-controllers`, `@metamask/snaps-sdk`, and `@metamask/snaps-utils` dependencies ([#9824](https://github.com/MetaMask/core/pull/9824)) ## [29.0.0] diff --git a/packages/profile-sync-controller/jest.config.js b/packages/profile-sync-controller/jest.config.js index d489f5f851e..e5ccac39389 100644 --- a/packages/profile-sync-controller/jest.config.js +++ b/packages/profile-sync-controller/jest.config.js @@ -18,9 +18,9 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 85.03, - functions: 92.75, - lines: 94.98, - statements: 95.03, + functions: 93.03, + lines: 95.15, + statements: 95.19, }, }, diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 17da311ac34..0a405f1d9ac 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -110,14 +110,13 @@ "dependencies": { "@metamask/address-book-controller": "^7.1.2", "@metamask/base-controller": "^9.1.0", + "@metamask/key-tree": "^10.1.1", "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/seedless-onboarding-controller": "^10.1.1", - "@metamask/snaps-controllers": "^19.0.0", - "@metamask/snaps-sdk": "^11.0.0", - "@metamask/snaps-utils": "^12.1.2", "@metamask/utils": "^11.11.0", "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0", "immer": "^9.0.6", "loglevel": "^1.8.1", @@ -127,6 +126,7 @@ "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-hd-keyring": "^15.0.0", "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-internal-api": "^12.0.0", "@metamask/providers": "^22.1.0", diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 77f44cef40d..9e46cd4033f 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -14,6 +14,10 @@ import { MOCK_ACCESS_JWT, MOCK_USER_PROFILE_LINEAGE_RESPONSE, } from '../../sdk/mocks/auth.js'; +import { + getMessageSigningPublicKey, + signMessageWithMessageSigningKey, +} from '../../shared/utils/message-signing.js'; import { AuthenticationController } from './AuthenticationController.js'; import type { AuthenticationControllerMessenger, @@ -25,6 +29,16 @@ import { MOCK_OATH_TOKEN_RESPONSE, } from './mocks/mockResponses.js'; +jest.mock('../../shared/utils/message-signing.js', () => ({ + MESSAGE_SIGNING_SNAP_ID: 'npm:@metamask/message-signing-snap', + getMessageSigningPublicKey: jest.fn(async () => 'MOCK_PUBLIC_KEY'), + signMessageWithMessageSigningKey: jest.fn(async () => 'MOCK_SIGNED_MESSAGE'), + deriveMessageSigningPrivateKey: jest.fn(), + deriveSip6PrivateKey: jest.fn(), +})); + +const MOCK_HD_SEED = new Uint8Array(64).fill(1); + const MOCK_ENTROPY_SOURCE_IDS = [ 'MOCK_ENTROPY_SOURCE_ID', 'MOCK_ENTROPY_SOURCE_ID2', @@ -138,7 +152,7 @@ describe('AuthenticationController', () => { it('should create access token(s) and update state', async () => { const metametrics = createMockAuthMetaMetrics(); const mockEndpoints = arrangeAuthAPIs(); - const { messenger, mockSnapGetPublicKey, mockSnapSignMessage } = + const { messenger, mockGetPublicKey, mockSignMessage } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ @@ -147,11 +161,11 @@ describe('AuthenticationController', () => { }); const result = await controller.performSignIn(); - // SRP enumeration uses KeyringController; snap is only needed for + // SRP enumeration uses KeyringController; native SIP-6 is used for // getPublicKey / signMessage during cold login. - expect(mockSnapGetPublicKey).toHaveBeenCalledTimes(2); + expect(mockGetPublicKey).toHaveBeenCalledTimes(2); // Primary and secondary tags produce distinct messages, so both are signed. - expect(mockSnapSignMessage).toHaveBeenCalledTimes(2); + expect(mockSignMessage).toHaveBeenCalledTimes(2); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -166,10 +180,10 @@ describe('AuthenticationController', () => { } }); - it('leverages the _snapSignMessageCache', async () => { + it('leverages the signMessage cache', async () => { const metametrics = createMockAuthMetaMetrics(); const mockEndpoints = arrangeAuthAPIs(); - const { messenger, mockSnapSignMessage } = + const { messenger, mockSignMessage } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ @@ -181,7 +195,7 @@ describe('AuthenticationController', () => { controller.performSignOut(); await controller.performSignIn(); // Both tagged login messages are cached across sign-out / sign-in. - expect(mockSnapSignMessage).toHaveBeenCalledTimes(2); + expect(mockSignMessage).toHaveBeenCalledTimes(2); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -194,7 +208,7 @@ describe('AuthenticationController', () => { it('signs primary and secondary login tags for multi-SRP wallets', async () => { const metametrics = createMockAuthMetaMetrics(); arrangeAuthAPIs(); - const { messenger, mockSnapSignMessage } = + const { messenger, mockSignMessage } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ @@ -204,8 +218,8 @@ describe('AuthenticationController', () => { await controller.performSignIn(); - const signedMessages = mockSnapSignMessage.mock.calls.map( - (call) => (call[0] as { message: string }).message, + const signedMessages = mockSignMessage.mock.calls.map( + (call) => call[0] as string, ); expect(signedMessages).toStrictEqual( expect.arrayContaining([ @@ -220,7 +234,7 @@ describe('AuthenticationController', () => { arrangeAuthAPIs(); const { messenger, - mockSnapSignMessage, + mockSignMessage, mockKeyringControllerGetState, mockSeedlessOnboardingGetState, } = createMockAuthenticationMessenger(); @@ -240,10 +254,9 @@ describe('AuthenticationController', () => { await controller.performSignIn(); - expect(mockSnapSignMessage).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/^metamask:[^:]+:[^:]+:primary$/u), - }), + expect(mockSignMessage).toHaveBeenCalledWith( + expect.stringMatching(/^metamask:[^:]+:[^:]+:primary$/u), + MOCK_HD_SEED, ); }); @@ -1052,9 +1065,9 @@ describe('AuthenticationController', () => { expect(resultUndefined).toBe(resultExplicit); }); - it('resolves primary entropySourceId from the HD keyring without the snap', async () => { + it('resolves primary entropySourceId from the HD keyring without signing', async () => { const metametrics = createMockAuthMetaMetrics(); - const { messenger, mockSnapGetPublicKey, mockKeyringControllerGetState } = + const { messenger, mockGetPublicKey, mockKeyringControllerGetState } = createMockAuthenticationMessenger(); const originalState = mockSignedInState(); const controller = new AuthenticationController({ @@ -1067,8 +1080,8 @@ describe('AuthenticationController', () => { await controller.getBearerToken(); await controller.getBearerToken(); - // Cached session: no snap identify/sign; only keyring for primary ID. - expect(mockSnapGetPublicKey).not.toHaveBeenCalled(); + // Cached session: no identify/sign; only keyring for primary ID. + expect(mockGetPublicKey).not.toHaveBeenCalled(); expect(mockKeyringControllerGetState).toHaveBeenCalled(); }); @@ -1649,7 +1662,7 @@ function createAuthenticationMessenger(): { messenger, actions: [ 'KeyringController:getState', - 'SnapController:handleRequest', + 'KeyringController:withKeyringV2Unsafe', 'SeedlessOnboardingController:getState', ], events: ['KeyringController:lock', 'KeyringController:unlock'], @@ -1666,50 +1679,58 @@ function createAuthenticationMessenger(): { function createMockAuthenticationMessenger(): { messenger: AuthenticationControllerMessenger; baseMessenger: RootMessenger; - mockSnapGetPublicKey: jest.Mock; - mockSnapSignMessage: jest.Mock; + mockGetPublicKey: jest.Mock; + mockSignMessage: jest.Mock; mockKeyringControllerGetState: jest.Mock; + mockWithKeyringV2Unsafe: jest.Mock; mockSeedlessOnboardingGetState: jest.Mock; } { const { baseMessenger, messenger } = createAuthenticationMessenger(); const mockCall = jest.spyOn(messenger, 'call'); - const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); - const mockSnapSignMessage = jest - .fn() - .mockResolvedValue('MOCK_SIGNED_MESSAGE'); + const mockGetPublicKey = jest.mocked(getMessageSigningPublicKey); + const mockSignMessage = jest.mocked(signMessageWithMessageSigningKey); + mockGetPublicKey.mockReset().mockResolvedValue('MOCK_PUBLIC_KEY'); + mockSignMessage.mockReset().mockResolvedValue('MOCK_SIGNED_MESSAGE'); const mockKeyringControllerGetState = jest.fn().mockReturnValue({ isUnlocked: true, keyrings: MOCK_HD_KEYRINGS, }); + const mockWithKeyringV2Unsafe = jest + .fn() + .mockImplementation( + async ( + _selector: { id: string }, + operation: (context: { + keyring: { type: string; seed?: Uint8Array }; + metadata: { id: string; name: string }; + }) => Promise, + ) => { + return operation({ + keyring: { type: 'hd', seed: MOCK_HD_SEED }, + metadata: { id: 'mock', name: '' }, + }); + }, + ); + const mockSeedlessOnboardingGetState = jest .fn() .mockReturnValue({ vault: null }); - mockCall.mockImplementation((...args) => { - const [actionType, params] = args; - if (actionType === 'SnapController:handleRequest') { - if (typeof params === 'string') { - throw new Error( - `MOCK_FAIL - unsupported SnapController:handleRequest call: ${params}`, - ); - } - - if (params?.request.method === 'getPublicKey') { - return mockSnapGetPublicKey(); - } - - if (params?.request.method === 'signMessage') { - return mockSnapSignMessage(params.request.params); - } - - throw new Error( - `MOCK_FAIL - unsupported SnapController:handleRequest call: ${ - params?.request.method as string - }`, - ); + mockCall.mockImplementation((...args: unknown[]) => { + const [actionType] = args; + if (actionType === 'KeyringController:withKeyringV2Unsafe') { + const [, selector, operation] = args as [ + typeof actionType, + { id: string }, + (context: { + keyring: { type: string; seed?: Uint8Array }; + metadata: { id: string; name: string }; + }) => Promise, + ]; + return mockWithKeyringV2Unsafe(selector, operation); } if (actionType === 'KeyringController:getState') { @@ -1728,9 +1749,10 @@ function createMockAuthenticationMessenger(): { return { messenger, baseMessenger, - mockSnapGetPublicKey, - mockSnapSignMessage, + mockGetPublicKey, + mockSignMessage, mockKeyringControllerGetState, + mockWithKeyringV2Unsafe, mockSeedlessOnboardingGetState, }; } @@ -1745,13 +1767,7 @@ function createMockAuthenticationMessenger(): { function mockAuthenticationFlowEndpoints(params?: { endpointFail: 'nonce' | 'login' | 'token' | 'lineage' | 'customerService'; }): ReturnType { - const { - mockNonceUrl, - mockOAuth2TokenUrl, - mockSrpLoginUrl, - mockUserProfileLineageUrl, - mockCustomerServiceTokenUrl, - } = arrangeAuthAPIs({ + return arrangeAuthAPIs({ mockNonceUrl: params?.endpointFail === 'nonce' ? { status: 500 } : undefined, mockSrpLoginUrl: @@ -1763,14 +1779,6 @@ function mockAuthenticationFlowEndpoints(params?: { mockCustomerServiceTokenUrl: params?.endpointFail === 'customerService' ? { status: 500 } : undefined, }); - - return { - mockNonceUrl, - mockOAuth2TokenUrl, - mockSrpLoginUrl, - mockUserProfileLineageUrl, - mockCustomerServiceTokenUrl, - }; } /** diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index c9e3d4d1276..1a9eb824b5e 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -8,10 +8,10 @@ import type { KeyringControllerGetStateAction, KeyringControllerLockEvent, KeyringControllerUnlockEvent, + KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { SeedlessOnboardingControllerGetStateAction } from '@metamask/seedless-onboarding-controller'; -import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { Json } from '@metamask/utils'; import type { @@ -34,10 +34,11 @@ import { getHdKeyringEntropySourceIds, getPrimaryHdKeyringEntropySourceId, } from '../../shared/utils/entropy-source.js'; +import { getHdKeyringSeed } from '../../shared/utils/hd-keyring-seed.js'; import { - createSnapPublicKeyRequest, - createSnapSignMessageRequest, -} from './auth-snap-requests.js'; + getMessageSigningPublicKey, + signMessageWithMessageSigningKey, +} from '../../shared/utils/message-signing.js'; import { AuthenticationControllerMethodActions } from './AuthenticationController-method-action-types.js'; const controllerName = 'AuthenticationController'; @@ -155,7 +156,7 @@ export type Events = // Allowed Actions type AllowedActions = | KeyringControllerGetStateAction - | SnapControllerHandleRequestAction + | KeyringControllerWithKeyringV2UnsafeAction | SeedlessOnboardingControllerGetStateAction; type AllowedEvents = KeyringControllerLockEvent | KeyringControllerUnlockEvent; @@ -251,8 +252,8 @@ export class AuthenticationController extends BaseController< setLoginResponse: this.#setLoginResponseToState.bind(this), }, signing: { - getIdentifier: this.#snapGetPublicKey.bind(this), - signMessage: this.#snapSignMessage.bind(this), + getIdentifier: this.#getPublicKey.bind(this), + signMessage: this.#signMessage.bind(this), }, getLoginTag: this.#getLoginTag.bind(this), getLoginIdentifierType: this.#getLoginIdentifierType.bind(this), @@ -680,51 +681,78 @@ export class AuthenticationController extends BaseController< } /** - * Returns the auth snap public key. + * Reads the BIP-39 seed for an HD entropy source from KeyringController. + * + * @param entropySourceId - Entropy source ID. Defaults to the primary HD + * keyring. + * @returns The HD keyring seed. + */ + async #getHdKeyringSeed(entropySourceId?: string): Promise { + const resolvedId = entropySourceId ?? this.#getPrimaryEntropySourceId(); + return getHdKeyringSeed(this.messenger, resolvedId); + } + + /** + * Returns the message-signing public key via native SIP-6 derivation + * (same key as `@metamask/message-signing-snap` with empty salt). * * @param entropySourceId - The entropy source ID used to derive the key, * when multiple sources are available (Multi-SRP). - * @returns The snap public key. + * @returns The public key hex. */ - async #snapGetPublicKey(entropySourceId?: string): Promise { - this.#assertIsUnlocked('#snapGetPublicKey'); + async #getPublicKey(entropySourceId?: string): Promise { + this.#assertIsUnlocked('#getPublicKey'); + const seed = await this.#getHdKeyringSeed(entropySourceId); + return getMessageSigningPublicKey(seed); + } - const result = (await this.messenger.call( - 'SnapController:handleRequest', - createSnapPublicKeyRequest(entropySourceId), - )) as string; + #_signMessageCache: Record = {}; - return result; + /** + * Builds a cache key scoped to a specific entropy source, so each SRP's + * signature stays isolated (same pattern as `UserStorageController`). + * + * When `entropySourceId` is omitted (primary SRP), it is resolved to the + * primary HD keyring's metadata ID rather than a stable literal. Because that + * ID is randomly regenerated whenever the vault is recreated (e.g. on + * restore), the cached entry is naturally invalidated across vaults — a + * different SRP can never inherit the previous primary's cached signature. + * + * @param message - The tagged message used for signing. + * @param entropySourceId - The entropy source ID. Omit for the primary SRP. + * @returns The scoped cache key. + */ + #scopedCacheKey( + message: `metamask:${string}`, + entropySourceId?: string, + ): string { + return `${entropySourceId ?? this.#getPrimaryEntropySourceId()}:${message}`; } - #_snapSignMessageCache: Record<`metamask:${string}`, string> = {}; - /** - * Signs a specific message using an underlying auth snap. + * Signs a `metamask:…` message with the native SIP-6 message-signing key. * * @param message - A specific tagged message to sign. * @param entropySourceId - The entropy source ID used to derive the key, * when multiple sources are available (Multi-SRP). - * @returns A Signature created by the snap. + * @returns Compact secp256k1 signature hex. */ - async #snapSignMessage( + async #signMessage( message: string, entropySourceId?: string, ): Promise { assertMessageStartsWithMetamask(message); + this.#assertIsUnlocked('#signMessage'); - if (this.#_snapSignMessageCache[message]) { - return this.#_snapSignMessageCache[message]; + const cacheKey = this.#scopedCacheKey(message, entropySourceId); + if (this.#_signMessageCache[cacheKey]) { + return this.#_signMessageCache[cacheKey]; } - this.#assertIsUnlocked('#snapSignMessage'); - - const result = (await this.messenger.call( - 'SnapController:handleRequest', - createSnapSignMessageRequest(message, entropySourceId), - )) as string; + const seed = await this.#getHdKeyringSeed(entropySourceId); + const result = await signMessageWithMessageSigningKey(message, seed); - this.#_snapSignMessageCache[message] = result; + this.#_signMessageCache[cacheKey] = result; return result; } 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 deleted file mode 100644 index 325669fa75b..00000000000 --- a/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; -import type { SnapId } from '@metamask/snaps-sdk'; - -type SnapRPCRequest = Parameters< - SnapControllerHandleRequestAction['handler'] ->[0]; - -const snapId = 'npm:@metamask/message-signing-snap' as SnapId; - -/** - * Constructs Request to Message Signing Snap to get Public Key - * - * @param entropySourceId - The source of entropy to use for key generation, - * when multiple sources are available (Multi-SRP). - * @returns Snap Public Key Request - */ -export function createSnapPublicKeyRequest( - entropySourceId?: string, -): SnapRPCRequest { - return { - snapId, - origin: 'metamask', - handler: 'onRpcRequest' as any, - request: { - method: 'getPublicKey', - ...(entropySourceId ? { params: { entropySourceId } } : {}), - }, - }; -} - -/** - * Constructs Request to get Message Signing Snap to sign a message. - * - * @param message - message to sign - * @param entropySourceId - The source of entropy to use for key generation, - * when multiple sources are available (Multi-SRP). - * @returns Snap Sign Message Request - */ -export function createSnapSignMessageRequest( - message: `metamask:${string}`, - entropySourceId?: string, -): SnapRPCRequest { - return { - snapId, - origin: 'metamask', - handler: 'onRpcRequest' as any, - request: { - method: 'signMessage', - params: { message, ...(entropySourceId ? { entropySourceId } : {}) }, - }, - }; -} 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 829c450ef21..d8c163801ac 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 @@ -26,6 +26,14 @@ import { defaultState, } from './UserStorageController.js'; +jest.mock('../../shared/utils/message-signing.js', () => ({ + MESSAGE_SIGNING_SNAP_ID: 'npm:@metamask/message-signing-snap', + getMessageSigningPublicKey: jest.fn(async () => 'MOCK_PUBLIC_KEY'), + signMessageWithMessageSigningKey: jest.fn(async () => 'mockStorageKey'), + deriveMessageSigningPrivateKey: jest.fn(), + deriveSip6PrivateKey: jest.fn(), +})); + describe('UserStorageController', () => { describe('constructor', () => { const arrangeMocks = () => { @@ -712,7 +720,7 @@ describe('UserStorageController', () => { }); }); - describe('snap handling', () => { + describe('message signing', () => { it('leverages a cache', async () => { const messengerMocks = mockUserStorageMessenger(); const controller = new UserStorageController({ @@ -729,7 +737,7 @@ describe('UserStorageController', () => { // The signed message (`metamask:${profileId}`) is identical across both // calls, so the only thing that can isolate the two vaults is the entropy // scope. The HD keyring metadata id is randomly regenerated on restore. - messengerMocks.mockSnapSignMessage + messengerMocks.mockSignMessage .mockResolvedValueOnce('signature-before-restore') .mockResolvedValueOnce('signature-after-restore'); @@ -756,7 +764,7 @@ describe('UserStorageController', () => { // The regenerated id changes the cache scope, so the new primary must // re-derive its own key instead of inheriting the previous vault's cached // key — proving no `'primary'`-style stable key carries across restores. - expect(messengerMocks.mockSnapSignMessage).toHaveBeenCalledTimes(2); + expect(messengerMocks.mockSignMessage).toHaveBeenCalledTimes(2); expect(keyAfterRestore).not.toBe(keyBeforeRestore); expect(keyAfterRestore).toBe(createSHA256Hash('signature-after-restore')); }); @@ -787,7 +795,7 @@ describe('UserStorageController', () => { mockAPI1.done(); mockAPI2.done(); - expect(messengerMocks.mockSnapSignMessage).toHaveBeenCalledTimes(1); + expect(messengerMocks.mockSignMessage).toHaveBeenCalledTimes(1); }); it('derives a distinct storage key per entropy source even when both resolve to the same profileId', async () => { @@ -808,7 +816,7 @@ describe('UserStorageController', () => { }); // Each entropy source signs with its own key, so the identical message // yields a different signature — and thus a different derived storage key. - messengerMocks.mockSnapSignMessage + messengerMocks.mockSignMessage .mockResolvedValueOnce('signature-for-entropy-source-1') .mockResolvedValueOnce('signature-for-entropy-source-2'); @@ -841,7 +849,7 @@ describe('UserStorageController', () => { // storage keys despite the shared profileId. mockSource1.done(); mockSource2.done(); - expect(messengerMocks.mockSnapSignMessage).toHaveBeenCalledTimes(2); + expect(messengerMocks.mockSignMessage).toHaveBeenCalledTimes(2); }); it('throws if the wallet is locked', async () => { @@ -855,7 +863,7 @@ describe('UserStorageController', () => { }); await expect(controller.getStorageKey()).rejects.toThrow( - '#snapSignMessage - unable to call snap, wallet is locked', + '#signMessage - unable to proceed, wallet is locked', ); await expect(controller.listEntropySources()).rejects.toThrow( 'listEntropySources - unable to list entropy sources, wallet is locked', @@ -883,7 +891,7 @@ describe('UserStorageController', () => { messengerMocks.baseMessenger.publish('KeyringController:lock'); await expect(controller.getStorageKey()).rejects.toThrow( - '#snapSignMessage - unable to call snap, wallet is locked', + '#signMessage - unable to proceed, wallet is locked', ); messengerMocks.baseMessenger.publish('KeyringController:unlock'); 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 8271ae76469..e58d9923d9c 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -21,9 +21,9 @@ import type { KeyringControllerGetStateAction, KeyringControllerLockEvent, KeyringControllerUnlockEvent, + KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; -import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { UserStorageGenericFeatureKey, @@ -37,7 +37,8 @@ import { getPrimaryHdKeyringEntropySourceId, } from '../../shared/utils/entropy-source.js'; import { EventQueue } from '../../shared/utils/event-queue.js'; -import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests.js'; +import { getHdKeyringSeed } from '../../shared/utils/hd-keyring-seed.js'; +import { signMessageWithMessageSigningKey } from '../../shared/utils/message-signing.js'; import type { AuthenticationControllerGetBearerTokenAction, AuthenticationControllerGetSessionProfileAction, @@ -169,8 +170,7 @@ export type Actions = export type AllowedActions = // Keyring Requests | KeyringControllerGetStateAction - // Snap Requests - | SnapControllerHandleRequestAction + | KeyringControllerWithKeyringV2UnsafeAction // Auth Requests | AuthenticationControllerGetBearerTokenAction | AuthenticationControllerGetSessionProfileAction @@ -251,7 +251,7 @@ export class UserStorageController extends BaseController< // signature and leak data across each other's user storage. #storageKeyCache: Record = {}; - #snapSignMessageCache: Record = {}; + #signMessageCache: Record = {}; readonly #keyringController = { setupLockedStateSubscriptions: () => { @@ -324,10 +324,7 @@ export class UserStorageController extends BaseController< ); }, signMessage: (message: string, entropySourceId?: string) => - this.#snapSignMessage( - message as `metamask:${string}`, - entropySourceId, - ), + this.#signMessage(message, entropySourceId), }, }, { @@ -583,34 +580,35 @@ export class UserStorageController extends BaseController< } /** - * Signs a specific message using an underlying auth snap. + * Signs a `metamask:…` message with the native SIP-6 message-signing key + * (same key as `@metamask/message-signing-snap` with empty salt). * * @param message - A specific tagged message to sign. * @param entropySourceId - The entropy source ID used to derive the key, * when multiple sources are available (Multi-SRP). - * @returns A Signature created by the snap. + * @returns Compact secp256k1 signature hex. */ - async #snapSignMessage( - message: `metamask:${string}`, + async #signMessage( + message: string, entropySourceId?: string, ): Promise { if (!this.#isUnlocked) { - throw new Error( - '#snapSignMessage - unable to call snap, wallet is locked', - ); + throw new Error('#signMessage - unable to proceed, wallet is locked'); } - const cacheKey = this.#scopedCacheKey(message, entropySourceId); - if (this.#snapSignMessageCache[cacheKey]) { - return this.#snapSignMessageCache[cacheKey]; + const cacheKey = this.#scopedCacheKey( + message as `metamask:${string}`, + entropySourceId, + ); + if (this.#signMessageCache[cacheKey]) { + return this.#signMessageCache[cacheKey]; } - const result = (await this.messenger.call( - 'SnapController:handleRequest', - createSnapSignMessageRequest(message, entropySourceId), - )) as string; + const resolvedId = entropySourceId ?? this.#getPrimaryEntropySourceId(); + const seed = await getHdKeyringSeed(this.messenger, resolvedId); + const result = await signMessageWithMessageSigningKey(message, seed); - this.#snapSignMessageCache[cacheKey] = result; + this.#signMessageCache[cacheKey] = result; return result; } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts index 69b42f9566d..e18212b3fbc 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts @@ -7,6 +7,7 @@ import type { NotNamespacedBy, } from '@metamask/messenger'; +import { signMessageWithMessageSigningKey } from '../../../shared/utils/message-signing.js'; import { MOCK_LOGIN_RESPONSE } from '../../authentication/mocks/index.js'; import type { AllowedActions, @@ -15,6 +16,8 @@ import type { } from '../index.js'; import { MOCK_STORAGE_KEY_SIGNATURE } from '../mocks/index.js'; +const MOCK_HD_SEED = new Uint8Array(64).fill(1); + const controllerName = 'UserStorageController'; type GetHandler = Extract< @@ -83,7 +86,7 @@ export function createCustomUserStorageMessenger(props?: { messenger, actions: [ 'KeyringController:getState', - 'SnapController:handleRequest', + 'KeyringController:withKeyringV2Unsafe', 'AuthenticationController:getBearerToken', 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', @@ -120,10 +123,8 @@ export function mockUserStorageMessenger( const { baseMessenger, messenger } = overrideMessengers ?? createCustomUserStorageMessenger(); - const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); - const mockSnapSignMessage = jest - .fn() - .mockResolvedValue(MOCK_STORAGE_KEY_SIGNATURE); + const mockSignMessage = jest.mocked(signMessageWithMessageSigningKey); + mockSignMessage.mockReset().mockResolvedValue(MOCK_STORAGE_KEY_SIGNATURE); const mockAuthGetBearerToken = typedMockFn( 'AuthenticationController:getBearerToken', @@ -162,27 +163,32 @@ export function mockUserStorageMessenger( ], }); + const mockWithKeyringV2Unsafe = jest + .fn() + .mockImplementation( + async ( + _selector: { id: string }, + operation: (context: { + keyring: { type: string; seed?: Uint8Array }; + metadata: { id: string; name: string }; + }) => Promise, + ) => { + return operation({ + keyring: { type: 'hd', seed: MOCK_HD_SEED }, + metadata: { id: 'mock', name: '' }, + }); + }, + ); + const mockAccountsListAccounts = jest.fn(); - jest.spyOn(messenger, 'call').mockImplementation((...args) => { + jest.spyOn(messenger, 'call').mockImplementation((...args: unknown[]) => { const typedArgs = args as unknown as CallParams; const [actionType] = typedArgs; - if (actionType === 'SnapController:handleRequest') { - const [, params] = typedArgs; - 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 as string - }`, - ); + if (actionType === 'KeyringController:withKeyringV2Unsafe') { + const [, selector, operation] = typedArgs; + return mockWithKeyringV2Unsafe(selector, operation); } if (actionType === 'AuthenticationController:getBearerToken') { @@ -213,8 +219,7 @@ export function mockUserStorageMessenger( return { baseMessenger, messenger, - mockSnapGetPublicKey, - mockSnapSignMessage, + mockSignMessage, mockAuthGetBearerToken, mockAuthGetSessionProfile, mockAuthPerformSignIn, @@ -223,6 +228,7 @@ export function mockUserStorageMessenger( mockKeyringAddAccounts, mockKeyringGetState, mockWithKeyringSelector, + mockWithKeyringV2Unsafe, mockAccountsListAccounts, }; } diff --git a/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.test.ts b/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.test.ts new file mode 100644 index 00000000000..f0e42c063fe --- /dev/null +++ b/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.test.ts @@ -0,0 +1,39 @@ +import { KeyringType } from '@metamask/keyring-api/v2'; + +import { getHdKeyringSeed } from './hd-keyring-seed.js'; + +describe('getHdKeyringSeed', () => { + it('returns the HD keyring seed for a matching entropy source id', async () => { + const seed = new Uint8Array(64).fill(7); + const messenger = { + call: jest.fn(async (_action, _selector, operation) => + operation({ + keyring: { type: KeyringType.Hd, seed }, + metadata: { id: 'entropy-1', name: '' }, + }), + ), + }; + + expect(await getHdKeyringSeed(messenger, 'entropy-1')).toBe(seed); + expect(messenger.call).toHaveBeenCalledWith( + 'KeyringController:withKeyringV2Unsafe', + { id: 'entropy-1' }, + expect.any(Function), + ); + }); + + it('throws when the keyring is not an HD keyring with a seed', async () => { + const messenger = { + call: jest.fn(async (_action, _selector, operation) => + operation({ + keyring: { type: KeyringType.Snap }, + metadata: { id: 'missing', name: '' }, + }), + ), + }; + + await expect(getHdKeyringSeed(messenger, 'missing')).rejects.toThrow( + 'Entropy source not found or is not an HD keyring.', + ); + }); +}); diff --git a/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.ts b/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.ts new file mode 100644 index 00000000000..f4a214ed821 --- /dev/null +++ b/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.ts @@ -0,0 +1,76 @@ +import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import type { KeyringType } from '@metamask/keyring-api/v2'; +import type { KeyringControllerWithKeyringV2UnsafeAction } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; + +/** + * HD keyring seed access for native SIP-6 message signing. + * + * This mirrors `@metamask/snaps-rpc-methods` `getMnemonicSeed` (when called + * with an entropy source id): + * `KeyringController:withKeyringV2Unsafe` → assert HD → return `keyring.seed`. + * + * Those helpers are not a public export of snaps-rpc-methods, so this thin wrapper lives here. + * + * @see https://github.com/MetaMask/snaps/blob/main/packages/snaps-rpc-methods/src/utils.ts + */ + +/** + * V2 HD keyring type (`KeyringType.Hd`). The template type keeps the literal + * aligned with `@metamask/keyring-api/v2` without a runtime dependency. + */ +const HD_KEYRING_TYPE: `${KeyringType.Hd}` = 'hd'; + +const ENTROPY_SOURCE_NOT_FOUND_ERROR = + 'Entropy source not found or is not an HD keyring.'; + +/** + * Structural messenger shape for `withKeyringV2Unsafe`. + * + * Not `Messenger` because + * `Messenger` is invariant in its action union — Auth / UserStorage messengers + * (which allow additional actions) are not assignable to that narrow type. + */ +type MessengerWithKeyringV2Unsafe = { + call: ( + ...args: Parameters< + Messenger['call'] + > + ) => unknown; +}; + +/** + * Reads the BIP-39 seed for an HD keyring entropy source via + * `KeyringController:withKeyringV2Unsafe`. + * + * Equivalent to snaps-rpc-methods `getMnemonicSeed(messenger, source)` for a + * concrete entropy source id. + * + * @param messenger - Messenger that can call `withKeyringV2Unsafe`. + * @param entropySourceId - Keyring metadata ID (SIP-30 entropy source). + * @returns The HD keyring seed. + * @throws If the keyring is missing or is not an HD keyring with a seed. + */ +export async function getHdKeyringSeed( + messenger: MessengerWithKeyringV2Unsafe, + entropySourceId: string, +): Promise { + try { + const keyringData = (await messenger.call( + 'KeyringController:withKeyringV2Unsafe', + { id: entropySourceId }, + async ({ keyring }) => { + const hdKeyring = keyring as HdKeyring; + return { type: hdKeyring.type, seed: hdKeyring.seed }; + }, + )) as { type: string; seed?: Uint8Array | null }; + + if (keyringData.type !== HD_KEYRING_TYPE || !keyringData.seed) { + throw new Error(ENTROPY_SOURCE_NOT_FOUND_ERROR); + } + + return keyringData.seed; + } catch { + throw new Error(ENTROPY_SOURCE_NOT_FOUND_ERROR); + } +} diff --git a/packages/profile-sync-controller/src/shared/utils/message-signing.test.ts b/packages/profile-sync-controller/src/shared/utils/message-signing.test.ts new file mode 100644 index 00000000000..9134932423b --- /dev/null +++ b/packages/profile-sync-controller/src/shared/utils/message-signing.test.ts @@ -0,0 +1,118 @@ +import { bytesToHex } from '@metamask/utils'; + +import { + deriveMessageSigningPrivateKey, + deriveSip6PrivateKey, + getMessageSigningPublicKey, + MESSAGE_SIGNING_SNAP_ID, + signMessageWithMessageSigningKey, +} from './message-signing.js'; + +// Same seed as `@metamask/snaps-utils` TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES +// (`test test test test test test test test test test test ball`). +const TEST_SEED = new Uint8Array([ + 44, 232, 45, 62, 149, 146, 73, 117, 90, 217, 78, 33, 68, 145, 185, 177, 102, + 61, 41, 58, 21, 196, 248, 21, 155, 72, 140, 191, 191, 66, 144, 46, 47, 188, + 165, 16, 149, 48, 252, 179, 255, 31, 120, 228, 174, 203, 27, 194, 102, 9, 173, + 1, 47, 174, 216, 184, 227, 85, 112, 105, 241, 209, 73, 65, +]); + +// From `@metamask/snaps-rpc-methods` SIP-6 ENTROPY_VECTORS. +const SIP6_VECTORS = [ + { + snapId: 'foo', + entropy: + '0x8bbb59ec55a4a8dd5429268e367ebbbe54eee7467c0090ca835c64d45c33a155', + }, + { + snapId: 'bar', + entropy: + '0xbdae5c0790d9189d8ae27fd4860b3b57bab420b6594c420ae9ae3a9f87c1ea14', + }, + { + snapId: 'foo', + salt: 'bar', + entropy: + '0x59cbec1fa877ecb38d88c3a2326b23bff374954b39ad9482c9b082306ac4b3ad', + }, + { + snapId: 'bar', + salt: 'baz', + entropy: + '0x814c1f121eb4067d1e1d177246461e8a1cc6a1b1152756737aba7fa9c2161ba2', + }, +] as const; + +describe('message-signing SIP-6 helpers', () => { + it('exports the message-signing snap ID used as SIP-6 input', () => { + expect(MESSAGE_SIGNING_SNAP_ID).toBe('npm:@metamask/message-signing-snap'); + }); + + it.each(SIP6_VECTORS)( + 'matches SIP-6 entropy vector for snapId=$snapId salt=$salt', + async ({ snapId, salt, entropy }) => { + const privateKey = await deriveSip6PrivateKey({ + seed: TEST_SEED, + input: snapId, + salt, + }); + expect(bytesToHex(privateKey)).toBe(entropy); + }, + ); + + it('derives a stable public key for the message-signing snap id', async () => { + const publicKey = await getMessageSigningPublicKey(TEST_SEED); + expect(publicKey).toMatch(/^0x[0-9a-f]{66}$/u); + + const again = await getMessageSigningPublicKey(TEST_SEED); + expect(again).toBe(publicKey); + }); + + it('signs metamask messages with a compact secp256k1 signature', async () => { + const signature = await signMessageWithMessageSigningKey( + 'metamask:test', + TEST_SEED, + ); + expect(signature).toMatch(/^0x[0-9a-f]{128}$/u); + + const again = await signMessageWithMessageSigningKey( + 'metamask:test', + TEST_SEED, + ); + expect(again).toBe(signature); + }); + + it('uses empty salt by default (internal metamask origin parity)', async () => { + const withDefaultSalt = await deriveMessageSigningPrivateKey(TEST_SEED); + const withExplicitEmptySalt = await deriveMessageSigningPrivateKey( + TEST_SEED, + '', + ); + expect(bytesToHex(withDefaultSalt)).toBe(bytesToHex(withExplicitEmptySalt)); + }); + + it('derives SIP-6 entropy when crypto.subtle exists without importKey', async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { subtle: { digest: async () => new ArrayBuffer(0) } }, + }); + + try { + const privateKey = await deriveSip6PrivateKey({ + seed: TEST_SEED, + input: 'foo', + }); + expect(bytesToHex(privateKey)).toBe(SIP6_VECTORS[0].entropy); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'crypto'); + } + } + }); +}); diff --git a/packages/profile-sync-controller/src/shared/utils/message-signing.ts b/packages/profile-sync-controller/src/shared/utils/message-signing.ts new file mode 100644 index 00000000000..12cd67e8507 --- /dev/null +++ b/packages/profile-sync-controller/src/shared/utils/message-signing.ts @@ -0,0 +1,185 @@ +import type { HardenedBIP32Node } from '@metamask/key-tree'; +import { SLIP10Node } from '@metamask/key-tree'; +import { + assert, + bytesToHex, + concatBytes, + createDataView, + hexToBytes, + stringToBytes, +} from '@metamask/utils'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hmac } from '@noble/hashes/hmac'; +import { sha256, sha512 } from '@noble/hashes/sha2'; +import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; + +/** + * Native SIP-6 message-signing helpers for AuthenticationController / + * UserStorageController. + * + * These are intentional copies of the message-signing snap crypto path so auth + * and user-storage can derive/sign without booting + * `npm:@metamask/message-signing-snap`. Behavior must stay byte-identical to: + * + * - SIP-6 derivation: + * `@metamask/snaps-rpc-methods` `deriveEntropyFromSeed` / + * `getEntropyDerivationPath` / `getDerivationPathArray` + * (https://github.com/MetaMask/snaps/blob/main/packages/snaps-rpc-methods/src/utils.ts) + * - Magic constant: + * `@metamask/snaps-utils` `SIP_6_MAGIC_VALUE` + * - Pubkey + `metamask:…` signing: + * `@metamask/message-signing-snap` `getPublicEntropyKey` / + * `signMessageWithEntropyKey` + * (https://github.com/MetaMask/message-signing-snap/blob/main/src/entropy-keys.ts) + * + * `deriveEntropyFromSeed` is not a public export of + * `@metamask/snaps-rpc-methods` today (and no core package depends on that + * package), so the SIP-6 math is vendored here rather than imported. + */ + +/** + * Snap ID used as the SIP-6 `input` so derived keys match + * `@metamask/message-signing-snap` (`snap_getEntropy` origin). + */ +export const MESSAGE_SIGNING_SNAP_ID = 'npm:@metamask/message-signing-snap'; + +/** + * Copy of `@metamask/snaps-utils` `SIP_6_MAGIC_VALUE` + * (`0xd36e6170 - 0x80000000`). + * + * @see https://metamask.github.io/SIPs/SIPS/sip-6 + */ +const SIP_6_MAGIC_VALUE = `1399742832'` as `${number}'`; + +const HARDENED_VALUE = 0x80000000; + +/** + * HMAC-SHA-512 for `@metamask/key-tree`. + * + * Passed into `SLIP10Node.fromSeed` so SIP-6 never uses Web Crypto. + * `@metamask/key-tree` treats any `crypto.subtle` as complete and then HMAC + * via `importKey` / `sign`. React Native only implements `digest`; its + * SubtleCrypto cannot HMAC. Noble HMAC is byte-identical without SubtleCrypto. + */ +const NOBLE_HMAC_SHA512 = { + hmacSha512: async (key: Uint8Array, data: Uint8Array): Promise => + hmac(sha512, key, data), +}; + +/** + * Copy of `@metamask/snaps-rpc-methods` `getDerivationPathArray`. + * + * Maps a 32-byte hash to eight hardened BIP-32 indices for `@metamask/key-tree`. + * + * @param hash - 32-byte hash. + * @returns Hardened BIP-32 path nodes. + */ +function getDerivationPathArray(hash: Uint8Array): HardenedBIP32Node[] { + const array: HardenedBIP32Node[] = []; + const view = createDataView(hash); + + for (let index = 0; index < 8; index++) { + const uint32 = view.getUint32(index * 4); + // eslint-disable-next-line no-bitwise + const pathIndex = (uint32 | HARDENED_VALUE) >>> 0; + array.push(`bip32:${pathIndex - HARDENED_VALUE}'` as const); + } + + return array; +} + +/** + * Copy of `@metamask/snaps-rpc-methods` `deriveEntropyFromSeed` (SIP-6), + * returning raw private-key bytes instead of a `0x`-prefixed hex string. + * + * @param options - Derivation options. + * @param options.seed - BIP-39 mnemonic seed. + * @param options.input - SIP-6 input (snap ID for `snap_getEntropy`). + * @param options.salt - Optional salt. Internal auth uses `''`. + * @returns 32-byte private key. + */ +export async function deriveSip6PrivateKey({ + seed, + input, + salt = '', +}: { + seed: Uint8Array; + input: string; + salt?: string; +}): Promise { + const hash = keccak256( + concatBytes([stringToBytes(input), keccak256(stringToBytes(salt))]), + ); + const computedDerivationPath = getDerivationPathArray(hash); + + const { privateKey } = await SLIP10Node.fromSeed( + { + derivationPath: [ + seed, + `bip32:${SIP_6_MAGIC_VALUE}`, + ...computedDerivationPath, + ], + curve: 'secp256k1', + }, + NOBLE_HMAC_SHA512, + ); + + assert(privateKey, 'Failed to derive SIP-6 entropy.'); + return hexToBytes(privateKey); +} + +/** + * Derives the message-signing private key via SIP-6, matching + * `snap_getEntropy` for the message-signing snap with empty salt + * (internal `metamask` origin). + * + * @param seed - BIP-39 mnemonic seed from an HD keyring. + * @param salt - Optional SIP-6 salt. Auth / user-storage use `''`. + * @returns 32-byte private key. + */ +export async function deriveMessageSigningPrivateKey( + seed: Uint8Array, + salt = '', +): Promise { + return deriveSip6PrivateKey({ + seed, + input: MESSAGE_SIGNING_SNAP_ID, + salt, + }); +} + +/** + * Copy of message-signing-snap `getPublicEntropyKey`: secp256k1 pubkey hex + * for the SIP-6 message-signing private key. + * + * @param seed - BIP-39 mnemonic seed from an HD keyring. + * @param salt - Optional SIP-6 salt. Auth / user-storage use `''`. + * @returns Public key hex with `0x` prefix. + */ +export async function getMessageSigningPublicKey( + seed: Uint8Array, + salt = '', +): Promise { + const privateKey = await deriveMessageSigningPrivateKey(seed, salt); + return bytesToHex(secp256k1.getPublicKey(privateKey)); +} + +/** + * Copy of message-signing-snap `signMessageWithEntropyKey`: sha256(message) + * then compact secp256k1 signature. + * + * @param message - Message to sign (must be validated by the caller). + * @param seed - BIP-39 mnemonic seed from an HD keyring. + * @param salt - Optional SIP-6 salt. Auth / user-storage use `''`. + * @returns Compact secp256k1 signature hex with `0x` prefix. + */ +export async function signMessageWithMessageSigningKey( + message: string, + seed: Uint8Array, + salt = '', +): Promise { + const privateKey = await deriveMessageSigningPrivateKey(seed, salt); + const digest = sha256(message); + const signature = secp256k1.sign(digest, privateKey); + return `0x${signature.toCompactHex()}`; +} diff --git a/yarn.lock b/yarn.lock index 8d50be5f5f7..b553365bb3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8608,17 +8608,17 @@ __metadata: "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/eth-hd-keyring": "npm:^15.0.0" + "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/seedless-onboarding-controller": "npm:^10.1.1" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/utils": "npm:^11.11.0" "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0"