diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 23b877ba5..efeb6822a 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `exportAccount` keyring method for base32 Stellar secret-seed export ([#187](https://github.com/MetaMask/internal-snaps/pull/187)) + ## [0.1.0] ### Added diff --git a/packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md b/packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md index a2bbd1abd..2e7ae74e8 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md +++ b/packages/stellar-wallet-snap/docs/use-cases/keyring/keyring.md @@ -15,6 +15,7 @@ Account management and SEP-43 signing entry points via `onKeyringRequest` → `K | `AccountService` | `services/account` | Persist / derive / select accounts (snap state) | | `OnChainAccountService` | `services/on-chain-account` | Snap-state snapshots for balances/assets; live activation for discovery | | `TransactionService` | `services/transaction` | Local pending keyring txs for `listAccountTransactions` | +| `WalletService` | `services/wallet` | HD derive signing material; used by `exportAccount` (never persisted) | | `SyncAccountsHandler` | `handlers/cronjob` | Scheduled after selection changes to refresh on-chain snapshots | ## Request / response @@ -45,6 +46,7 @@ Requests are origin-checked, then dispatched to the methods below. | `listAccountTransactions` | Paginated keyring transactions for the account | **Snap state** (pending / local txs via `TransactionService` — **not** Horizon history) | | `discoverAccounts` | Derive BIP-44 address for index; return it only if activated on any requested scope | Derive locally; activation check is **live on-chain** (`NetworkService.getAccount`) | | `resolveAccountAddress` | Given an address, return CAIP-10 if this snap owns it; else `null` (MetaMask may fall back) | **Snap state** (keyring account lookup by address) | +| `exportAccount` | Export the Stellar secret seed (`S…` strkey / base32). Only `encoding: "base32"` is supported | **Derived** via `WalletService` (never persisted) | | `filterAccountChains` | Not implemented | Throws `MethodNotSupportedError` | | `updateAccount` | Not implemented | Throws `MethodNotSupportedError` | | `submitRequest` | [signTransaction.md](./signTransaction.md) · [signMessage.md](./signMessage.md) · [signAuthEntry.md](./signAuthEntry.md) | | diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 7e305f7f0..a04437fb3 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -26,6 +26,13 @@ "allowedOrigins": ["https://portfolio.metamask.io"], "capabilities": { "scopes": ["stellar:pubnet"], + "privateKey": { + "exportFormats": [ + { + "encoding": "base32" + } + ] + }, "bip44": { "deriveIndex": true, "deriveIndexRange": true, diff --git a/packages/stellar-wallet-snap/src/api/address.test.ts b/packages/stellar-wallet-snap/src/api/address.test.ts index 377a4b06b..c7987c738 100644 --- a/packages/stellar-wallet-snap/src/api/address.test.ts +++ b/packages/stellar-wallet-snap/src/api/address.test.ts @@ -3,6 +3,7 @@ import { assert, StructError } from '@metamask/superstruct'; import { StellarAddressOrContractStruct, StellarAddressStruct, + StellarSecretKeyStruct, } from './address'; describe('StellarAddressStruct', () => { @@ -21,6 +22,25 @@ describe('StellarAddressStruct', () => { }); }); +describe('StellarSecretKeyStruct', () => { + it('accepts a valid Stellar secret seed', () => { + expect(() => + assert( + 'SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW', + StellarSecretKeyStruct, + ), + ).not.toThrow(); + }); + + it.each([ + 'invalid-secret', + 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + '', + ])('rejects an invalid Stellar secret seed: "%s"', (secret) => { + expect(() => assert(secret, StellarSecretKeyStruct)).toThrow(StructError); + }); +}); + describe('StellarAddressOrContractStruct', () => { it('accepts a valid Stellar address', () => { expect(() => diff --git a/packages/stellar-wallet-snap/src/api/address.ts b/packages/stellar-wallet-snap/src/api/address.ts index 08199f085..509624ac8 100644 --- a/packages/stellar-wallet-snap/src/api/address.ts +++ b/packages/stellar-wallet-snap/src/api/address.ts @@ -21,6 +21,29 @@ export const StellarAddressStruct = refine( }, ); +/** + * Validation struct for a Stellar secret seed (`S…` strkey / base32). + */ +export const StellarSecretKeyStruct = refine( + nonempty(string()), + 'stellar_secret_key', + (value: string) => { + try { + if (!StrKey.isValidEd25519SecretSeed(value)) { + return 'Invalid Stellar secret key'; + } + return true; + } catch { + return 'Invalid Stellar secret key'; + } + }, +); + +/** + * Type for a Stellar secret seed. + */ +export type StellarSecretKey = Infer; + export const StellarAddressOrContractStruct = refine( nonempty(string()), 'stellar_contract_or_address', diff --git a/packages/stellar-wallet-snap/src/constants.ts b/packages/stellar-wallet-snap/src/constants.ts index 588dafa3e..d655873d5 100644 --- a/packages/stellar-wallet-snap/src/constants.ts +++ b/packages/stellar-wallet-snap/src/constants.ts @@ -9,6 +9,14 @@ import snapManifest from '../snap.manifest.json'; export const SUPPORTED_SCOPES = snapManifest.initialPermissions['endowment:keyring'].capabilities.scopes; +/** + * Private-key export encoding supported by this snap. + * + * Always `base32` (Stellar `S…` strkey). Not read from the snap manifest: + * this snap does not support any other encoding. + */ +export const PRIVATE_KEY_EXPORT_ENCODING = 'base32' as const; + /** * The base reserve for the Stellar network. * diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index 3d13f9d4f..319b62e98 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -174,6 +174,7 @@ const keyringHandler = new KeyringHandler({ accountService, onChainAccountService, transactionService, + walletService, handlers: keyringMethodHandlers, }); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index 069ae1196..324e922a1 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -1,10 +1,11 @@ -import { assert, StructError } from '@metamask/superstruct'; +import { assert, create, StructError } from '@metamask/superstruct'; import { KnownCaip2ChainId } from '../../api'; import type { StellarKeyringAccount } from '../../services/account'; import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; import { CreateAccountOptionsStruct, + ExportAccountRequestStruct, ResolveAccountAddressRequestStruct, ListAccountTransactionsRequestStruct, MultichainMethod, @@ -586,3 +587,61 @@ describe('ListAccountTransactionsRequestStruct', () => { ); }); }); + +describe('ExportAccountRequestStruct', () => { + it.each([ + { + request: { accountId: account.id }, + expected: { + accountId: account.id, + options: { type: 'private-key' as const, encoding: 'base32' as const }, + }, + }, + { + request: { + accountId: account.id, + options: { type: 'private-key' as const }, + }, + expected: { + accountId: account.id, + options: { type: 'private-key' as const, encoding: 'base32' as const }, + }, + }, + { + request: { + accountId: account.id, + options: { type: 'private-key' as const, encoding: 'base32' as const }, + }, + expected: { + accountId: account.id, + options: { type: 'private-key' as const, encoding: 'base32' as const }, + }, + }, + ])('accepts a valid exportAccount request', ({ request, expected }) => { + expect(create(request, ExportAccountRequestStruct)).toStrictEqual(expected); + }); + + it.each([ + { accountId: 'not-a-uuid' }, + { + accountId: account.id, + options: { type: 'mnemonic', encoding: 'base32' }, + }, + { + accountId: account.id, + options: { type: 'private-key', encoding: 'utf-8' }, + }, + { + accountId: account.id, + options: { type: 'private-key', encoding: 'hexadecimal' }, + }, + { + accountId: account.id, + options: { type: 'private-key', encoding: 'base58' }, + }, + ])('rejects an invalid exportAccount request', (request) => { + expect(() => assert(request, ExportAccountRequestStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/packages/stellar-wallet-snap/src/handlers/keyring/api.ts index e0bd78183..044b94af1 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -15,6 +15,7 @@ import { nullable, enums, refine, + defaulted, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; @@ -29,6 +30,7 @@ import { KnownCaip2ChainId, KnownCaip2ChainIdStruct } from '../../api/network'; import { Utf8StringStruct } from '../../api/string'; import { UuidStruct } from '../../api/uuid'; import { HashIdPreimageXdrStruct, XdrStruct } from '../../api/xdr'; +import { PRIVATE_KEY_EXPORT_ENCODING } from '../../constants'; import { networkToCaip2ChainId } from '../../services/network/utils'; /** JSON-RPC methods supported by this snap's multichain keyring. */ @@ -306,6 +308,29 @@ export const SignAuthEntryResponseStruct = union([ */ export const GetAccountRequestStruct = UuidStruct; +/** + * Validation struct for the exportAccount request. + * + * Only {@link PRIVATE_KEY_EXPORT_ENCODING} (`base32`) is accepted. Missing + * `options` or `encoding` default to that encoding. + */ +export const ExportAccountRequestStruct = object({ + accountId: UuidStruct, + options: defaulted( + object({ + type: literal('private-key'), + encoding: defaulted( + enums([PRIVATE_KEY_EXPORT_ENCODING]), + PRIVATE_KEY_EXPORT_ENCODING, + ), + }), + { + type: 'private-key' as const, + encoding: PRIVATE_KEY_EXPORT_ENCODING, + }, + ), +}); + /** * Validation struct for the deleteAccount request. */ @@ -349,6 +374,11 @@ export type ResolveAccountAddressJsonRpcRequest = Infer< */ export type GetAccountRequest = Infer; +/** + * Type for the exportAccount request. + */ +export type ExportAccountRequest = Infer; + /** * Type for the deleteAccount request. */ diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 4fec7fdaf..b4e9d7bf3 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -18,6 +18,11 @@ import { StellarSnapException } from '../../utils/errors'; export class KeyringException extends StellarSnapException {} +/** + * Thrown when private-key export fails. Messages must not include the secret. + */ +export class ExportAccountException extends KeyringException {} + /** * SEP-43 error codes. * diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 0753e83e4..8cf0ce14f 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -27,7 +27,8 @@ import { createMockTransactionService, generateMockTransactions, } from '../../services/transaction/__mocks__/transaction.fixtures'; -import { getDerivationPath } from '../../services/wallet'; +import { getDerivationPath, WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import { getSlip44AssetId, getDefaultEntropySource, @@ -43,6 +44,7 @@ import { SignTransactionResponseStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; +import { ExportAccountException } from './exceptions'; import { KeyringHandler } from './keyring'; jest.mock('../../utils/logger'); @@ -106,7 +108,7 @@ describe('KeyringHandler', () => { mockSignTransactionHandler = { handle: jest.fn() }; mockSignAuthEntryHandler = { handle: jest.fn() }; - const { accountService, onChainAccountService } = + const { accountService, onChainAccountService, walletService } = mockOnChainAccountService(); const { transactionService } = createMockTransactionService(); keyringHandler = new KeyringHandler({ @@ -114,6 +116,7 @@ describe('KeyringHandler', () => { accountService, onChainAccountService, transactionService, + walletService, handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, [MultichainMethod.SignTransaction]: mockSignTransactionHandler, @@ -154,6 +157,26 @@ describe('KeyringHandler', () => { expect(result).toBeNull(); }); + + it('does not log the keyring result (exportAccount returns a secret seed)', async () => { + const privateKey = + 'SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW'; + jest.mocked(handleKeyringRequest).mockResolvedValue({ + type: 'private-key', + encoding: 'base32', + privateKey, + }); + + await keyringHandler.handle(METAMASK_ORIGIN, { + method: 'keyring_exportAccount', + id: '1', + jsonrpc: '2.0', + } as JsonRpcRequest); + + expect( + JSON.stringify(jest.mocked(logger.debug).mock.calls), + ).not.toContain(privateKey); + }); }); describe('getAccount', () => { @@ -627,6 +650,160 @@ describe('KeyringHandler', () => { }); }); + describe('exportAccount', () => { + it('exports the account private key as base32', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const wallet = getTestWallet(); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const result = await keyringHandler.exportAccount(mockAccountId, { + type: 'private-key', + encoding: 'base32', + }); + + expect(result).toStrictEqual({ + type: 'private-key', + encoding: 'base32', + privateKey: wallet.secret, + }); + }); + + it('defaults encoding to base32 when options omit encoding', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const wallet = getTestWallet(); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const result = await keyringHandler.exportAccount(mockAccountId, { + type: 'private-key', + } as Parameters[1]); + expect(result.encoding).toBe('base32'); + }); + + it('defaults to base32 encoding when options are omitted', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const wallet = getTestWallet(); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const result = await keyringHandler.exportAccount(mockAccountId); + + expect(result).toStrictEqual({ + type: 'private-key', + encoding: 'base32', + privateKey: wallet.secret, + }); + }); + + it('throws if the account id is not a uuid', async () => { + await expect( + keyringHandler.exportAccount('not-a-uuid', { + type: 'private-key', + encoding: 'base32', + }), + ).rejects.toThrow(InvalidParamsError); + }); + + it('throws if the account is not found', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockRejectedValue( + new AccountNotFoundException(NON_EXISTENT_ID), + ); + + await expect( + keyringHandler.exportAccount(NON_EXISTENT_ID, { + type: 'private-key', + encoding: 'base32', + }), + ).rejects.toThrow(AccountNotFoundException); + }); + + it('rejects an unsupported encoding', async () => { + await expect( + keyringHandler.exportAccount(mockAccountId, { + type: 'private-key', + encoding: 'utf-8' as unknown as 'base32', + }), + ).rejects.toThrow(/Expected/u); + }); + + it('rejects hexadecimal export encoding', async () => { + await expect( + keyringHandler.exportAccount(mockAccountId, { + type: 'private-key', + encoding: 'hexadecimal', + }), + ).rejects.toThrow(InvalidParamsError); + }); + + it('rejects base58 export encoding', async () => { + await expect( + keyringHandler.exportAccount(mockAccountId, { + type: 'private-key', + encoding: 'base58', + }), + ).rejects.toThrow(InvalidParamsError); + }); + + it('rejects an unsupported export type', async () => { + await expect( + keyringHandler.exportAccount(mockAccountId, { + type: 'mnemonic' as unknown as 'private-key', + encoding: 'base32', + }), + ).rejects.toThrow(/Expected/u); + }); + + it('propagates wallet resolution errors', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockRejectedValue(new Error('derivation failed')); + + await expect(keyringHandler.exportAccount(mockAccountId)).rejects.toThrow( + 'derivation failed', + ); + }); + + it('throws ExportAccountException when the derived secret fails validation', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const wallet = getTestWallet(); + jest.spyOn(wallet, 'secret', 'get').mockReturnValue('not-a-secret'); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + await expect(keyringHandler.exportAccount(mockAccountId)).rejects.toThrow( + ExportAccountException, + ); + }); + + it('throws a generic ExportAccountException when reading the secret fails', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const wallet = getTestWallet(); + jest.spyOn(wallet, 'secret', 'get').mockImplementation(() => { + throw new Error('secret() exploded'); + }); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + await expect(keyringHandler.exportAccount(mockAccountId)).rejects.toThrow( + 'Error exporting account', + ); + }); + }); + describe('deleteAccount', () => { it('deletes an account', async () => { const { deleteSpy } = getAccountServiceSpies(); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 874237acc..2affbf36c 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -11,10 +11,15 @@ import { AccountCreationType, assertCreateAccountOptionIsSupported, } from '@metamask/keyring-api'; -import type { KeyringSnapRpc } from '@metamask/keyring-api/v2'; +import type { + ExportAccountOptions, + ExportedAccount, + KeyringSnapRpc, +} from '@metamask/keyring-api/v2'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { is } from '@metamask/superstruct'; import type { CaipAssetType, CaipAssetTypeOrId, @@ -25,6 +30,7 @@ import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; +import { StellarSecretKeyStruct } from '../../api'; import { AppConfig } from '../../config'; import type { AccountService, @@ -42,6 +48,7 @@ import { toStandardBalanceEntry, } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction/TransactionService'; +import type { WalletService } from '../../services/wallet'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, @@ -49,6 +56,7 @@ import { getSlip44AssetId, isClassicAssetId, isSlip44Id, + rethrowIfInstanceElseThrow, validateOrigin, validateRequest, withCatchAndThrowSnapError, @@ -58,6 +66,7 @@ import { SyncAccountsHandler } from '../cronjob/syncAccounts'; import type { GetAccountRequest, MultichainMethod } from './api'; import { DeleteAccountRequestStruct, + ExportAccountRequestStruct, GetAccountRequestStruct, ListAccountTransactionsRequestStruct, MultichainMethodStruct, @@ -67,6 +76,7 @@ import { GetAccountBalancesRequestStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; +import { ExportAccountException } from './exceptions'; export class KeyringHandler implements KeyringSnapRpc { readonly #logger: ILogger; @@ -77,6 +87,8 @@ export class KeyringHandler implements KeyringSnapRpc { readonly #transactionService: TransactionService; + readonly #walletService: WalletService; + readonly #handlers: Record; constructor({ @@ -84,18 +96,21 @@ export class KeyringHandler implements KeyringSnapRpc { accountService, onChainAccountService, transactionService, + walletService, handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; transactionService: TransactionService; + walletService: WalletService; handlers: Record; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; + this.#walletService = walletService; this.#handlers = handlers; } @@ -111,7 +126,6 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.debug('Keyring request handled', { origin, method: request.method, - result: keyringRequestResult, }); return keyringRequestResult; }, this.#logger)) ?? null; @@ -369,6 +383,57 @@ export class KeyringHandler implements KeyringSnapRpc { } } + /** + * Exports the Stellar secret seed for an account (`S…` strkey / base32). + * Triggered by the client when the user requests a private-key export. + * + * @param accountId - The id of the account to export. + * @param options - Export options. Encoding must be `base32`; omitted + * `options` / `encoding` default to `base32`. + * @returns The exported private key (`type`, `encoding`, `privateKey`). + * @throws {ExportAccountException} If the derived seed fails validation, or + * another error occurs while reading it (the latter uses a generic message + * so the secret is not leaked). + */ + async exportAccount( + accountId: string, + options?: ExportAccountOptions, + ): Promise { + const { options: exportOptions } = validateRequest( + { accountId, options }, + ExportAccountRequestStruct, + ); + + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + const wallet = await this.#walletService.resolveWallet(account); + + // For security reasons, we wrap the export in a try-catch block to avoid leaking the private key in case of an error. + try { + const privateKey = wallet.secret; + // SECURITY: Use `is` rather than `assert`. A StructError would embed the + // private key in its message. + if (!is(privateKey, StellarSecretKeyStruct)) { + throw new ExportAccountException( + 'Derived private key failed encoding validation', + ); + } + + return { + type: exportOptions.type, + encoding: exportOptions.encoding, + privateKey, + }; + } catch (error: unknown) { + return rethrowIfInstanceElseThrow( + error, + [ExportAccountException], + new ExportAccountException('Error exporting account'), + ); + } + } + async deleteAccount(accountId: string): Promise { validateRequest(accountId, DeleteAccountRequestStruct); diff --git a/packages/stellar-wallet-snap/src/permissions.ts b/packages/stellar-wallet-snap/src/permissions.ts index 7106f893e..e9de1a142 100644 --- a/packages/stellar-wallet-snap/src/permissions.ts +++ b/packages/stellar-wallet-snap/src/permissions.ts @@ -23,6 +23,7 @@ const metamaskPermissions = new Set([ KeyringSnapRpcMethod.GetAccountAssets, KeyringSnapRpcMethod.ResolveAccountAddress, KeyringSnapRpcMethod.SetSelectedAccounts, + KeyringSnapRpcMethod.ExportAccount, /** * Keyring API v1 method names, kept because consumers still call them. * Dropping them makes the client fail with an access restriction error on diff --git a/packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts b/packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts index cb568cdd3..7d83cec91 100644 --- a/packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts +++ b/packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts @@ -26,6 +26,16 @@ describe('Wallet', () => { }); }); + describe('secret', () => { + it('returns the Stellar secret seed for a derived wallet', () => { + const wallet = getTestWallet({ seed }); + const expected = Keypair.fromRawEd25519Seed( + bufferToUint8Array(seed), + ).secret(); + expect(wallet.secret).toStrictEqual(expected); + }); + }); + describe('signMessage', () => { it('returns a base64-encoded signature for a string message', () => { const wallet = getTestWallet({ seed }); diff --git a/packages/stellar-wallet-snap/src/services/wallet/Wallet.ts b/packages/stellar-wallet-snap/src/services/wallet/Wallet.ts index e651efc11..d5ae93af3 100644 --- a/packages/stellar-wallet-snap/src/services/wallet/Wallet.ts +++ b/packages/stellar-wallet-snap/src/services/wallet/Wallet.ts @@ -11,7 +11,8 @@ import { } from './exceptions'; /** - * Signing-only handle: Stellar SDK keypair for transaction and SEP-53 message signing. + * Handle over a Stellar SDK keypair: transaction / SEP-53 / SEP-43 signing + * and secret-seed export. */ export class Wallet { readonly #signer: Keypair; @@ -29,6 +30,17 @@ export class Wallet { return this.#signer.publicKey(); } + /** + * The Stellar secret seed (`S…` strkey / base32). + * + * Used by keyring `exportAccount`. + * + * @returns Secret seed string (`S…`). + */ + get secret(): string { + return this.#signer.secret(); + } + /** * Signs the given transaction with this wallet's signer. * diff --git a/packages/stellar-wallet-snap/src/utils/requestResponse.test.ts b/packages/stellar-wallet-snap/src/utils/requestResponse.test.ts index b6d7ed325..cf725c005 100644 --- a/packages/stellar-wallet-snap/src/utils/requestResponse.test.ts +++ b/packages/stellar-wallet-snap/src/utils/requestResponse.test.ts @@ -82,6 +82,7 @@ describe('validateOrigin', () => { KeyringSnapRpcMethod.GetAccountAssets, KeyringSnapRpcMethod.ResolveAccountAddress, KeyringSnapRpcMethod.SetSelectedAccounts, + KeyringSnapRpcMethod.ExportAccount, ])('allows method %s for metamask', (method) => { const origin = METAMASK_ORIGIN;