diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index e8ce03422d..69205bc699 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -125,7 +125,7 @@ function handleCreateLocalKeyChain(req: express.Request) { * @deprecated * @param req */ -function handleDeriveLocalKeyChain(req: express.Request) { +function handleDeriveLocalKeyChain(req: ExpressApiRouteRequest<'express.v1.keychain.derive', 'post'>) { return req.bitgo.keychains().deriveLocal(req.body); } @@ -1566,7 +1566,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ]); app.post('/api/v1/keychain/local', parseBody, prepareBitGo(config), promiseWrapper(handleCreateLocalKeyChain)); - app.post('/api/v1/keychain/derive', parseBody, prepareBitGo(config), promiseWrapper(handleDeriveLocalKeyChain)); + router.post('express.v1.keychain.derive', [prepareBitGo(config), typedPromiseWrapper(handleDeriveLocalKeyChain)]); router.post('express.v1.wallet.simplecreate', [ prepareBitGo(config), typedPromiseWrapper(handleCreateWalletWithKeychains), diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index d9791f9332..436664cf15 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -16,6 +16,7 @@ import { PostSignTransaction } from './v1/signTransaction'; import { PostKeychainLocal } from './v2/keychainLocal'; import { PostLightningInitWallet } from './v2/lightningInitWallet'; import { PostVerifyCoinAddress } from './v2/verifyAddress'; +import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -60,6 +61,9 @@ export const ExpressApi = apiSpec({ 'express.calculateminerfeeinfo': { post: PostCalculateMinerFeeInfo, }, + 'express.v1.keychain.derive': { + post: PostDeriveLocalKeyChain, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v1/deriveLocalKeyChain.ts b/modules/express/src/typedRoutes/api/v1/deriveLocalKeyChain.ts new file mode 100644 index 0000000000..ef8f649402 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v1/deriveLocalKeyChain.ts @@ -0,0 +1,61 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Request parameters for deriving a local keychain + */ +export const DeriveLocalKeyChainRequestBody = { + /** The derivation path to use (e.g. 'm/0/1') */ + path: t.string, + /** The extended private key to derive from (either xprv or xpub must be provided) */ + xprv: optional(t.string), + /** The extended public key to derive from (either xprv or xpub must be provided) */ + xpub: optional(t.string), +}; + +/** + * Response for deriving a local keychain + */ +export const DeriveLocalKeyChainResponse = t.type({ + /** The derivation path that was used */ + path: t.string, + /** The derived extended public key */ + xpub: t.string, + /** The derived extended private key (only included if xprv was provided in the request) */ + xprv: optional(t.string), + /** The Ethereum address derived from the xpub (if available) */ + ethAddress: optional(t.string), +}); + +/** + * Derive a local keychain + * + * Locally derives a keychain from a top level BIP32 string (xprv or xpub), given a path. + * This is useful for deriving child keys from a parent key without having to store the child keys. + * + * The derivation process: + * 1. Takes either an xprv (extended private key) or xpub (extended public key) as input + * 2. Derives a child key at the specified path using BIP32 derivation + * 3. Returns the derived xpub (and xprv if an xprv was provided) + * 4. Also attempts to derive an Ethereum address from the xpub if possible + * + * Note: You must provide either xprv or xpub, but not both. If xprv is provided, + * both the derived xprv and xpub are returned. If xpub is provided, only the + * derived xpub is returned. + * + * @operationId express.v1.keychain.derive + */ +export const PostDeriveLocalKeyChain = httpRoute({ + path: '/api/v1/keychain/derive', + method: 'POST', + request: httpRequest({ + body: DeriveLocalKeyChainRequestBody, + }), + response: { + /** Successfully derived keychain */ + 200: DeriveLocalKeyChainResponse, + /** Invalid request or derivation fails */ + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/deriveLocalKeyChain.ts b/modules/express/test/unit/typedRoutes/deriveLocalKeyChain.ts new file mode 100644 index 0000000000..d8bc2fe5e4 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/deriveLocalKeyChain.ts @@ -0,0 +1,227 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + DeriveLocalKeyChainRequestBody, + DeriveLocalKeyChainResponse, + PostDeriveLocalKeyChain, +} from '../../../src/typedRoutes/api/v1/deriveLocalKeyChain'; +import { assertDecode } from './common'; + +describe('DeriveLocalKeyChain codec tests', function () { + describe('DeriveLocalKeyChainRequestBody', function () { + it('should validate body with required path and xprv', function () { + const validBody = { + path: 'm/0/1', + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), validBody); + assert.strictEqual(decoded.path, validBody.path); + assert.strictEqual(decoded.xprv, validBody.xprv); + assert.strictEqual(decoded.xpub, undefined); // Optional field + }); + + it('should validate body with required path and xpub', function () { + const validBody = { + path: 'm/0/1', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), validBody); + assert.strictEqual(decoded.path, validBody.path); + assert.strictEqual(decoded.xpub, validBody.xpub); + assert.strictEqual(decoded.xprv, undefined); // Optional field + }); + + it('should reject body with missing path', function () { + const invalidBody = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + assert.throws(() => { + assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string path', function () { + const invalidBody = { + path: 123, // number instead of string + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + assert.throws(() => { + assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string xprv', function () { + const invalidBody = { + path: 'm/0/1', + xprv: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string xpub', function () { + const invalidBody = { + path: 'm/0/1', + xpub: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(DeriveLocalKeyChainRequestBody), invalidBody); + }); + }); + + // Note: The validation that either xprv or xpub must be provided is handled by the implementation, + // not by the io-ts codec, so we don't test for that here. + }); + + describe('DeriveLocalKeyChainResponse', function () { + it('should validate response with all required fields', function () { + const validResponse = { + path: 'm/0/1', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + const decoded = assertDecode(DeriveLocalKeyChainResponse, validResponse); + assert.strictEqual(decoded.path, validResponse.path); + assert.strictEqual(decoded.xpub, validResponse.xpub); + assert.strictEqual(decoded.xprv, undefined); // Optional field + assert.strictEqual(decoded.ethAddress, undefined); // Optional field + }); + + it('should validate response with all fields including optional ones', function () { + const validResponse = { + path: 'm/0/1', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + ethAddress: '0x1234567890123456789012345678901234567890', + }; + + const decoded = assertDecode(DeriveLocalKeyChainResponse, validResponse); + assert.strictEqual(decoded.path, validResponse.path); + assert.strictEqual(decoded.xpub, validResponse.xpub); + assert.strictEqual(decoded.xprv, validResponse.xprv); + assert.strictEqual(decoded.ethAddress, validResponse.ethAddress); + }); + + it('should reject response with missing path', function () { + const invalidResponse = { + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + assert.throws(() => { + assertDecode(DeriveLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with missing xpub', function () { + const invalidResponse = { + path: 'm/0/1', + }; + + assert.throws(() => { + assertDecode(DeriveLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string path', function () { + const invalidResponse = { + path: 123, // number instead of string + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + }; + + assert.throws(() => { + assertDecode(DeriveLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string xpub', function () { + const invalidResponse = { + path: 'm/0/1', + xpub: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(DeriveLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string xprv', function () { + const invalidResponse = { + path: 'm/0/1', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + xprv: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(DeriveLocalKeyChainResponse, invalidResponse); + }); + }); + + it('should reject response with non-string ethAddress', function () { + const invalidResponse = { + path: 'm/0/1', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + ethAddress: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(DeriveLocalKeyChainResponse, invalidResponse); + }); + }); + }); + + describe('Edge cases', function () { + it('should handle empty strings for string fields', function () { + const body = { + path: '', + xprv: '', + }; + + const decoded = assertDecode(t.type(DeriveLocalKeyChainRequestBody), body); + assert.strictEqual(decoded.path, ''); + assert.strictEqual(decoded.xprv, ''); + }); + + it('should handle additional unknown properties', function () { + const body = { + path: 'm/0/1', + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + unknownProperty: 'some value', + }; + + // io-ts with t.exact() strips out additional properties + const decoded = assertDecode(t.exact(t.type(DeriveLocalKeyChainRequestBody)), body); + assert.strictEqual(decoded.path, body.path); + assert.strictEqual(decoded.xprv, body.xprv); + // @ts-expect-error - unknownProperty doesn't exist on the type + assert.strictEqual(decoded.unknownProperty, undefined); + }); + }); + + describe('PostDeriveLocalKeyChain route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostDeriveLocalKeyChain.path, '/api/v1/keychain/derive'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostDeriveLocalKeyChain.method, 'POST'); + }); + + it('should have the correct request configuration', function () { + // Verify the route is configured with a request property + assert.ok(PostDeriveLocalKeyChain.request); + }); + + it('should have the correct response types', function () { + // Check that the response object has the expected status codes + assert.ok(PostDeriveLocalKeyChain.response[200]); + assert.ok(PostDeriveLocalKeyChain.response[400]); + }); + }); +});