From b73a598673aa9b5a59c79bc94f25610cd417b059 Mon Sep 17 00:00:00 2001 From: Lokesh Chandra Date: Wed, 12 Nov 2025 07:47:18 +0530 Subject: [PATCH] fix(express): createLocalKeyChain type codec Ticket: WP-6717 --- .../typedRoutes/api/v1/createLocalKeyChain.ts | 28 +++-- .../unit/typedRoutes/createLocalKeyChain.ts | 108 ++++-------------- 2 files changed, 37 insertions(+), 99 deletions(-) diff --git a/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts b/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts index 8113bf1e99..f897e306b5 100644 --- a/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts +++ b/modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts @@ -3,10 +3,14 @@ import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; import { BitgoExpressError } from '../../schemas/error'; /** - * Request parameters for creating a local keychain + * Request body for creating a local keychain */ export const CreateLocalKeyChainRequestBody = { - /** Optional seed for key generation (use with caution) */ + /** + * Optional seed for key generation. If not provided, a random seed with 512 bits + * of entropy will be generated for maximum security. The seed is used to derive a BIP32 + * extended key pair. + */ seed: optional(t.string), }; @@ -14,24 +18,26 @@ export const CreateLocalKeyChainRequestBody = { * Response for creating a local keychain */ export const CreateLocalKeyChainResponse = t.type({ - /** The extended private key */ + /** The extended private key in BIP32 format (xprv...) */ xprv: t.string, - /** The extended public key */ + /** The extended public key in BIP32 format (xpub...) */ xpub: t.string, - /** The Ethereum address derived from the xpub (if available) */ + /** Ethereum address derived from the extended public key (only available when Ethereum utilities are accessible) */ ethAddress: optional(t.string), }); /** * Create a local keychain * - * Locally creates a new keychain. This is a client-side function that does not - * involve any server-side operations. Returns an object containing the xprv and xpub - * for the new chain. The created keychain is not known to the BitGo service. - * To use it with the BitGo service, use the 'Add Keychain' API call. + * Locally creates a new keychain using BIP32 HD (Hierarchical Deterministic) key derivation. + * This is a client-side operation that does not involve any server-side operations. * - * For security reasons, it is highly recommended that you encrypt and destroy - * the original xprv immediately to prevent theft. + * Returns an object containing the xprv and xpub keys in BIP32 extended key format. + * The created keychain is not known to the BitGo service. To use it with BitGo, + * you must add it using the 'Add Keychain' API call. + * + * For security reasons, it is highly recommended that you encrypt the private key + * immediately and securely destroy the unencrypted original to prevent theft. * * @operationId express.v1.keychain.local * @tag express diff --git a/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts b/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts index 7ddb7fbc0e..40dfde44d4 100644 --- a/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts +++ b/modules/express/test/unit/typedRoutes/createLocalKeyChain.ts @@ -43,7 +43,7 @@ describe('CreateLocalKeyChain codec tests', function () { }); describe('CreateLocalKeyChainResponse', function () { - it('should validate response with required fields', function () { + it('should validate response with required xprv field', function () { const validResponse = { xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', @@ -52,20 +52,17 @@ describe('CreateLocalKeyChain codec tests', function () { const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse); assert.strictEqual(decoded.xprv, validResponse.xprv); assert.strictEqual(decoded.xpub, validResponse.xpub); - assert.strictEqual(decoded.ethAddress, undefined); // Optional field }); - it('should validate response with all fields including optional ones', function () { + it('should validate response with both xprv and xpub fields', function () { const validResponse = { xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', - ethAddress: '0x1234567890123456789012345678901234567890', }; const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse); assert.strictEqual(decoded.xprv, validResponse.xprv); assert.strictEqual(decoded.xpub, validResponse.xpub); - assert.strictEqual(decoded.ethAddress, validResponse.ethAddress); }); it('should reject response with missing xprv', function () { @@ -78,14 +75,15 @@ describe('CreateLocalKeyChain codec tests', function () { }); }); - it('should reject response with missing xpub', function () { - const invalidResponse = { + it('should allow response with missing ethAddress (optional)', function () { + const validResponse = { xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', }; - assert.throws(() => { - assertDecode(CreateLocalKeyChainResponse, invalidResponse); - }); + const decoded = assertDecode(CreateLocalKeyChainResponse, validResponse); + assert.strictEqual(decoded.xprv, validResponse.xprv); + assert.strictEqual(decoded.xpub, validResponse.xpub); }); it('should reject response with non-string xprv', function () { @@ -109,18 +107,6 @@ describe('CreateLocalKeyChain codec tests', function () { assertDecode(CreateLocalKeyChainResponse, invalidResponse); }); }); - - it('should reject response with non-string ethAddress', function () { - const invalidResponse = { - xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', - xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', - ethAddress: 123, // number instead of string - }; - - assert.throws(() => { - assertDecode(CreateLocalKeyChainResponse, invalidResponse); - }); - }); }); describe('Edge cases', function () { @@ -177,6 +163,7 @@ describe('CreateLocalKeyChain codec tests', function () { const mockKeychainResponse = { xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + ethAddress: '0x1234567890123456789012345678901234567890', }; afterEach(function () { @@ -541,6 +528,7 @@ describe('CreateLocalKeyChain codec tests', function () { create: sinon.stub().resolves({ xprv: longXprv, xpub: longXpub, + ethAddress: '0x1234567890123456789012345678901234567890', }), }; @@ -560,32 +548,6 @@ describe('CreateLocalKeyChain codec tests', function () { assert.strictEqual(decodedResponse.xprv, longXprv); }); - it('should handle response with empty ethAddress', async function () { - const requestBody = {}; - - const mockKeychains = { - create: sinon.stub().resolves({ - xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', - xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', - ethAddress: '', - }), - }; - - sinon.stub(BitGo.prototype, 'keychains').returns(mockKeychains as any); - - const result = await agent - .post('/api/v1/keychain/local') - .set('Authorization', 'Bearer test_access_token_12345') - .set('Content-Type', 'application/json') - .send(requestBody); - - assert.strictEqual(result.status, 200); - assert.strictEqual(result.body.ethAddress, ''); - - const decodedResponse = assertDecode(CreateLocalKeyChainResponse, result.body); - assert.strictEqual(decodedResponse.ethAddress, ''); - }); - it('should handle response with additional unexpected fields', async function () { const requestBody = {}; @@ -608,11 +570,7 @@ describe('CreateLocalKeyChain codec tests', function () { assert.strictEqual(result.status, 200); // Codec validation should still pass with required fields present - const decodedResponse = assertDecode(CreateLocalKeyChainResponse, { - xprv: result.body.xprv, - xpub: result.body.xpub, - ethAddress: result.body.ethAddress, - }); + const decodedResponse = assertDecode(CreateLocalKeyChainResponse, result.body); assert.ok(decodedResponse); }); @@ -683,15 +641,17 @@ describe('CreateLocalKeyChain codec tests', function () { }); }); - it('should reject response with missing xpub field', async function () { + it('should allow response with missing ethAddress field (optional)', async function () { const requestBody = {}; - const invalidResponse = { + const validResponse = { xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + // ethAddress is optional, so missing it is valid }; const mockKeychains = { - create: sinon.stub().resolves(invalidResponse), + create: sinon.stub().resolves(validResponse), }; sinon.stub(BitGo.prototype, 'keychains').returns(mockKeychains as any); @@ -702,11 +662,11 @@ describe('CreateLocalKeyChain codec tests', function () { .set('Content-Type', 'application/json') .send(requestBody); - // Framework returns 200 with invalid response, codec validation should fail + // pub is optional, so this should pass assert.strictEqual(result.status, 200); - assert.throws(() => { - assertDecode(CreateLocalKeyChainResponse, result.body); - }); + const decodedResponse = assertDecode(CreateLocalKeyChainResponse, result.body); + assert.strictEqual(decodedResponse.xprv, validResponse.xprv); + assert.strictEqual(decodedResponse.xpub, validResponse.xpub); }); it('should reject response with wrong type for xprv', async function () { @@ -763,34 +723,6 @@ describe('CreateLocalKeyChain codec tests', function () { }); }); - it('should reject response with wrong type for ethAddress', async function () { - const requestBody = {}; - - const invalidResponse = { - xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', - xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', - ethAddress: 123, // number instead of string - }; - - const mockKeychains = { - create: sinon.stub().resolves(invalidResponse), - }; - - sinon.stub(BitGo.prototype, 'keychains').returns(mockKeychains as any); - - const result = await agent - .post('/api/v1/keychain/local') - .set('Authorization', 'Bearer test_access_token_12345') - .set('Content-Type', 'application/json') - .send(requestBody); - - // Framework returns 200 with invalid response, codec validation should fail - assert.strictEqual(result.status, 200); - assert.throws(() => { - assertDecode(CreateLocalKeyChainResponse, result.body); - }); - }); - it('should reject response with empty object', async function () { const requestBody = {};