diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 48a9bf1341..df961af760 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `ProofOfOwnershipService` for signing chain-native proofs of account ownership ([#9016](https://github.com/MetaMask/core/pull/9016)) + - Exposes `ProofOfOwnershipService:sign({ account, nonce })`, dispatching by the CAIP-2 namespace of the account's first scope. + - EVM accounts are signed via `KeyringController:signPersonalMessage` (EIP-191); Solana, Tron, and Bitcoin accounts are signed via `SnapController:handleRequest` with the `onClientRequest` handler against the snap declared in `account.metadata.snap.id`, which keeps the request silent and client-internal. + - The non-EVM snaps are expected to implement a `signProofOfOwnership` JSON-RPC method that validates the message prefix `metamask:proof-of-ownership:` before signing. + - Add `profileMetricsServiceName` alias for the existing `serviceName` export, to disambiguate it from the new `proofOfOwnershipServiceName`. The original `serviceName` export is unchanged. - Add proof of ownership API wiring pre-requisites ([#8974](https://github.com/MetaMask/core/pull/8974)) - Add `ProfileMetricsService:fetchNonces` messenger action wrapping `POST /api/v2/nonce/batch`. - Add optional `proof` field on accounts submitted via `ProfileMetricsService:submitMetrics` so that the auth API can use it to mark accounts as `verified: true`. diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index b3fc48d41c..9d75f7bebe 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -60,10 +60,14 @@ "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.6", "@metamask/profile-sync-controller": "^28.1.1", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/snaps-utils": "^12.1.2", "@metamask/superstruct": "^3.1.0", "@metamask/transaction-controller": "^67.0.0", "@metamask/utils": "^11.9.0", - "async-mutex": "^0.5.0" + "async-mutex": "^0.5.0", + "uuid": "^8.3.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index fb3527f57f..c18872284b 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -19,9 +19,9 @@ import { TransactionControllerTransactionSubmittedEvent } from '@metamask/transa import { Duration, inMilliseconds } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import type { ProfileMetricsServiceMethodActions } from '.'; -import type { ProfileMetricsControllerMethodActions } from '.'; +import type { ProfileMetricsControllerMethodActions } from './ProfileMetricsController-method-action-types'; import type { AccountWithScopes } from './ProfileMetricsService'; +import type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; /** * The name of the {@link ProfileMetricsController}, used to namespace the diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index e9a7b60852..677a0a4f7d 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -14,7 +14,7 @@ import { } from '@metamask/superstruct'; import type { IDisposable } from 'cockatiel'; -import type { ProfileMetricsServiceMethodActions } from '.'; +import type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; /** * The shape of an entry in the `POST /api/v2/nonce/batch` response body. diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts new file mode 100644 index 0000000000..28436e645f --- /dev/null +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts @@ -0,0 +1,32 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ProofOfOwnershipService } from './ProofOfOwnershipService'; + +/** + * Sign a proof of ownership for the given account and server-issued nonce. + * + * The returned proof is shaped to drop directly into + * `AccountWithScopes.proof` for `ProfileMetricsService:submitMetrics`. + * + * @param data - The account to prove ownership of and the nonce to bind + * the proof to. + * @returns The proof of ownership (nonce echo + signature). + * @throws {ProofUnsupportedNamespaceError} if the account's first scope + * carries a namespace this service does not know how to sign for, or if + * the account has no scopes. + * @throws if the underlying signer (keyring or snap) rejects, or if the + * snap returns a malformed response. + */ +export type ProofOfOwnershipServiceSignAction = { + type: `ProofOfOwnershipService:sign`; + handler: ProofOfOwnershipService['sign']; +}; + +/** + * Union of all ProofOfOwnershipService action types. + */ +export type ProofOfOwnershipServiceMethodActions = + ProofOfOwnershipServiceSignAction; diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts new file mode 100644 index 0000000000..27bbb11018 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts @@ -0,0 +1,433 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { ProofOfOwnershipService } from '.'; +import type { ProofOfOwnershipServiceMessenger } from '.'; +import { SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD } from './ProofOfOwnershipService'; +import { ProofUnsupportedNamespaceError } from './utils/canonicalize'; + +/** + * Creates a mock InternalAccount with the given scopes. EVM accounts are + * built with no `metadata.snap`; non-EVM accounts get a snap entry so the + * service can route to `SnapController:handleRequest`. + * + * @param overrides - Partial overrides for the account fields. Provide + * `scopes` to control namespace dispatch, `address` to control the canonical + * address used in the signed message, and `metadata.snap.id` to override + * the default `'npm:@metamask/test-wallet-snap'`. + * @returns A mock InternalAccount. + */ +function createMockAccount( + overrides: Partial = {}, +): InternalAccount { + const scopes = overrides.scopes ?? ['eip155:1']; + const isEvm = scopes[0]?.startsWith('eip155'); + const baseMetadata = { + keyring: { type: 'Test Keyring' }, + name: 'Mock Account', + importTime: 0, + }; + const metadata = isEvm + ? baseMetadata + : { + ...baseMetadata, + snap: { + id: 'npm:@metamask/test-wallet-snap', + name: 'Test Wallet Snap', + enabled: true, + }, + }; + + return { + id: 'mock-account-id', + address: '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes, + ...overrides, + // When the caller passes `metadata`, take it verbatim (full replacement) + // so tests can express "no snap" / "snap-with-no-id" / etc. without + // having to fight a merge. + metadata: overrides.metadata ?? metadata, + } as InternalAccount; +} + +describe('ProofOfOwnershipService', () => { + describe('constructor', () => { + it('registers the sign messenger action on construction', async () => { + const { rootMessenger } = getService(); + + // The service is reachable only if the constructor registered the + // `sign` action on its messenger; if it hadn't, this call would + // throw "Action handler not found" before any of the test stubs ran. + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account: createMockAccount(), + nonce: 'n0', + }); + + expect(proof).toStrictEqual({ + nonce: 'n0', + signature: '0xdefaultsig', + }); + }); + }); + + describe('eip155 dispatch', () => { + it('routes EVM accounts through KeyringController:signPersonalMessage with the canonical message', async () => { + const signPersonalMessage = jest + .fn, [{ data: string; from: string }]>() + .mockResolvedValue('0xevmsignature'); + const { rootMessenger } = getService({ signPersonalMessage }); + + // Lowercase address on input — service must canonicalize to EIP-55 in + // the signed message even when the keyring is asked to sign as the + // raw account address. + const account = createMockAccount({ + address: '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed', + scopes: ['eip155:1'], + }); + + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'abc123', + }); + + expect(signPersonalMessage).toHaveBeenCalledTimes(1); + expect(signPersonalMessage).toHaveBeenCalledWith({ + data: 'metamask:proof-of-ownership:abc123:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + from: '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed', + }); + expect(proof).toStrictEqual({ + nonce: 'abc123', + signature: '0xevmsignature', + }); + }); + + it('surfaces errors thrown by the keyring without wrapping them', async () => { + const signPersonalMessage = jest + .fn, [{ data: string; from: string }]>() + .mockRejectedValue(new Error('keyring locked')); + const { rootMessenger } = getService({ signPersonalMessage }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account: createMockAccount(), + nonce: 'n', + }), + ).rejects.toThrow('keyring locked'); + }); + }); + + describe('snap dispatch', () => { + it.each([ + { + namespace: 'solana', + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }, + { + namespace: 'tron', + scope: 'tron:0x2b6653dc', + address: 'TRX9Yg4yFqyKBcXBSc1nKMpHsfYVgKvN3p', + }, + { + namespace: 'bip122', + scope: 'bip122:000000000019d6689c085ae165831e93', + address: 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4', + }, + ])( + 'routes $namespace accounts through SnapController:handleRequest with the canonical message', + async ({ address, scope }) => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ signature: '0xsnapsig' }); + const { rootMessenger } = getService({ + snapHandle, + }); + const account = createMockAccount({ + id: 'snap-account-id', + address, + scopes: [scope as `${string}:${string}`], + }); + + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n42', + }); + + expect(snapHandle).toHaveBeenCalledTimes(1); + const [request] = snapHandle.mock.calls[0] as [ + { + snapId: string; + origin: string; + handler: string; + request: { + jsonrpc: string; + method: string; + params: { accountId: string; message: string }; + }; + }, + ]; + expect(request.snapId).toBe('npm:@metamask/test-wallet-snap'); + expect(request.origin).toBe('metamask'); + expect(request.handler).toBe('onClientRequest'); + expect(request.request.jsonrpc).toBe('2.0'); + expect(request.request.method).toBe( + SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD, + ); + expect(request.request.params.accountId).toBe('snap-account-id'); + // The message payload uses the canonical address — bech32 lowercased + // for BIP-122, base58 verbatim for Solana/Tron. + expect(request.request.params.message).toMatch( + /^metamask:proof-of-ownership:n42:/u, + ); + expect(proof).toStrictEqual({ nonce: 'n42', signature: '0xsnapsig' }); + }, + ); + + it('throws a descriptive error when the account has no snap metadata', async () => { + const { rootMessenger } = getService(); + const account = createMockAccount({ + id: 'orphan', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + metadata: { + keyring: { type: 'Test Keyring' }, + name: 'Orphan', + importTime: 0, + }, + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow( + "ProofOfOwnershipService: account 'orphan' has no snap to sign a proof of ownership.", + ); + }); + + it('throws when the snap returns a malformed response (missing signature)', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ notSignature: true }); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow( + `ProofOfOwnershipService: snap 'npm:@metamask/test-wallet-snap' returned a malformed response to '${SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD}'.`, + ); + }); + + it('throws when the snap returns a non-string signature', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ signature: 12345 }); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow(/returned a malformed response/u); + }); + + it('tolerates additional fields in the snap response (forward-compatible schema)', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ + signature: '0xsnapsig', + publicKey: '0xpub', + algorithm: 'ed25519', + }); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }); + + expect(proof.signature).toBe('0xsnapsig'); + }); + + it('surfaces errors thrown by the snap without wrapping them', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockRejectedValue(new Error('snap unavailable')); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow('snap unavailable'); + }); + }); + + describe('namespace handling', () => { + it('throws ProofUnsupportedNamespaceError for unrecognized namespaces', async () => { + const { rootMessenger } = getService(); + const account = createMockAccount({ + scopes: ['cosmos:cosmoshub-4'], + address: 'cosmos1abc', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow(ProofUnsupportedNamespaceError); + }); + + it('throws ProofUnsupportedNamespaceError when the account has no scopes', async () => { + const { rootMessenger } = getService(); + const account = createMockAccount({ scopes: [] }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow(ProofUnsupportedNamespaceError); + }); + + it('dispatches based on the first scope when an account carries multiple scopes from the same namespace', async () => { + const signPersonalMessage = jest + .fn, [{ data: string; from: string }]>() + .mockResolvedValue('0xsig'); + const { rootMessenger } = getService({ signPersonalMessage }); + + // Realistic case: multi-chain EVM account scoped to mainnet + polygon. + const account = createMockAccount({ + scopes: ['eip155:1', 'eip155:137'], + }); + + await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }); + + expect(signPersonalMessage).toHaveBeenCalledTimes(1); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test, delegating the two + * external actions it needs. + * + * @param rootMessenger - The root messenger, with all external actions and + * events registered. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ProofOfOwnershipServiceMessenger { + const serviceMessenger: ProofOfOwnershipServiceMessenger = new Messenger({ + namespace: 'ProofOfOwnershipService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: serviceMessenger, + actions: [ + 'KeyringController:signPersonalMessage', + 'SnapController:handleRequest', + ], + }); + return serviceMessenger; +} + +/** + * Constructs the service under test with the two external handlers wired up + * (defaulting to inert stubs that can be overridden per test). + * + * @param args - Optional handler overrides. + * @param args.signPersonalMessage - Stub for + * `KeyringController:signPersonalMessage`. Defaults to a stub that resolves + * to `'0xdefaultsig'`. + * @param args.snapHandle - Stub for `SnapController:handleRequest`. Defaults + * to a stub that resolves to `{ signature: '0xdefaultsnapsig' }`. + * @returns The new service, the root messenger, and the service messenger. + */ +function getService({ + signPersonalMessage, + snapHandle, +}: { + signPersonalMessage?: (args: { + data: string; + from: string; + }) => Promise; + snapHandle?: (args: unknown) => Promise; +} = {}): { + service: ProofOfOwnershipService; + rootMessenger: RootMessenger; + messenger: ProofOfOwnershipServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + signPersonalMessage ?? (async (): Promise => '0xdefaultsig'), + ); + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + (snapHandle ?? + (async (): Promise<{ signature: string }> => ({ + signature: '0xdefaultsnapsig', + }))) as never, + ); + + const messenger = getMessenger(rootMessenger); + const service = new ProofOfOwnershipService({ messenger }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts new file mode 100644 index 0000000000..f36f9a0075 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts @@ -0,0 +1,284 @@ +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { string, type as structType } from '@metamask/superstruct'; +import { KnownCaipNamespace, parseCaipChainId } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +import type { AccountOwnershipProof } from './ProfileMetricsService'; +import type { ProofOfOwnershipServiceMethodActions } from './ProofOfOwnershipService-method-action-types'; +import { + canonicalizeAddress, + ProofUnsupportedNamespaceError, +} from './utils/canonicalize'; + +// === GENERAL === + +/** + * The name of the {@link ProofOfOwnershipService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'ProofOfOwnershipService'; + +/** + * The shape of the request object for signing a proof of ownership for a + * single account. + */ +export type ProofOfOwnershipSignRequest = { + /** + * The account to sign the proof for. Provides the address, the scopes used + * to infer the signing namespace, and (for non-EVM) the snap ID + account + * ID needed by `SnapController:handleRequest`. + */ + account: InternalAccount; + /** + * A single-use nonce minted by the auth API (see + * `ProfileMetricsService:fetchNonces`). Consumed server-side on + * verification; replay is not possible. + */ + nonce: string; +}; + +/** + * The JSON-RPC method name exposed by non-EVM wallet snaps for silent + * proof-of-ownership signing. Each supported snap (Bitcoin, Solana, Tron) + * implements this method under the `onClientRequest` handler and is expected + * to validate that the signed message begins with + * `metamask:proof-of-ownership:`. + */ +export const SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD = 'signProofOfOwnership'; + +/** + * The shape of a successful response from a non-EVM wallet snap's + * {@link SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD} handler. Validated at runtime; + * declared with `type()` (not `object()`) so additive snap-side schema + * changes do not break the client. + */ +const SnapSignProofResponseStruct = structType({ + signature: string(), +}); + +/** + * Builds the canonical message string that all chains sign for a proof of + * ownership: `metamask:proof-of-ownership::`. + * Defined per the auth API spec — kept inline so the contract is visible + * next to the dispatch logic that uses it. + * + * @param nonce - The single-use nonce from the auth API. + * @param canonicalAddress - The account address in its CAIP-namespace + * canonical form (see {@link canonicalizeAddress}). + * @returns The message string to sign. + */ +function buildProofMessage(nonce: string, canonicalAddress: string): string { + return `metamask:proof-of-ownership:${nonce}:${canonicalAddress}`; +} + +/** + * Extracts the CAIP-2 namespace from the first scope of an account. All + * scopes on a single account are expected to share a namespace (e.g. an + * `eip155` account may carry many `eip155:` scopes but never a + * `solana:<…>` one). + * + * @param account - The internal account to inspect. + * @returns The namespace portion of the first scope. + * @throws {ProofUnsupportedNamespaceError} if the account has no scopes. + */ +function getAccountNamespace(account: InternalAccount): string { + const [firstScope] = account.scopes; + if (!firstScope) { + throw new ProofUnsupportedNamespaceError(''); + } + // `parseCaipChainId` validates the `:` shape. + return parseCaipChainId(firstScope).namespace; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['sign'] as const; + +/** + * Actions that {@link ProofOfOwnershipService} exposes to other consumers. + */ +export type ProofOfOwnershipServiceActions = + ProofOfOwnershipServiceMethodActions; + +/** + * Actions from other messengers that {@link ProofOfOwnershipService} calls. + */ +type AllowedActions = + | KeyringControllerSignPersonalMessageAction + | SnapControllerHandleRequestAction; + +/** + * Events that {@link ProofOfOwnershipService} exposes to other consumers. + */ +export type ProofOfOwnershipServiceEvents = never; + +/** + * Events from other messengers that {@link ProofOfOwnershipService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ProofOfOwnershipService}. + */ +export type ProofOfOwnershipServiceMessenger = Messenger< + typeof serviceName, + ProofOfOwnershipServiceActions | AllowedActions, + ProofOfOwnershipServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * A service that produces chain-native cryptographic proofs that the wallet + * controls a given account address. + * + * Dispatches by the CAIP-2 namespace of the account's first scope: + * + * - `eip155` → `KeyringController:signPersonalMessage` (EIP-191). + * - `solana`, `tron`, `bip122` → `SnapController:handleRequest` with the + * `onClientRequest` handler against the wallet snap declared in + * `account.metadata.snap.id`. Routed this way (rather than through the + * keyring) so the request is silent and client-internal: no user prompt, + * no dapp-facing exposure. The snap is expected to implement + * {@link SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD} and to validate the message + * prefix before signing. + * + * Any other namespace yields {@link ProofUnsupportedNamespaceError}, which + * callers in the polling pipeline catch to submit the account without a + * proof (the auth API treats `proof` as optional). + */ +export class ProofOfOwnershipService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + /** + * The messenger suited for this service. + */ + readonly #messenger: ProofOfOwnershipServiceMessenger; + + /** + * Constructs a new ProofOfOwnershipService object. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + */ + constructor({ messenger }: { messenger: ProofOfOwnershipServiceMessenger }) { + this.name = serviceName; + this.#messenger = messenger; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Sign a proof of ownership for the given account and server-issued nonce. + * + * The returned proof is shaped to drop directly into + * `AccountWithScopes.proof` for `ProfileMetricsService:submitMetrics`. + * + * @param data - The account to prove ownership of and the nonce to bind + * the proof to. + * @returns The proof of ownership (nonce echo + signature). + * @throws {ProofUnsupportedNamespaceError} if the account's first scope + * carries a namespace this service does not know how to sign for, or if + * the account has no scopes. + * @throws if the underlying signer (keyring or snap) rejects, or if the + * snap returns a malformed response. + */ + async sign( + data: ProofOfOwnershipSignRequest, + ): Promise { + const { account, nonce } = data; + const namespace = getAccountNamespace(account); + const canonicalAddress = canonicalizeAddress(account.address, namespace); + const message = buildProofMessage(nonce, canonicalAddress); + + let signature: string; + if (namespace === KnownCaipNamespace.Eip155) { + signature = await this.#signEvm(account.address, message); + } else { + // canonicalizeAddress() already threw for any unsupported namespace, + // so anything reaching here is one of solana / tron / bip122. + signature = await this.#signViaSnap(account, message); + } + + return { nonce, signature }; + } + + /** + * Sign an EIP-191 personal message via the keyring controller. + * + * @param address - The EVM address to sign with (must belong to an + * unlocked keyring). + * @param message - The proof message to sign. + * @returns The 0x-prefixed signature. + */ + async #signEvm(address: string, message: string): Promise { + return await this.#messenger.call('KeyringController:signPersonalMessage', { + data: message, + from: address, + }); + } + + /** + * Sign a proof message via the wallet snap declared on the account's + * metadata. Uses the `onClientRequest` handler with `origin: 'metamask'`, + * which is what makes the request silent (no user prompt, no + * dapp-facing exposure). + * + * @param account - The internal account; must carry a non-empty + * `metadata.snap.id`. + * @param message - The proof message to sign. + * @returns The signature string as returned by the snap. + * @throws if the snap response does not match + * {@link SnapSignProofResponseStruct}. + */ + async #signViaSnap( + account: InternalAccount, + message: string, + ): Promise { + const snapId = account.metadata.snap?.id; + if (!snapId) { + throw new Error( + `ProofOfOwnershipService: account '${account.id}' has no snap to sign a proof of ownership.`, + ); + } + + const response: unknown = await this.#messenger.call( + 'SnapController:handleRequest', + { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + id: uuid(), + jsonrpc: '2.0', + method: SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD, + params: { + accountId: account.id, + message, + }, + }, + }, + ); + + if (!SnapSignProofResponseStruct.is(response)) { + throw new Error( + `ProofOfOwnershipService: snap '${snapId}' returned a malformed response to '${SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD}'.`, + ); + } + + return response.signature; + } +} diff --git a/packages/profile-metrics-controller/src/index.ts b/packages/profile-metrics-controller/src/index.ts index a8a928733b..81c38273fa 100644 --- a/packages/profile-metrics-controller/src/index.ts +++ b/packages/profile-metrics-controller/src/index.ts @@ -16,8 +16,21 @@ export type { ProfileMetricsServiceMessenger, ProfileMetricsSubmitMetricsRequest, } from './ProfileMetricsService'; -export { ProfileMetricsService, serviceName } from './ProfileMetricsService'; +export { + ProfileMetricsService, + serviceName, + serviceName as profileMetricsServiceName, +} from './ProfileMetricsService'; export type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; +export type { + ProofOfOwnershipServiceActions, + ProofOfOwnershipServiceEvents, + ProofOfOwnershipServiceMessenger, +} from './ProofOfOwnershipService'; +export { + ProofOfOwnershipService, + serviceName as proofOfOwnershipServiceName, +} from './ProofOfOwnershipService'; export type { ProfileMetricsControllerMethodActions, ProfileMetricsControllerSkipInitialDelayAction, diff --git a/yarn.lock b/yarn.lock index 35afdaca4d..f3df98eaea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8019,6 +8019,9 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/profile-sync-controller": "npm:^28.1.1" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/transaction-controller": "npm:^67.0.0" "@metamask/utils": "npm:^11.9.0" @@ -8033,6 +8036,7 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + uuid: "npm:^8.3.2" languageName: unknown linkType: soft @@ -8473,9 +8477,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0": - version: 11.1.0 - resolution: "@metamask/snaps-sdk@npm:11.1.0" +"@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0, @metamask/snaps-sdk@npm:^11.1.1": + version: 11.1.1 + resolution: "@metamask/snaps-sdk@npm:11.1.1" dependencies: "@metamask/key-tree": "npm:^10.1.1" "@metamask/providers": "npm:^22.1.1" @@ -8483,23 +8487,23 @@ __metadata: "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.11.0" luxon: "npm:^3.5.0" - checksum: 10/138c616584d537b9976ae48123090ab5731848d79d5d1f4e979c797dfdfe061329cbf18a5e84d8bd068fe36d5b9d169337f6d74efab0736f30c31ddf4088f70b + checksum: 10/8419625775b83045d98c4ed920002ad884471e2303f6650104c97457c238f095ff35030663ddf111ebb6ce8f4cd915f57b7b9a5597d00288093701dbb79e787c languageName: node linkType: hard "@metamask/snaps-utils@npm:^12.1.2, @metamask/snaps-utils@npm:^12.1.3, @metamask/snaps-utils@npm:^12.2.0": - version: 12.2.0 - resolution: "@metamask/snaps-utils@npm:12.2.0" + version: 12.2.1 + resolution: "@metamask/snaps-utils@npm:12.2.1" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" "@metamask/key-tree": "npm:^10.1.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/permission-controller": "npm:^12.3.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/permission-controller": "npm:^13.1.1" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.4.0" "@metamask/snaps-registry": "npm:^4.0.0" - "@metamask/snaps-sdk": "npm:^11.1.0" + "@metamask/snaps-sdk": "npm:^11.1.1" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.11.0" "@scure/base": "npm:^1.1.1" @@ -8514,7 +8518,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.15.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/0e7cb5a4deebad3dc98404486b7767be049e9f86173195e9b1ae577f197e67cdd392f1e05ebb146a199ffc3cd52013636637a4d1ac7f9fec164f3189be97df55 + checksum: 10/fda8caefa414d9755a2305b345b5bbbf30ee614215a49070449f314d5d906c645457cf0d7f4881a82f69d398aff5ad2fe9c648bbb2a5003c29c2d01e2f42f199 languageName: node linkType: hard