diff --git a/packages/examples/examples/bls-signer/snap.manifest.json b/packages/examples/examples/bls-signer/snap.manifest.json index 773093bdff..44432a314d 100644 --- a/packages/examples/examples/bls-signer/snap.manifest.json +++ b/packages/examples/examples/bls-signer/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps-monorepo.git" }, "source": { - "shasum": "qNYd+o17O8kEwz40rxgBftRiyss2sYS7ir++rlLBQXo=", + "shasum": "V9PaaBMIyXuDzYQfUWBZjNStPgSv1MBp6g9En7iV6x4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/examples/bls-signer/src/index.js b/packages/examples/examples/bls-signer/src/index.js index ee68bed7af..8c22bda4b3 100644 --- a/packages/examples/examples/bls-signer/src/index.js +++ b/packages/examples/examples/bls-signer/src/index.js @@ -27,14 +27,19 @@ module.exports.onRpcRequest = async ({ request }) => { 'BLS signature request', `Do you want to BLS sign ${data} with ${pubKey}?`, ); + if (!approved) { throw rpcErrors.eth.unauthorized(); } + const PRIVATE_KEY = await wallet.request({ - method: 'snap_getAppKey', + method: 'snap_getEntropy', + params: { + version: 1, + }, }); - const signature = await bls.sign(request.params[0], PRIVATE_KEY, DOMAIN); - return signature; + + return await bls.sign(request.params[0], PRIVATE_KEY, DOMAIN); } default: diff --git a/packages/rpc-methods/jest.config.js b/packages/rpc-methods/jest.config.js index b0211f082b..bda662d8eb 100644 --- a/packages/rpc-methods/jest.config.js +++ b/packages/rpc-methods/jest.config.js @@ -5,10 +5,10 @@ module.exports = deepmerge(baseConfig, { coveragePathIgnorePatterns: ['./src/index.ts'], coverageThreshold: { global: { - branches: 87.01, - functions: 87.03, - lines: 78.96, - statements: 78.96, + branches: 87.5, + functions: 88.13, + lines: 80.89, + statements: 80.89, }, }, testTimeout: 2500, diff --git a/packages/rpc-methods/package.json b/packages/rpc-methods/package.json index 1f99cbc186..9a5599e0d8 100644 --- a/packages/rpc-methods/package.json +++ b/packages/rpc-methods/package.json @@ -31,6 +31,7 @@ "@metamask/snaps-utils": "^0.23.0", "@metamask/types": "^1.1.0", "@metamask/utils": "^3.3.1", + "@noble/hashes": "^1.1.3", "eth-rpc-errors": "^4.0.2", "nanoid": "^3.1.31", "superstruct": "^0.16.7" diff --git a/packages/rpc-methods/src/restricted/__fixtures__/entropy.ts b/packages/rpc-methods/src/restricted/__fixtures__/entropy.ts new file mode 100644 index 0000000000..7fafd6738c --- /dev/null +++ b/packages/rpc-methods/src/restricted/__fixtures__/entropy.ts @@ -0,0 +1,35 @@ +/** + * SIP-6 test vectors. + */ +export const ENTROPY_VECTORS = [ + { + snapId: 'foo', + derivationPath: + "m/1399742832'/1323571613'/1848851859'/458888073'/1339050117'/513522582'/1371866341'/2121938770'/1014285256'", + entropy: + '0x8bbb59ec55a4a8dd5429268e367ebbbe54eee7467c0090ca835c64d45c33a155', + }, + { + snapId: 'bar', + derivationPath: + "m/1399742832'/767024459'/1206550137'/1427647479'/1048031962'/1656784813'/1860822351'/1362389435'/2133253878'", + entropy: + '0xbdae5c0790d9189d8ae27fd4860b3b57bab420b6594c420ae9ae3a9f87c1ea14', + }, + { + snapId: 'foo', + salt: 'bar', + derivationPath: + "m/1399742832'/2002032866'/301374032'/1159533269'/453247377'/187127851'/1859522268'/152471137'/187531423'", + entropy: + '0x59cbec1fa877ecb38d88c3a2326b23bff374954b39ad9482c9b082306ac4b3ad', + }, + { + snapId: 'bar', + salt: 'baz', + derivationPath: + "m/1399742832'/734358031'/701613791'/1618075622'/1535938847'/1610213550'/18831365'/356906080'/2095933563'", + entropy: + '0x814c1f121eb4067d1e1d177246461e8a1cc6a1b1152756737aba7fa9c2161ba2', + }, +]; diff --git a/packages/rpc-methods/src/restricted/__fixtures__/index.ts b/packages/rpc-methods/src/restricted/__fixtures__/index.ts new file mode 100644 index 0000000000..c7bcadbd90 --- /dev/null +++ b/packages/rpc-methods/src/restricted/__fixtures__/index.ts @@ -0,0 +1 @@ +export * from './entropy'; diff --git a/packages/rpc-methods/src/restricted/getBip32Entropy.test.ts b/packages/rpc-methods/src/restricted/getBip32Entropy.test.ts index 4065f0bc9c..4586a84e9c 100644 --- a/packages/rpc-methods/src/restricted/getBip32Entropy.test.ts +++ b/packages/rpc-methods/src/restricted/getBip32Entropy.test.ts @@ -1,4 +1,4 @@ -import { SnapCaveatType } from '@metamask/snaps-utils'; +import { SIP_6_MAGIC_VALUE, SnapCaveatType } from '@metamask/snaps-utils'; import { getBip32EntropyBuilder, getBip32EntropyCaveatMapper, @@ -196,6 +196,22 @@ describe('getBip32EntropyCaveatSpecifications', () => { 'The requested path is not permitted. Allowed paths must be specified in the snap manifest.', ); }); + + it('throws if the purpose is not allowed', async () => { + const fn = jest.fn().mockImplementation(() => 'foo'); + + await expect( + getBip32EntropyCaveatSpecifications[ + SnapCaveatType.PermittedDerivationPaths + ].decorator(fn, { + type: SnapCaveatType.PermittedDerivationPaths, + value: [params], + // @ts-expect-error Missing other required properties. + })({ params: { ...params, path: ['m', SIP_6_MAGIC_VALUE, "0'"] } }), + ).rejects.toThrow( + 'Invalid BIP-32 entropy path definition: At path: path -- The purpose "1399742832\'" is not allowed for entropy derivation.', + ); + }); }); describe('validator', () => { @@ -209,6 +225,19 @@ describe('getBip32EntropyCaveatSpecifications', () => { }), ).toThrow('At path: value.0.path -- Path must start with "m".'); }); + + it('throws if the caveat values contain forbidden paths', () => { + expect(() => + getBip32EntropyCaveatSpecifications[ + SnapCaveatType.PermittedDerivationPaths + ].validator?.({ + type: SnapCaveatType.PermittedDerivationPaths, + value: [{ path: ['m', SIP_6_MAGIC_VALUE, "0'"], curve: 'secp256k1' }], + }), + ).toThrow( + 'Invalid BIP-32 entropy caveat: At path: value.0.path -- The purpose "1399742832\'" is not allowed for entropy derivation.', + ); + }); }); }); diff --git a/packages/rpc-methods/src/restricted/getEntropy.test.ts b/packages/rpc-methods/src/restricted/getEntropy.test.ts new file mode 100644 index 0000000000..88b748c6da --- /dev/null +++ b/packages/rpc-methods/src/restricted/getEntropy.test.ts @@ -0,0 +1,83 @@ +import { PermissionType } from '@metamask/controllers'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import { deriveEntropy, getEntropyBuilder } from './getEntropy'; +import { ENTROPY_VECTORS } from './__fixtures__'; + +const TEST_SECRET_RECOVERY_PHRASE = + 'test test test test test test test test test test test ball'; + +describe('getEntropyBuilder', () => { + it('has the expected shape', () => { + expect(getEntropyBuilder).toStrictEqual({ + targetKey: 'snap_getEntropy', + specificationBuilder: expect.any(Function), + methodHooks: { + getMnemonic: true, + getUnlockPromise: true, + }, + }); + }); + + it('returns the expected specification', () => { + const methodHooks = { + getMnemonic: jest.fn(), + getUnlockPromise: jest.fn(), + }; + + expect( + getEntropyBuilder.specificationBuilder({ methodHooks }), + ).toStrictEqual({ + permissionType: PermissionType.RestrictedMethod, + targetKey: 'snap_getEntropy', + allowedCaveats: null, + methodImplementation: expect.any(Function), + }); + }); +}); + +describe('deriveEntropy', () => { + it.each(ENTROPY_VECTORS)( + 'derives entropy from the given parameters', + async () => { + const { snapId, salt, entropy } = ENTROPY_VECTORS[0]; + + expect( + await deriveEntropy(snapId, TEST_SECRET_RECOVERY_PHRASE, salt ?? ''), + ).toStrictEqual(entropy); + }, + ); +}); + +describe('getEntropyImplementation', () => { + it('returns the expected result', async () => { + const getMnemonic = jest + .fn() + .mockImplementation(() => TEST_SECRET_RECOVERY_PHRASE); + + const getUnlockPromise = jest.fn(); + + const methodHooks = { + getMnemonic, + getUnlockPromise, + }; + + const implementation = getEntropyBuilder.specificationBuilder({ + methodHooks, + }).methodImplementation; + + const result = await implementation({ + method: 'snap_getEntropy', + params: { + version: 1, + salt: 'foo', + }, + context: { + origin: MOCK_SNAP_ID, + }, + }); + + expect(result).toStrictEqual( + '0x6d8e92de419401c7da3cedd5f60ce5635b26059c2a4a8003877fec83653a4921', + ); + }); +}); diff --git a/packages/rpc-methods/src/restricted/getEntropy.ts b/packages/rpc-methods/src/restricted/getEntropy.ts new file mode 100644 index 0000000000..9395a6d775 --- /dev/null +++ b/packages/rpc-methods/src/restricted/getEntropy.ts @@ -0,0 +1,191 @@ +import { ethErrors } from 'eth-rpc-errors'; +import { Infer, literal, object, optional, string } from 'superstruct'; +import { + add0x, + assert, + assertStruct, + concatBytes, + createDataView, + Hex, + NonEmptyArray, + stringToBytes, +} from '@metamask/utils'; +import { HardenedBIP32Node, SLIP10Node } from '@metamask/key-tree'; +import { + PermissionSpecificationBuilder, + PermissionType, + RestrictedMethodOptions, + ValidPermissionSpecification, +} from '@metamask/controllers'; +import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; +import { SIP_6_MAGIC_VALUE } from '@metamask/snaps-utils'; + +const HARDENED_VALUE = 0x80000000; + +const targetKey = 'snap_getEntropy'; + +type GetEntropySpecificationBuilderOptions = { + allowedCaveats?: Readonly> | null; + methodHooks: GetEntropyHooks; +}; + +type GetEntropySpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.RestrictedMethod; + targetKey: typeof targetKey; + methodImplementation: ReturnType; + allowedCaveats: Readonly> | null; +}>; + +export const GetEntropyArgsStruct = object({ + version: literal(1), + salt: optional(string()), +}); + +/** + * @property version - The version of the `snap_getEntropy` method. This must be + * the numeric literal `1`. + * @property salt - A string to use as the salt when deriving the entropy. If + * omitted, the salt will be an empty string. + */ +export type GetEntropyArgs = Infer; + +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.RestrictedMethod, + GetEntropySpecificationBuilderOptions, + GetEntropySpecification +> = ({ + allowedCaveats = null, + methodHooks, +}: GetEntropySpecificationBuilderOptions) => { + return { + permissionType: PermissionType.RestrictedMethod, + targetKey, + allowedCaveats, + methodImplementation: getEntropyImplementation(methodHooks), + }; +}; + +export const getEntropyBuilder = Object.freeze({ + targetKey, + specificationBuilder, + methodHooks: { + getMnemonic: true, + getUnlockPromise: true, + }, +} as const); + +export type GetEntropyHooks = { + /** + * @returns The mnemonic of the user's primary keyring. + */ + getMnemonic: () => Promise; + + /** + * Waits for the extension to be unlocked. + * + * @returns A promise that resolves once the extension is unlocked. + */ + getUnlockPromise: (shouldShowUnlockRequest: boolean) => Promise; +}; + +/** + * Get a BIP-32 derivation path array from a hash, which is compatible with + * `@metamask/key-tree`. The hash is assumed to be 32 bytes long. + * + * @param hash - The hash to derive indices from. + * @returns The derived indices as a {@link HardenedBIP32Node} array. + */ +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); + + // This is essentially `index | 0x80000000`. Because JavaScript numbers are + // signed, we use the bitwise unsigned right shift operator to ensure that + // the result is a positive number. + // eslint-disable-next-line no-bitwise + const pathIndex = (uint32 | HARDENED_VALUE) >>> 0; + array.push(`bip32:${pathIndex - HARDENED_VALUE}'` as const); + } + + return array; +} + +/** + * Derive entropy from the given mnemonic phrase and salt. + * + * This is based on the reference implementation of + * [SIP-6](https://metamask.github.io/SIPs/SIPS/sip-6). + * + * @param snapId - The snap ID to derive entropy for. + * @param mnemonicPhrase - The mnemonic phrase to derive entropy from. + * @param salt - The salt to use when deriving entropy. + * @returns The derived entropy. + */ +export async function deriveEntropy( + snapId: string, + mnemonicPhrase: string, + salt = '', +): Promise { + const snapIdBytes = stringToBytes(snapId); + const saltBytes = stringToBytes(salt); + + // Get the derivation path from the snap ID. + const hash = keccak256(concatBytes([snapIdBytes, keccak256(saltBytes)])); + const computedDerivationPath = getDerivationPathArray(hash); + + // Derive the private key using BIP-32. + const { privateKey } = await SLIP10Node.fromDerivationPath({ + derivationPath: [ + `bip39:${mnemonicPhrase}`, + `bip32:${SIP_6_MAGIC_VALUE}`, + ...computedDerivationPath, + ], + curve: 'secp256k1', + }); + + // This should never happen, but this keeps TypeScript happy. + assert(privateKey, 'Failed to derive the entropy.'); + + return add0x(privateKey); +} + +/** + * Builds the method implementation for `snap_getEntropy`. The implementation + * is based on the reference implementation of + * [SIP-6](https://metamask.github.io/SIPs/SIPS/sip-6). + * + * @param hooks - The RPC method hooks. + * @param hooks.getMnemonic - The method to get the mnemonic of the user's + * primary keyring. + * @param hooks.getUnlockPromise - The method to get a promise that resolves + * once the extension is unlocked. + * @returns The method implementation. + */ +function getEntropyImplementation({ + getMnemonic, + getUnlockPromise, +}: GetEntropyHooks) { + return async function getEntropy( + options: RestrictedMethodOptions, + ): Promise { + const { + params, + context: { origin }, + } = options; + + assertStruct( + params, + GetEntropyArgsStruct, + 'Invalid "snap_getEntropy" parameters', + ethErrors.rpc.invalidParams, + ); + + await getUnlockPromise(true); + const mnemonicPhrase = await getMnemonic(); + + return deriveEntropy(origin, mnemonicPhrase, params.salt); + }; +} diff --git a/packages/rpc-methods/src/restricted/index.ts b/packages/rpc-methods/src/restricted/index.ts index fc07462b3c..025d951b49 100644 --- a/packages/rpc-methods/src/restricted/index.ts +++ b/packages/rpc-methods/src/restricted/index.ts @@ -21,6 +21,7 @@ import { getBip32PublicKeyBuilder, GetBip32PublicKeyMethodHooks, } from './getBip32PublicKey'; +import { getEntropyBuilder, GetEntropyHooks } from './getEntropy'; export { AlertFields, @@ -38,6 +39,7 @@ export type RestrictedMethodHooks = ConfirmMethodHooks & GetBip32EntropyMethodHooks & GetBip32PublicKeyMethodHooks & GetBip44EntropyMethodHooks & + GetEntropyHooks & InvokeSnapMethodHooks & ManageStateMethodHooks & NotifyMethodHooks; @@ -48,6 +50,7 @@ export const restrictedMethodPermissionBuilders = { [getBip32EntropyBuilder.targetKey]: getBip32EntropyBuilder, [getBip32PublicKeyBuilder.targetKey]: getBip32PublicKeyBuilder, [getBip44EntropyBuilder.targetKey]: getBip44EntropyBuilder, + [getEntropyBuilder.targetKey]: getEntropyBuilder, [invokeSnapBuilder.targetKey]: invokeSnapBuilder, [manageStateBuilder.targetKey]: manageStateBuilder, [notifyBuilder.targetKey]: notifyBuilder, diff --git a/packages/snaps-utils/src/manifest/validation.test.ts b/packages/snaps-utils/src/manifest/validation.test.ts index 96d082e954..57d04bc041 100644 --- a/packages/snaps-utils/src/manifest/validation.test.ts +++ b/packages/snaps-utils/src/manifest/validation.test.ts @@ -121,6 +121,12 @@ describe('Bip32PathStruct', () => { ); }, ); + + it('throws for forbidden paths', () => { + expect(() => assert(['m', "1399742832'", '0'], Bip32PathStruct)).toThrow( + 'The purpose "1399742832\'" is not allowed for entropy derivation.', + ); + }); }); describe('Bip32EntropyStruct', () => { diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 0733282b9c..740b12e1f8 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -21,6 +21,13 @@ import { CronjobSpecificationArrayStruct } from '../cronjob'; import { NamespacesStruct } from '../namespace'; import { NameStruct, NpmSnapFileNames, VersionStruct } from '../types'; +// 0xd36e6170 - 0x80000000 +export const SIP_6_MAGIC_VALUE = `1399742832'` as `${number}'`; + +// BIP-43 purposes that cannot be used for entropy derivation. These are in the +// string form, ending with `'`. +const FORBIDDEN_PURPOSES: string[] = [SIP_6_MAGIC_VALUE]; + export type Base64Opts = { /** * Is the `=` padding at the end required or not. @@ -97,6 +104,10 @@ export const Bip32PathStruct = refine( return 'Path must be a valid BIP-32 derivation path array.'; } + if (FORBIDDEN_PURPOSES.includes(path[1])) { + return `The purpose "${path[1]}" is not allowed for entropy derivation.`; + } + return true; }, ); @@ -122,6 +133,7 @@ export const Bip32EntropyStruct = bip32entropy( curve: enums(['ed25519', 'secp256k1']), }), ); + export type Bip32Entropy = Infer; export const Bip32PublicKeyStruct = bip32entropy( @@ -131,6 +143,7 @@ export const Bip32PublicKeyStruct = bip32entropy( compressed: optional(boolean()), }), ); + export type Bip32PublicKey = Infer; const PermissionsStruct = type({ @@ -154,12 +167,14 @@ const PermissionsStruct = type({ Infinity, ), ), + snap_getEntropy: optional(object({})), 'endowment:keyring': optional( object({ namespaces: NamespacesStruct, }), ), }); + export type SnapPermissions = Infer; export const SnapManifestStruct = object({ diff --git a/yarn.lock b/yarn.lock index d7468ebe2e..84a8d75203 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2871,6 +2871,7 @@ __metadata: "@metamask/snaps-utils": ^0.23.0 "@metamask/types": ^1.1.0 "@metamask/utils": ^3.3.1 + "@noble/hashes": ^1.1.3 "@types/node": ^14.14.25 deepmerge: ^4.2.2 eslint: ^7.30.0