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'); + }); + }); +});