Skip to content

Commit c44ffa9

Browse files
authored
Add snap_getBip32Entropy method (#683)
* Add snap_getBip32Entropy method * Add permission caveats * Improve permission and path validation * Fix PR comments * Add tests * Revert SNAP_PREFIX changes * Improve error message * Fix naming and test issue
1 parent 920c960 commit c44ffa9

10 files changed

Lines changed: 557 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module.exports = {
2+
collectCoverage: true,
3+
// Ensures that we collect coverage from all source files, not just tested
4+
// ones.
5+
collectCoverageFrom: ['./src/**/*.ts'],
6+
coveragePathIgnorePatterns: ['./src/index.ts'],
7+
coverageReporters: ['clover', 'json', 'lcov', 'text', 'json-summary'],
8+
coverageThreshold: {
9+
global: {
10+
branches: 25.73,
11+
functions: 32.07,
12+
lines: 20,
13+
statements: 20.42,
14+
},
15+
},
16+
globals: {
17+
'ts-jest': {
18+
tsconfig: 'tsconfig.json',
19+
},
20+
},
21+
moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'],
22+
preset: 'ts-jest',
23+
// "resetMocks" resets all mocks, including mocked modules, to jest.fn(),
24+
// between each test case.
25+
resetMocks: true,
26+
// "restoreMocks" restores all mocks created using jest.spyOn to their
27+
// original implementations, between each test. It does not affect mocked
28+
// modules.
29+
restoreMocks: true,
30+
testEnvironment: 'node',
31+
testRegex: ['\\.test\\.(ts|js)$'],
32+
testTimeout: 2500,
33+
};

packages/rpc-methods/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"dist/"
1313
],
1414
"scripts": {
15+
"test": "jest && yarn posttest",
16+
"posttest": "jest-it-up",
17+
"test:ci": "yarn test",
1518
"lint:eslint": "eslint . --cache --ext js,ts",
1619
"lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path ../../.gitignore",
1720
"lint": "yarn lint:eslint && yarn lint:misc --check",
@@ -46,9 +49,12 @@
4649
"eslint-plugin-jsdoc": "^36.1.0",
4750
"eslint-plugin-node": "^11.1.0",
4851
"eslint-plugin-prettier": "^3.4.0",
52+
"jest": "^27.5.1",
53+
"jest-it-up": "^2.0.0",
4954
"prettier": "^2.3.2",
5055
"prettier-plugin-packagejson": "^2.2.11",
5156
"rimraf": "^3.0.2",
57+
"ts-jest": "^27.1.5",
5258
"typescript": "^4.4.0"
5359
},
5460
"engines": {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export enum SnapCaveatType {
2+
PermittedDerivationPaths = 'permittedDerivationPaths',
3+
}

packages/rpc-methods/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
} from './permitted';
55
export {
66
builders as restrictedMethodPermissionBuilders,
7+
caveatSpecifications,
78
RestrictedMethodHooks,
89
} from './restricted';
910
export { selectHooks } from './utils';

packages/rpc-methods/src/permitted/invokeSnapSugar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { ethErrors } from 'eth-rpc-errors';
2-
import { SNAP_PREFIX } from '@metamask/snap-utils';
32
import {
43
PermittedHandlerExport,
54
JsonRpcRequest,
65
JsonRpcEngineNextCallback,
76
JsonRpcEngineEndCallback,
87
} from '@metamask/types';
98
import { isObject } from '@metamask/utils';
9+
import { SNAP_PREFIX } from '@metamask/snap-utils';
1010

