diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index ca66ce932..33a2673d9 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `signProofOfOwnership` client request for silent proof-of-ownership signing (SEP-0053) ([#186](https://github.com/MetaMask/internal-snaps/pull/186)) - Add `exportAccount` keyring method for base32 Stellar secret-seed export ([#187](https://github.com/MetaMask/internal-snaps/pull/187)) - Add `TrustlineExceedLimitException` for send simulation when a payment would exceed the destination trustline limit (previously a generic `TransactionValidationException`) ([#185](https://github.com/MetaMask/internal-snaps/pull/185)) - Add `@metamask/snap-networks-utils` `^1.0.0` ([#182](https://github.com/MetaMask/internal-snaps/pull/182)) diff --git a/packages/stellar-wallet-snap/docs/use-cases/README.md b/packages/stellar-wallet-snap/docs/use-cases/README.md index ad094cda4..4e553e97a 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/README.md +++ b/packages/stellar-wallet-snap/docs/use-cases/README.md @@ -12,6 +12,7 @@ High-level flows for the Stellar Wallet Snap. Each doc focuses on **handlers**, | Quote swap / bridge fee | `computeFee` | [computeFee.md](./client-request/computeFee.md) | | Sign & submit swap / bridge | `signAndSendTransaction` | [signAndSendTransaction.md](./client-request/signAndSendTransaction.md) | | Change trustline (opt-in / opt-out) | `changeTrustOpt` | [changeTrustOpt.md](./client-request/changeTrustOpt.md) | +| Silent proof-of-ownership signing | `signProofOfOwnership` | [signProofOfOwnership.md](./client-request/signProofOfOwnership.md) | ## Cronjob (`onCronjob`) diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/signProofOfOwnership.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/signProofOfOwnership.md new file mode 100644 index 000000000..1e991e15a --- /dev/null +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/signProofOfOwnership.md @@ -0,0 +1,78 @@ +# Use case: `signProofOfOwnership` + +Silently signs a proof-of-ownership message so `@metamask/profile-metrics-controller` can prove the user controls a Stellar address. + +| | | +| ---------- | --------------------------------------------------------------------------------------------------------------- | +| **Entry** | `onClientRequest` → `ClientRequestHandler` → `SignProofOfOwnershipHandler` | +| **Method** | `signProofOfOwnership` (`ClientRequestMethod.SignProofOfOwnership`) | +| **Source** | [`handlers/clientRequest/signProofOfOwnership.ts`](../../../src/handlers/clientRequest/signProofOfOwnership.ts) | + +This is a **silent sign** — there is no confirmation dialog. That is intentional: the MetaMask client needs an ownership proof without interrupting the user. The method is scoped so it cannot be used as a general sign-message bypass: + +1. SIP-31 `onClientRequest` is only callable by the MetaMask client. +2. The plaintext must be `metamask:proof-of-ownership:{nonce}:{address}`, and the embedded address must match the signing account. +3. Signing uses [SEP-0053](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md) (`Wallet.signMessage`); the response is 0x-prefixed hex for the identity auth API. + +## Request / response (shape) + +**Request params** + +- `accountId` — keyring account UUID +- `message` — plaintext `metamask:proof-of-ownership:{nonce}:{address}` (see [Message format](#message-format)) +- `nonce`, `address` — coerced from `message` internally (clients do not send these) + +**Response** + +- `{ signature }` — SEP-0053 Stellar signed-message ed25519 signature as hex, plus a `0x` prefix for the identity auth API: + - The raw signature is **64 bytes** → **128** lowercase hex characters (the `0x` prefix is **not** part of those 64 bytes). + - Wire form: `0x` + 128 hex chars (130 characters total), e.g. validated by `/^0x[0-9a-f]{128}$/`. + +## Message format + +Parsed by [`parseProofOfOwnershipMessage`](../../../src/handlers/clientRequest/utils.ts) during request validation: + +- Prefix must be exactly `metamask:proof-of-ownership:` (case-sensitive). +- `{nonce}` is non-empty and may contain `:` characters; parsing splits on the **last** `:` in the remainder. +- `{address}` must be a valid Stellar strkey (G… public key). + +Example: `metamask:proof-of-ownership:ns:abc:123:GBX…` → nonce `ns:abc:123`, address `GBX…`. + +## Participants + +| Component | Path | Role in this flow | +| ----------------------------- | ------------------------ | ---------------------------------------------------- | +| `ClientRequestHandler` | `handlers/clientRequest` | Routes `signProofOfOwnership` to the handler | +| `SignProofOfOwnershipHandler` | `handlers/clientRequest` | Validates message, resolves wallet, signs | +| `AccountResolver` | `handlers/` | Loads keyring account + wallet (no on-chain account) | +| `AccountService` | `services/account` | Keyring account lookup (via resolver) | +| `WalletService` / `Wallet` | `services/wallet` | Signing key material + SEP-0053 `signMessage` | + +No confirmation UI or network calls. + +## Step-by-step + +1. **Route** — `onClientRequest` dispatches to `SignProofOfOwnershipHandler`. +2. **Validate** — Request must match `SignProofOfOwnershipJsonRpcRequestStruct` (prefix, nonce, Stellar address). `nonce` and `address` are coerced from `message`. +3. **Resolve** — `AccountResolver.resolveAccount` with `RESOLVE_ACCOUNT_KEYRING_AND_WALLET` loads keyring account and wallet only. The signing account does not need to be activated on-chain. +4. **Bind** — The address in the message must equal the signing account address. +5. **Sign** — `Wallet.signMessage(message, 'hex')` returns the 64-byte SEP-0053 signature as 128 hex chars (no `0x`), then the handler prefixes `0x`. + +## Sequence (happy path) + +```mermaid +sequenceDiagram + participant Client + participant Handler as SignProofOfOwnershipHandler + participant Resolver as AccountResolver + participant Wallet + + Client->>Handler: signProofOfOwnership { accountId, message } + Note over Handler: validate coerces nonce + address from message + Handler->>Resolver: resolve keyring account + wallet + Resolver-->>Handler: account, wallet + Handler->>Handler: message address == account.address + Handler->>Wallet: signMessage (SEP-0053, hex) + Wallet-->>Handler: 64-byte signature as 128 hex chars (no 0x) + Handler-->>Client: { signature } (0x + 128 hex chars) +``` diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index 319b62e98..ab234e5ea 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -15,6 +15,7 @@ import { ConfirmSendHandler } from './handlers/clientRequest/confirmSend'; import { OnAddressInputHandler } from './handlers/clientRequest/onAddressInput'; import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput'; import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; +import { SignProofOfOwnershipHandler } from './handlers/clientRequest/signProofOfOwnership'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; import { @@ -292,6 +293,11 @@ const computeFeeHandler = new ComputeFeeHandler({ transactionService, }); +const signProofOfOwnershipHandler = new SignProofOfOwnershipHandler({ + logger, + accountResolver, +}); + const clientRequestMethodHandlers: Record< ClientRequestMethod, IClientRequestHandler @@ -302,6 +308,7 @@ const clientRequestMethodHandlers: Record< [ClientRequestMethod.ConfirmSend]: confirmSendHandler, [ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler, [ClientRequestMethod.ComputeFee]: computeFeeHandler, + [ClientRequestMethod.SignProofOfOwnership]: signProofOfOwnershipHandler, }; const clientRequestHandler = new ClientRequestHandler({ diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index 34fbf1ef9..b04c0f69b 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -21,6 +21,8 @@ import { ConfirmSendJsonRpcResponseStruct, SignAndSendTransactionJsonRpcRequestStruct, SignAndSendTransactionJsonRpcResponseStruct, + SignProofOfOwnershipJsonRpcRequestStruct, + SignProofOfOwnershipJsonRpcResponseStruct, } from './api'; const accountId = '11111111-1111-4111-8111-111111111111'; @@ -918,3 +920,155 @@ describe('ConfirmSendJsonRpcResponseStruct', () => { ); }); }); + +describe('SignProofOfOwnershipJsonRpcRequestStruct', () => { + const nonce = 'a1b2c3d4e5f6789012345678'; + + it.each([ + { + message: `metamask:proof-of-ownership:${nonce}:${stellarAddress}`, + nonce, + address: stellarAddress, + }, + { + message: `metamask:proof-of-ownership:abc-DEF_123:${stellarAddress}`, + nonce: 'abc-DEF_123', + address: stellarAddress, + }, + { + message: `metamask:proof-of-ownership:ns:abc:123:${stellarAddress}`, + nonce: 'ns:abc:123', + address: stellarAddress, + }, + ])( + 'accepts a valid signProofOfOwnership request: "$message"', + ({ message, nonce: expectedNonce, address }) => { + const result = create( + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message }, + }, + SignProofOfOwnershipJsonRpcRequestStruct, + ); + + expect(result.params).toStrictEqual({ + accountId, + message, + nonce: expectedNonce, + address, + }); + }, + ); + + it.each([ + { + method: ClientRequestMethod.ConfirmSend, + params: { + accountId, + message: `metamask:proof-of-ownership:${nonce}:${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `rewards,${stellarAddress},123` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof:${nonce}:${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `Metamask:proof-of-ownership:${nonce}:${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `${nonce}:${stellarAddress}` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: '' }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `metamask:proof-of-ownership:${nonce}` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof-of-ownership::${stellarAddress}`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { accountId, message: `metamask:proof-of-ownership:${nonce}:` }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof-of-ownership:${nonce}:not-a-stellar-address`, + }, + }, + { + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: `metamask:proof-of-ownership:${nonce}:0x1234567890abcdef1234567890abcdef12345678`, + }, + }, + ])( + 'rejects an invalid signProofOfOwnership request', + ({ method, params }) => { + expect(() => + assert( + { jsonrpc: '2.0', id: 1, method, params }, + SignProofOfOwnershipJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }, + ); +}); + +describe('SignProofOfOwnershipJsonRpcResponseStruct', () => { + it('accepts a 0x-prefixed 64-byte hex signature', () => { + expect(() => + assert( + { + signature: `0x${'ab'.repeat(64)}`, + }, + SignProofOfOwnershipJsonRpcResponseStruct, + ), + ).not.toThrow(); + }); + + it.each([ + { + signature: + 'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==', + }, + { signature: 'ab'.repeat(64) }, // missing 0x + { signature: `0x${'ab'.repeat(63)}` }, // 63 bytes + { signature: `0x${'ab'.repeat(65)}` }, // 65 bytes + { signature: `0x${'AB'.repeat(64)}` }, // uppercase + { signature: 'not!!!valid-hex' }, + { signature: '' }, + {}, + ])('rejects an invalid signProofOfOwnership response', (response) => { + expect(() => + assert(response, SignProofOfOwnershipJsonRpcResponseStruct), + ).toThrow(StructError); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index ac8342e07..637334541 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -17,6 +17,7 @@ import { integer, min, coerce, + pattern, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; @@ -36,6 +37,7 @@ import { SwapTransactionXdrStruct, } from '../../api'; import { isSep41Id } from '../../utils'; +import { parseProofOfOwnershipMessage } from './utils'; /** * Enum for the client request method. @@ -48,6 +50,11 @@ export const ClientRequestMethod = { // Standard multichain workflow for bridge SignAndSendTransaction: 'signAndSendTransaction', ComputeFee: 'computeFee', + /** + * Silent proof-of-ownership signing for `@metamask/profile-metrics-controller`. + * SIP-31 client-only. + */ + SignProofOfOwnership: 'signProofOfOwnership', /** -------------------------------- Stellar Specific -------------------------------- */ ChangeTrustOpt: 'changeTrustOpt', } as const; @@ -383,6 +390,72 @@ export const ComputeFeeJsonRpcResponseStruct = array( }), ); +/** + * Validates that a plaintext message follows the proof-of-ownership format: + * `'metamask:proof-of-ownership:{nonce}:{address}'`. + */ +export const ProofOfOwnershipMessageStruct = refine( + string(), + 'ProofOfOwnershipMessage', + (value: string) => { + try { + parseProofOfOwnershipMessage(value); + return true; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid proof-of-ownership message'; + } + }, +); + +/** + * Validation struct for the signProofOfOwnership JSON-RPC request. + * Coerces `nonce` and `address` from `message` (clients send only accountId + message). + */ +export const SignProofOfOwnershipJsonRpcRequestStruct = coerce( + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.SignProofOfOwnership), + params: object({ + accountId: UuidStruct, + message: ProofOfOwnershipMessageStruct, + nonce: nonempty(string()), + address: StellarAddressStruct, + }), + }), + ), + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.SignProofOfOwnership), + params: object({ + accountId: UuidStruct, + message: ProofOfOwnershipMessageStruct, + }), + }), + ), + (request) => ({ + ...request, + params: { + ...request.params, + ...parseProofOfOwnershipMessage(request.params.message), + }, + }), +); + +/** + * Validation struct for the signProofOfOwnership JSON-RPC response. + * + * Signature is the SEP-0053 Stellar signed-message ed25519 signature (64 bytes → + * 128 lowercase hex chars) with a leading `0x` for the identity auth API. + * The `0x` prefix is not part of the 64-byte signature length. + */ +export const SignProofOfOwnershipJsonRpcResponseStruct = object({ + signature: pattern(string(), /^0x[0-9a-f]{128}$/u), +}); + /** * A JSON-RPC request with an account resolve parameter. */ @@ -474,3 +547,17 @@ export type ComputeFeeJsonRpcRequest = Infer< export type ComputeFeeJsonRpcResponse = Infer< typeof ComputeFeeJsonRpcResponseStruct >; + +/** + * Type for the signProofOfOwnership JSON-RPC request. + */ +export type SignProofOfOwnershipJsonRpcRequest = Infer< + typeof SignProofOfOwnershipJsonRpcRequestStruct +>; + +/** + * Type for the signProofOfOwnership JSON-RPC response. + */ +export type SignProofOfOwnershipJsonRpcResponse = Infer< + typeof SignProofOfOwnershipJsonRpcResponseStruct +>; diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.test.ts new file mode 100644 index 000000000..aa908aa5d --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.test.ts @@ -0,0 +1,126 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +import { AccountService, StellarKeyringAccount } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { Wallet, WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { logger } from '../../utils/logger'; +import { AccountResolver } from '../accountResolver'; +import { ClientRequestMethod } from './api'; +import { SignProofOfOwnershipHandler } from './signProofOfOwnership'; + +jest.mock('../../utils/logger'); + +describe('SignProofOfOwnershipHandler', () => { + const accountId = '11111111-1111-4111-8111-111111111111'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + type SetupResult = { + handler: SignProofOfOwnershipHandler; + account: StellarKeyringAccount; + wallet: Wallet; + resolveAccountSpy: jest.SpyInstance; + resolveWalletSpy: jest.SpyInstance; + buildProofMessage: (proofNonce?: string, proofAddress?: string) => string; + createRequest: (message?: string) => JsonRpcRequest; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function setup(): SetupResult { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + const accountResolver = new AccountResolver({ + accountService, + onChainAccountService, + walletService, + }); + + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account }); + + const resolveWalletSpy = jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const handler = new SignProofOfOwnershipHandler({ + logger, + accountResolver, + }); + + const buildProofMessage = ( + proofNonce: string = nonce, + proofAddress: string = wallet.address, + ): string => `metamask:proof-of-ownership:${proofNonce}:${proofAddress}`; + + const createRequest = (message?: string): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignProofOfOwnership, + params: { + accountId, + message: message ?? buildProofMessage(), + }, + }); + + return { + handler, + account, + wallet, + resolveAccountSpy, + resolveWalletSpy, + buildProofMessage, + createRequest, + }; + } + + it('signs the proof message and returns the SEP-0053 signature as 0x-prefixed hex', async () => { + const { + handler, + wallet, + resolveAccountSpy, + resolveWalletSpy, + buildProofMessage, + createRequest, + } = setup(); + const message = buildProofMessage(); + const hexSignature = wallet.signMessage(message, 'hex'); + + const result = await handler.handle(createRequest()); + + expect(resolveAccountSpy).toHaveBeenCalledWith({ accountId }); + expect(resolveWalletSpy).toHaveBeenCalled(); + expect(result).toStrictEqual({ + signature: `0x${hexSignature}`, + }); + // make sure the signature is a valid 0x-prefixed 64-byte hex string + expect((result as { signature: string }).signature).toMatch( + /^0x[0-9a-f]{128}$/u, + ); + expect(wallet.verifyMessage(message, hexSignature, 'hex')).toBe(true); + }); + + it('throws if the address in the message does not match the signing account', async () => { + const { handler, wallet, createRequest, buildProofMessage } = setup(); + const otherWallet = getTestWallet(); + const otherAddress = otherWallet.address; + + await expect( + handler.handle(createRequest(buildProofMessage(nonce, otherAddress))), + ).rejects.toThrow( + `Address in proof-of-ownership message (${otherAddress}) does not match signing account address (${wallet.address})`, + ); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.ts new file mode 100644 index 000000000..5c28243db --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/signProofOfOwnership.ts @@ -0,0 +1,86 @@ +import type { Logger } from '@metamask/snap-networks-utils'; +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { add0x } from '@metamask/utils'; + +import type { AccountResolver } from '../accountResolver'; +import { RESOLVE_ACCOUNT_KEYRING_AND_WALLET } from '../accountResolver'; +import { BaseHandler } from '../base'; +import type { + SignProofOfOwnershipJsonRpcRequest, + SignProofOfOwnershipJsonRpcResponse, +} from './api'; +import { + SignProofOfOwnershipJsonRpcRequestStruct, + SignProofOfOwnershipJsonRpcResponseStruct, +} from './api'; +import type { IClientRequestHandler } from './base'; + +/** + * Handles the silent signing of a proof-of-ownership message, of format + * `'metamask:proof-of-ownership:{nonce}:{address}'`. + * + * Used by `@metamask/profile-metrics-controller` to prove wallet control of an + * address. This is a **silent sign** (no user confirmation dialog): it skips + * the usual sign-message security prompt on purpose so the client can collect + * profile-metrics ownership proofs without interrupting the user. + * + */ +export class SignProofOfOwnershipHandler + // We don't need to resolve an on-chain account for this handler, + // so we can use the base handler without any additional options. + extends BaseHandler< + SignProofOfOwnershipJsonRpcRequest, + SignProofOfOwnershipJsonRpcResponse + > + implements IClientRequestHandler +{ + readonly #accountResolver: AccountResolver; + + constructor({ + logger, + accountResolver, + }: { + logger: Logger; + accountResolver: AccountResolver; + }) { + super({ + logger: logger.withPrefix('[🔏 SignProofOfOwnershipHandler]'), + requestStruct: SignProofOfOwnershipJsonRpcRequestStruct, + responseStruct: SignProofOfOwnershipJsonRpcResponseStruct, + }); + this.#accountResolver = accountResolver; + } + + /** + * Resolves the keyring account + wallet (no on-chain activation required) + * and signs the validated proof message. + * + * @param request - The JSON-RPC request containing `accountId`, `message`, + * and coerced `address`. + * @returns `{ signature }` — SEP-0053 64-byte ed25519 signature as hex + * (128 chars) with a leading `0x` (prefix not included in the 64 bytes). + * @throws {InvalidParamsError} If the address in the message does not match + * the signing account. + */ + protected async handleRequest( + request: SignProofOfOwnershipJsonRpcRequest, + ): Promise { + const { accountId, message, address: messageAddress } = request.params; + + const { account, wallet } = await this.#accountResolver.resolveAccount({ + accountId, + options: RESOLVE_ACCOUNT_KEYRING_AND_WALLET, + }); + + if (messageAddress !== account.address) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- InvalidParamsError is the JSON-RPC snap error surface + throw new InvalidParamsError( + `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${account.address})`, + ); + } + + return { + signature: add0x(wallet.signMessage(message, 'hex')), + }; + } +} diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts index b4dec26c8..4b4390e5f 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts @@ -1,3 +1,4 @@ +import { StellarAddressStruct } from '../../api/address'; import { TransactionValidationException } from '../../services/transaction'; import type { Transaction } from '../../services/transaction'; @@ -27,3 +28,46 @@ export function assertRefreshedTransactionFeeNotHigher(params: { ); } } + +/** + * Parses a proof-of-ownership message of format + * `'metamask:proof-of-ownership:{nonce}:{address}'`. + * Splits on the last `:` so opaque nonces may contain colons. + * + * @param message - The plaintext proof-of-ownership message. + * @returns The parsed nonce and Stellar address. + * @throws Error if the message format is invalid. + */ +export function parseProofOfOwnershipMessage(message: string): { + nonce: string; + address: string; +} { + const messagePrefix = 'metamask:proof-of-ownership:'; + + if (!message.startsWith(messagePrefix)) { + throw new Error(`Message must start with "${messagePrefix}"`); + } + + const remainder = message.slice(messagePrefix.length); + const separatorIdx = remainder.lastIndexOf(':'); + if (separatorIdx === -1) { + throw new Error( + 'Message must follow the format "metamask:proof-of-ownership:{nonce}:{address}"', + ); + } + + const nonce = remainder.slice(0, separatorIdx); + const address = remainder.slice(separatorIdx + 1); + + if (nonce === '') { + throw new Error( + 'Proof-of-ownership message must contain a non-empty nonce', + ); + } + + if (!StellarAddressStruct.is(address)) { + throw new Error('Invalid Stellar address in proof-of-ownership message'); + } + + return { nonce, address }; +}