From 888bdee33a4cbd84d705565990f70457ea864d63 Mon Sep 17 00:00:00 2001 From: Support Bot Date: Fri, 24 Jul 2026 06:05:14 +0000 Subject: [PATCH] feat(express): add API endpoint to verify private key matches wallet Add POST /api/v2/{coin}/wallet/{id}/verifyPrivateKey endpoint to BitGo Express. The endpoint accepts either a plaintext private key (prv) or an encrypted private key (encryptedPrv + walletPassphrase) and verifies that it matches the wallet's user keychain by calling coin.assertIsValidKey. When no publicKey is provided the user keychain's pub (or commonKeychain for TSS wallets) is fetched from BitGo automatically. Ticket: WCI-1139 Co-Authored-By: Claude Session-Id: b17db9f7-ab01-4cc9-ba52-3b1fb87cb276 Task-Id: 77daf7cf-15f3-4405-9967-bd3c8150be94 --- modules/express/src/clientRoutes.ts | 39 +++ modules/express/src/typedRoutes/api/index.ts | 9 + .../typedRoutes/api/v2/verifyPrivateKey.ts | 91 ++++++ .../test/unit/typedRoutes/verifyPrivateKey.ts | 261 ++++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 modules/express/src/typedRoutes/api/v2/verifyPrivateKey.ts create mode 100644 modules/express/test/unit/typedRoutes/verifyPrivateKey.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index dd5fdc99a2..9a7dcf7cfb 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -801,6 +801,41 @@ export async function handleV2IsWalletAddress( return await wallet.baseCoin.isWalletAddress(req.decoded as any); } +/** + * handle v2 verifyPrivateKey - verify that a private key matches a wallet's user keychain + * @param req + */ +export async function handleV2VerifyPrivateKey( + req: ExpressApiRouteRequest<'express.v2.wallet.verifyPrivateKey', 'post'> +): Promise<{ valid: boolean }> { + const bitgo = req.bitgo; + const coin = bitgo.coin(req.decoded.coin); + const { prv, encryptedPrv, walletPassphrase, multiSigType, publicKey: explicitPublicKey } = req.decoded; + + if (!prv && !encryptedPrv) { + throw new ApiResponseError('either prv or encryptedPrv is required', 400); + } + if (encryptedPrv && !walletPassphrase) { + throw new ApiResponseError('walletPassphrase is required when encryptedPrv is provided', 400); + } + + let resolvedPublicKey = explicitPublicKey; + if (!resolvedPublicKey) { + const wallet = await coin.wallets().get({ id: req.decoded.id }); + const keychains = await coin.keychains().getKeysForSigning({ wallet }); + const userKeychain = keychains[0]; + resolvedPublicKey = userKeychain.commonKeychain ?? userKeychain.pub; + } + + const auditParams = + prv !== undefined + ? { prv, multiSigType, publicKey: resolvedPublicKey } + : { encryptedPrv: encryptedPrv!, walletPassphrase: walletPassphrase!, multiSigType, publicKey: resolvedPublicKey }; + + await coin.assertIsValidKey(auditParams as any); + return { valid: true }; +} + /** * handle v2 deriveAddress - locally derive and return a wallet receive address from a * derivation path, using public key material only. @@ -2057,6 +2092,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { prepareBitGo(config), typedPromiseWrapper(handleV2IsWalletAddress), ]); + router.post('express.v2.wallet.verifyPrivateKey', [ + prepareBitGo(config), + typedPromiseWrapper(handleV2VerifyPrivateKey), + ]); router.post('express.v2.address.derive', [prepareBitGo(config), typedPromiseWrapper(handleV2DeriveAddress)]); router.post('express.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 85adc8a2ea..232e3c1717 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -58,6 +58,7 @@ import { PostWalletEnableTokens } from './v2/walletEnableTokens'; import { PostWalletSweep } from './v2/walletSweep'; import { PostWalletAccelerateTx } from './v2/walletAccelerateTx'; import { PostIsWalletAddress } from './v2/isWalletAddress'; +import { PostVerifyPrivateKey } from './v2/verifyPrivateKey'; import { PostDeriveAddress } from './v2/deriveAddress'; import { GetAccountResources } from './v2/accountResources'; import { GetResourceDelegations } from './v2/resourceDelegations'; @@ -236,6 +237,12 @@ export const ExpressV2WalletIsWalletAddressApiSpec = apiSpec({ }, }); +export const ExpressV2WalletVerifyPrivateKeyApiSpec = apiSpec({ + 'express.v2.wallet.verifyPrivateKey': { + post: PostVerifyPrivateKey, + }, +}); + export const ExpressV2AddressDeriveApiSpec = apiSpec({ 'express.v2.address.derive': { post: PostDeriveAddress, @@ -406,6 +413,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressWalletFanoutUnspentsApiSpec & typeof ExpressV2WalletCreateAddressApiSpec & typeof ExpressV2WalletIsWalletAddressApiSpec & + typeof ExpressV2WalletVerifyPrivateKeyApiSpec & typeof ExpressV2AddressDeriveApiSpec & typeof ExpressKeychainLocalApiSpec & typeof ExpressKeychainChangePasswordApiSpec & @@ -452,6 +460,7 @@ export const ExpressApi: ExpressApi = { ...ExpressV2WalletCreateAddressApiSpec, ...ExpressV2WalletConsolidateAccountApiSpec, ...ExpressV2WalletIsWalletAddressApiSpec, + ...ExpressV2WalletVerifyPrivateKeyApiSpec, ...ExpressV2AddressDeriveApiSpec, ...ExpressKeychainLocalApiSpec, ...ExpressKeychainChangePasswordApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/verifyPrivateKey.ts b/modules/express/src/typedRoutes/api/v2/verifyPrivateKey.ts new file mode 100644 index 0000000000..83ae8a03fd --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/verifyPrivateKey.ts @@ -0,0 +1,91 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional, type HttpRoute } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for verifying a private key against a wallet + */ +export const VerifyPrivateKeyParams = { + /** A cryptocurrency or token ticker symbol. */ + coin: t.string, + /** The wallet ID */ + id: t.string, +} as const; + +/** + * Request body for verifying that a private key matches a wallet's user keychain. + * Exactly one of prv or encryptedPrv must be provided. + */ +export const VerifyPrivateKeyBody = { + /** + * The plaintext private key to verify. + * Mutually exclusive with encryptedPrv + walletPassphrase. + */ + prv: optional(t.string), + + /** + * Encrypted private key (BitGo keycard format). + * Must be supplied together with walletPassphrase. + */ + encryptedPrv: optional(t.string), + + /** + * Passphrase used to decrypt encryptedPrv. + * Required when encryptedPrv is provided. + */ + walletPassphrase: optional(t.string), + + /** + * The multisig type of the wallet. + * Use 'tss' for TSS/MPC wallets; omit or use 'onchain' for standard wallets. + */ + multiSigType: optional(t.union([t.literal('tss'), t.literal('onchain')])), + + /** + * The public key corresponding to the private key. + * Required for TSS wallets where it is the common keychain. + * For standard wallets, if omitted the user keychain pub is fetched from the wallet. + */ + publicKey: optional(t.string), +} as const; + +/** + * Successful verification response + */ +export const VerifyPrivateKeyResponse200 = t.type({ + /** Whether the private key is valid for the wallet's user keychain */ + valid: t.boolean, +}); + +/** + * Response for verifying a private key against a wallet + */ +export const VerifyPrivateKeyResponse = { + 200: VerifyPrivateKeyResponse200, + 400: BitgoExpressError, +} as const; + +/** + * Verify that a private key matches the user keychain of a wallet. + * + * This endpoint accepts either a plaintext private key (prv) or an encrypted + * private key (encryptedPrv + walletPassphrase). It fetches the wallet's user + * keychain from BitGo to obtain the corresponding public key, then calls + * coin.assertIsValidKey to cryptographically verify the private key. + * + * Returns { valid: true } when the private key is valid for the wallet. + * Returns a 400 error if required parameters are missing or invalid. + * Throws if the private key does not match the public key on record. + * + * @operationId express.v2.wallet.verifyPrivateKey + * @tag Express + */ +export const PostVerifyPrivateKey: HttpRoute<'post'> = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/verifyPrivateKey', + method: 'POST', + request: httpRequest({ + params: VerifyPrivateKeyParams, + body: VerifyPrivateKeyBody, + }), + response: VerifyPrivateKeyResponse, +}); diff --git a/modules/express/test/unit/typedRoutes/verifyPrivateKey.ts b/modules/express/test/unit/typedRoutes/verifyPrivateKey.ts new file mode 100644 index 0000000000..f60cd9d6ec --- /dev/null +++ b/modules/express/test/unit/typedRoutes/verifyPrivateKey.ts @@ -0,0 +1,261 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + VerifyPrivateKeyBody, + VerifyPrivateKeyParams, + VerifyPrivateKeyResponse, + PostVerifyPrivateKey, + VerifyPrivateKeyResponse200, +} from '../../../src/typedRoutes/api/v2/verifyPrivateKey'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('VerifyPrivateKey codec tests', function () { + describe('VerifyPrivateKeyParams', function () { + it('should validate params with coin and wallet id', function () { + const validParams = { coin: 'tbtc', id: 'wallet123' }; + const decoded = assertDecode(t.type(VerifyPrivateKeyParams), validParams); + assert.strictEqual(decoded.coin, 'tbtc'); + assert.strictEqual(decoded.id, 'wallet123'); + }); + + it('should reject params with missing coin', function () { + assert.throws(() => assertDecode(t.type(VerifyPrivateKeyParams), { id: 'wallet123' })); + }); + + it('should reject params with missing id', function () { + assert.throws(() => assertDecode(t.type(VerifyPrivateKeyParams), { coin: 'tbtc' })); + }); + }); + + describe('VerifyPrivateKeyBody', function () { + it('should validate body with prv only', function () { + const body = { prv: 'xprv123...' }; + const decoded = assertDecode(t.type(VerifyPrivateKeyBody), body); + assert.strictEqual(decoded.prv, 'xprv123...'); + }); + + it('should validate body with encryptedPrv and walletPassphrase', function () { + const body = { encryptedPrv: '{"ct":"..."}', walletPassphrase: 'mypassword' }; + const decoded = assertDecode(t.type(VerifyPrivateKeyBody), body); + assert.strictEqual(decoded.encryptedPrv, '{"ct":"..."}'); + assert.strictEqual(decoded.walletPassphrase, 'mypassword'); + }); + + it('should validate body with prv and multiSigType tss', function () { + const body = { prv: 'xprv123...', multiSigType: 'tss' as const }; + const decoded = assertDecode(t.type(VerifyPrivateKeyBody), body); + assert.strictEqual(decoded.multiSigType, 'tss'); + }); + + it('should validate body with prv and explicit publicKey', function () { + const body = { prv: 'xprv123...', publicKey: 'xpub...' }; + const decoded = assertDecode(t.type(VerifyPrivateKeyBody), body); + assert.strictEqual(decoded.publicKey, 'xpub...'); + }); + + it('should validate empty body (all fields optional)', function () { + const body = {}; + const decoded = assertDecode(t.type(VerifyPrivateKeyBody), body); + assert.strictEqual(decoded.prv, undefined); + }); + + it('should reject body with invalid multiSigType', function () { + assert.throws(() => assertDecode(t.type(VerifyPrivateKeyBody), { prv: 'xprv...', multiSigType: 'invalid' })); + }); + }); + + describe('VerifyPrivateKeyResponse200', function () { + it('should validate response with valid: true', function () { + const decoded = assertDecode(VerifyPrivateKeyResponse200, { valid: true }); + assert.strictEqual(decoded.valid, true); + }); + + it('should validate response with valid: false', function () { + const decoded = assertDecode(VerifyPrivateKeyResponse200, { valid: false }); + assert.strictEqual(decoded.valid, false); + }); + + it('should reject response missing valid field', function () { + assert.throws(() => assertDecode(VerifyPrivateKeyResponse200, {})); + }); + }); + + describe('PostVerifyPrivateKey route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostVerifyPrivateKey.path, '/api/v2/{coin}/wallet/{id}/verifyPrivateKey'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostVerifyPrivateKey.method, 'POST'); + }); + + it('should have 200 and 400 response types', function () { + assert.ok(PostVerifyPrivateKey.response[200]); + assert.ok(PostVerifyPrivateKey.response[400]); + }); + }); + + describe('Supertest integration tests', function () { + const agent = setupAgent(); + + afterEach(function () { + sinon.restore(); + }); + + function mockCoinWithKeychain(opts: { + assertIsValidKey?: (params: unknown) => Promise; + getKeysForSigning?: () => Promise; + }) { + const assertIsValidKeyStub = sinon.stub().callsFake(opts.assertIsValidKey ?? (() => Promise.resolve())); + const getKeysForSigningStub = sinon + .stub() + .callsFake(opts.getKeysForSigning ?? (() => Promise.resolve([{ pub: 'xpub...' }]))); + + const mockWallet = {}; + const walletsGetStub = sinon.stub().resolves(mockWallet); + + const mockCoin = { + wallets: sinon.stub().returns({ get: walletsGetStub }), + keychains: sinon.stub().returns({ getKeysForSigning: getKeysForSigningStub }), + assertIsValidKey: assertIsValidKeyStub, + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + return { assertIsValidKeyStub, getKeysForSigningStub, walletsGetStub }; + } + + it('should return valid:true for a matching prv with explicit publicKey', async function () { + const { assertIsValidKeyStub } = mockCoinWithKeychain({}); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ prv: 'xprv...', publicKey: 'xpub...' }); + + assert.strictEqual(result.status, 200); + assert.deepStrictEqual(result.body, { valid: true }); + sinon.assert.calledOnce(assertIsValidKeyStub); + }); + + it('should fetch public key from wallet keychains when publicKey is omitted', async function () { + const { assertIsValidKeyStub, getKeysForSigningStub } = mockCoinWithKeychain({ + getKeysForSigning: () => Promise.resolve([{ pub: 'xpubFromWallet' }]), + }); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ prv: 'xprv...' }); + + assert.strictEqual(result.status, 200); + assert.deepStrictEqual(result.body, { valid: true }); + sinon.assert.calledOnce(getKeysForSigningStub); + sinon.assert.calledOnce(assertIsValidKeyStub); + const callArgs = assertIsValidKeyStub.firstCall.args[0]; + assert.strictEqual(callArgs.publicKey, 'xpubFromWallet'); + }); + + it('should use commonKeychain over pub when keychain has both', async function () { + const { assertIsValidKeyStub } = mockCoinWithKeychain({ + getKeysForSigning: () => Promise.resolve([{ pub: 'xpub', commonKeychain: 'commonKC' }]), + }); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ prv: 'xprv...' }); + + assert.strictEqual(result.status, 200); + const callArgs = assertIsValidKeyStub.firstCall.args[0]; + assert.strictEqual(callArgs.publicKey, 'commonKC'); + }); + + it('should pass multiSigType tss to assertIsValidKey', async function () { + const { assertIsValidKeyStub } = mockCoinWithKeychain({}); + + await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ prv: 'xprv...', publicKey: 'xpub...', multiSigType: 'tss' }); + + const callArgs = assertIsValidKeyStub.firstCall.args[0]; + assert.strictEqual(callArgs.multiSigType, 'tss'); + }); + + it('should accept encryptedPrv with walletPassphrase', async function () { + const { assertIsValidKeyStub } = mockCoinWithKeychain({}); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ encryptedPrv: '{"ct":"..."}', walletPassphrase: 'pass', publicKey: 'xpub...' }); + + assert.strictEqual(result.status, 200); + const callArgs = assertIsValidKeyStub.firstCall.args[0]; + assert.strictEqual(callArgs.encryptedPrv, '{"ct":"..."}'); + assert.strictEqual(callArgs.walletPassphrase, 'pass'); + }); + + it('should return 400 when neither prv nor encryptedPrv is provided', async function () { + mockCoinWithKeychain({}); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ publicKey: 'xpub...' }); + + assert.strictEqual(result.status, 400); + assert.ok(result.body.error); + }); + + it('should return 400 when encryptedPrv is provided without walletPassphrase', async function () { + mockCoinWithKeychain({}); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ encryptedPrv: '{"ct":"..."}' }); + + assert.strictEqual(result.status, 400); + assert.ok(result.body.error); + }); + + it('should propagate error when assertIsValidKey throws (key mismatch)', async function () { + mockCoinWithKeychain({ + assertIsValidKey: () => Promise.reject(new Error('private key does not match public key')), + }); + + const result = await agent + .post('/api/v2/tbtc/wallet/wallet123/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ prv: 'xprv_wrong...', publicKey: 'xpub...' }); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should propagate error when wallet not found', async function () { + const mockCoin = { + wallets: sinon.stub().returns({ get: sinon.stub().rejects(new Error('wallet not found')) }), + keychains: sinon.stub().returns({ getKeysForSigning: sinon.stub().resolves([]) }), + assertIsValidKey: sinon.stub().resolves(), + }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post('/api/v2/tbtc/wallet/nonexistent/verifyPrivateKey') + .set('Authorization', 'Bearer test_access_token_12345') + .send({ prv: 'xprv...' }); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + }); +});