Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions modules/express/src/typedRoutes/api/v1/createLocalKeyChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,41 @@ 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),
};

/**
* 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
Expand Down
108 changes: 20 additions & 88 deletions modules/express/test/unit/typedRoutes/createLocalKeyChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand Down Expand Up @@ -177,6 +163,7 @@ describe('CreateLocalKeyChain codec tests', function () {
const mockKeychainResponse = {
xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
xpub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8',
ethAddress: '0x1234567890123456789012345678901234567890',
};

afterEach(function () {
Expand Down Expand Up @@ -541,6 +528,7 @@ describe('CreateLocalKeyChain codec tests', function () {
create: sinon.stub().resolves({
xprv: longXprv,
xpub: longXpub,
ethAddress: '0x1234567890123456789012345678901234567890',
}),
};

Expand All @@ -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 = {};

Expand All @@ -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);
});

Expand Down Expand Up @@ -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);
Expand All @@ -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 () {
Expand Down Expand Up @@ -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 = {};

Expand Down