1111
/**
1212
* `wallet_invokeSnap` attempts to invoke an RPC method of the specified Snap.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { SnapCaveatType } from '../caveats';
2+
import {
3+
getBip32EntropyBuilder,
4+
getBip32EntropyCaveatSpecifications,
5+
getBip32EntropyImplementation,
6+
validateCaveatPaths,
7+
validatePath,
8+
} from './getBip32Entropy';
9+
10+
const TEST_SECRET_RECOVERY_PHRASE =
11+
'test test test test test test test test test test test ball';
12+
13+
describe('validatePath', () => {
14+
it.each([true, false, null, undefined, 'foo', [], new (class {})()])(
15+
'throws if the value is not a plain object',
16+
(value) => {
17+
expect(() => validatePath(value)).toThrow('Expected a plain object.');
18+
},
19+
);
20+
21+
it.each([{}, { path: [] }, { path: 'foo' }])(
22+
'throws if the path is invalid or empty',
23+
() => {
24+
expect(() => validatePath({})).toThrow(
25+
'Invalid "path" parameter. The path must be a non-empty BIP-32 derivation path array.',
26+
);
27+
},
28+
);
29+
30+
it('throws if the path does not start with "m"', () => {
31+
expect(() => validatePath({ path: ["44'", "60'"] })).toThrow(
32+
'Invalid "path" parameter. The path must start with "m".',
33+
);
34+
});
35+
36+
it.each([
37+
{ path: ['m', 'foo'] },
38+
{ path: ['m', '0', 'bar'] },
39+
{ path: ['m', 0] },
40+
])('throws if the path is invalid', (value) => {
41+
expect(() => validatePath(value)).toThrow(
42+
'Invalid "path" parameter. The path must be a valid BIP-32 derivation path array.',
43+
);
44+
});
45+
46+
it.each([{ path: ['m'] }, { path: ['m', "44'"] }])(
47+
'throws if the path has a length of less than three',
48+
(value) => {
49+
expect(() => validatePath(value)).toThrow(
50+
'Invalid "path" parameter. Paths must have a length of at least three.',
51+
);
52+
},
53+
);
54+
55+
it('throws if the curve is invalid', () => {
56+
expect(() =>
57+
validatePath({ path: ['m', "44'", "60'"], curve: 'foo' }),
58+
).toThrow(
59+
'Invalid "curve" parameter. The curve must be "secp256k1" or "ed25519".',
60+
);
61+
});
62+
63+
it('throws if the curve is ed25519 and the path has an unhardened index', () => {
64+
expect(() =>
65+
validatePath({ path: ['m', "44'", "60'", '1'], curve: 'ed25519' }),
66+
).toThrow(
67+
'Invalid "path" parameter. Ed25519 does not support unhardened paths.',
68+
);
69+
});
70+
71+
it('does not throw if the path is valid', () => {
72+
expect(() =>
73+
validatePath({ path: ['m', "44'", "60'"], curve: 'secp256k1' }),
74+
).not.toThrow();
75+
76+
expect(() =>
77+
validatePath({ path: ['m', "44'", "60'"], curve: 'ed25519' }),
78+
).not.toThrow();
79+
});
80+
});
81+
82+
describe('validateCaveatPaths', () => {
83+
it.each([[], null, undefined, 'foo'])(
84+
'throws if the value is not an array or empty',
85+
(value) => {
86+
expect(() =>
87+
validateCaveatPaths({
88+
type: SnapCaveatType.PermittedDerivationPaths,
89+
value,
90+
}),
91+
).toThrow('Expected non-empty array of paths.');
92+
},
93+
);
94+
95+
it('throws if any of the paths is invalid', () => {
96+
expect(() =>
97+
validateCaveatPaths({
98+
type: SnapCaveatType.PermittedDerivationPaths,
99+
value: [{ path: ['foo'], curve: 'secp256k1' }],
100+
}),
101+
).toThrow('Invalid "path" parameter. The path must start with "m".');
102+
});
103+
});
104+
105+
describe('specificationBuilder', () => {
106+
const methodHooks = {
107+
getMnemonic: jest.fn(),
108+
getUnlockPromise: jest.fn(),
109+
};
110+
111+
const specification = getBip32EntropyBuilder.specificationBuilder({
112+
methodHooks,
113+
});
114+
115+
describe('validator', () => {
116+
it('throws if the caveat is not a single "permittedDerivationPaths"', () => {
117+
expect(() =>
118+
// @ts-expect-error Missing required permission types.
119+
specification.validator({}),
120+
).toThrow('Expected a single "permittedDerivationPaths" caveat.');
121+
122+
expect(() =>
123+
// @ts-expect-error Missing other required permission types.
124+
specification.validator({
125+
caveats: [{ type: 'foo', value: 'bar' }],
126+
}),
127+
).toThrow('Expected a single "permittedDerivationPaths" caveat.');
128+
129+
expect(() =>
130+
// @ts-expect-error Missing other required permission types.
131+
specification.validator({
132+
caveats: [
133+
{ type: 'permittedDerivationPaths', value: [] },
134+
{ type: 'permittedDerivationPaths', value: [] },
135+
],
136+
}),
137+
).toThrow('Expected a single "permittedDerivationPaths" caveat.');
138+
});
139+
});
140+
});
141+
142+
describe('getBip32EntropyCaveatSpecifications', () => {
143+
describe('decorator', () => {
144+
const params = { path: ['m', "44'", "60'"], curve: 'secp256k1' };
145+
146+
it('returns the result of the method implementation', async () => {
147+
const fn = jest.fn().mockImplementation(() => 'foo');
148+
149+
expect(
150+
await getBip32EntropyCaveatSpecifications[
151+
SnapCaveatType.PermittedDerivationPaths
152+
].decorator(fn, {
153+
type: SnapCaveatType.PermittedDerivationPaths,
154+
value: [params],
155+
// @ts-expect-error Missing other required properties.
156+
})({ params }),
157+
).toBe('foo');
158+
});
159+
160+
it('throws if the path is invalid', async () => {
161+
const fn = jest.fn().mockImplementation(() => 'foo');
162+
163+
await expect(
164+
getBip32EntropyCaveatSpecifications[
165+
SnapCaveatType.PermittedDerivationPaths
166+
].decorator(fn, {
167+
type: SnapCaveatType.PermittedDerivationPaths,
168+
value: [params],
169+
// @ts-expect-error Missing other required properties.
170+
})({ params: { ...params, path: [] } }),
171+
).rejects.toThrow(
172+
'Invalid "path" parameter. The path must be a non-empty BIP-32 derivation path array.',
173+
);
174+
});
175+
176+
it('throws if the path is not specified in the caveats', async () => {
177+
const fn = jest.fn().mockImplementation(() => 'foo');
178+
179+
await expect(
180+
getBip32EntropyCaveatSpecifications[
181+
SnapCaveatType.PermittedDerivationPaths
182+
].decorator(fn, {
183+
type: SnapCaveatType.PermittedDerivationPaths,
184+
value: [params],
185+
// @ts-expect-error Missing other required properties.
186+
})({ params: { ...params, path: ['m', "44'", "0'"] } }),
187+
).rejects.toThrow(
188+
'The requested path is not permitted. Allowed paths must be specified in the snap manifest.',
189+
);
190+
});
191+
});
192+
193+
describe('validator', () => {
194+
it('throws if the caveat values are invalid', () => {
195+
expect(() =>
196+
getBip32EntropyCaveatSpecifications[
197+
SnapCaveatType.PermittedDerivationPaths
198+
].validator?.({
199+
type: SnapCaveatType.PermittedDerivationPaths,
200+
value: [{ path: ['foo'], curve: 'secp256k1' }],
201+
}),
202+
).toThrow('Invalid "path" parameter. The path must start with "m".');
203+
});
204+
});
205+
});
206+
207+
describe('getBip32EntropyImplementation', () => {
208+
describe('getBip32Entropy', () => {
209+
it('derives the entropy from the path', async () => {
210+
const getUnlockPromise = jest.fn().mockResolvedValue(undefined);
211+
const getMnemonic = jest
212+
.fn()
213+
.mockResolvedValue(TEST_SECRET_RECOVERY_PHRASE);
214+
215+
expect(
216+
// @ts-expect-error Missing other required properties.
217+
await getBip32EntropyImplementation({ getUnlockPromise, getMnemonic })({
218+
params: { path: ['m', "44'", "60'"], curve: 'secp256k1' },
219+
}),
220+
).toMatchInlineSnapshot(`
221+
Object {
222+
"chainCode": "c4d424c253ca0eab92de6d8c819a37889e15a11bbf1cb6a48ffca2faef1f4d4d",
223+
"curve": "secp256k1",
224+
"depth": 2,
225+
"index": 2147483708,
226+
"parentFingerprint": 2557986109,
227+
"privateKey": "ca8d3571710e2b08628926f0ec14983aded0fd039518c59522c004e0e7eb4f5a",
228+
"publicKey": "041e31e8432aab932fe18b5f9798b7252394ff0b943920b40c50a79301062df5ece2b884a45c456241e35000137e6dbd92c9119ccd5f46cc92ba9568ca661b994b",
229+
}
230+
`);
231+
});
232+
});
233+
});

0 commit comments

Comments
 (0)