-
Notifications
You must be signed in to change notification settings - Fork 542
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
314 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
packages/rpc-methods/src/restricted/__fixtures__/entropy.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}, | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './entropy'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
import { ethErrors } from 'eth-rpc-errors'; | ||
import { Infer, literal, object, optional, string } from 'superstruct'; | ||
import { | ||
add0x, | ||
assert, | ||
assertStruct, | ||
concatBytes, | ||
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'; | ||
|
||
const MAGIC_VALUE = 0xd36e6170; | ||
const HARDENED_VALUE = 0x80000000; | ||
|
||
const targetKey = 'snap_getEntropy'; | ||
|
||
type GetEntropySpecificationBuilderOptions = { | ||
allowedCaveats?: Readonly<NonEmptyArray<string>> | null; | ||
methodHooks: GetEntropyHooks; | ||
}; | ||
|
||
type GetEntropySpecification = ValidPermissionSpecification<{ | ||
permissionType: PermissionType.RestrictedMethod; | ||
targetKey: typeof targetKey; | ||
methodImplementation: ReturnType<typeof getEntropyImplementation>; | ||
allowedCaveats: Readonly<NonEmptyArray<string>> | 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<typeof GetEntropyArgsStruct>; | ||
|
||
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<string>; | ||
|
||
/** | ||
* Waits for the extension to be unlocked. | ||
* | ||
* @returns A promise that resolves once the extension is unlocked. | ||
*/ | ||
getUnlockPromise: (shouldShowUnlockRequest: boolean) => Promise<void>; | ||
}; | ||
|
||
/** | ||
* 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 = new DataView(hash.buffer, hash.byteOffset, hash.byteLength); | ||
|
||
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<Hex> { | ||
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:${MAGIC_VALUE - HARDENED_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<GetEntropyArgs>, | ||
): Promise<Hex> { | ||
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); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters