diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f6433eb73b..d267ce340f 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1022,24 +1022,24 @@ async function handleWalletUpdate(req: express.Request): Promise { * Changes a keychain's passphrase, re-encrypting the key to a new password * @param req */ -export async function handleKeychainChangePassword(req: express.Request): Promise { - const { oldPassword, newPassword, otp } = req.body; - if (!oldPassword || !newPassword) { - throw new ApiResponseError('Missing 1 or more required fields: [oldPassword, newPassword]', 400); - } +export async function handleKeychainChangePassword( + req: ExpressApiRouteRequest<'express.keychain.changePassword', 'post'> +): Promise { + const { oldPassword, newPassword, otp, coin: coinName, id } = req.decoded; const reqId = new RequestTracer(); const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); + const coin = bitgo.coin(coinName); if (otp) { await bitgo.unlock({ otp }); } const keychain = await coin.keychains().get({ - id: req.params.id, + id: id, reqId, }); + if (!keychain) { throw new ApiResponseError(`Keychain ${req.params.id} not found`, 404); } @@ -1610,12 +1610,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { app.put('/express/api/v2/:coin/wallet/:id', parseBody, prepareBitGo(config), promiseWrapper(handleWalletUpdate)); // change wallet passphrase - app.post( - '/api/v2/:coin/keychain/:id/changepassword', - parseBody, + router.post('express.keychain.changePassword', [ prepareBitGo(config), - promiseWrapper(handleKeychainChangePassword) - ); + typedPromiseWrapper(handleKeychainChangePassword), + ]); router.post('express.v2.wallet.createAddress', [prepareBitGo(config), typedPromiseWrapper(handleV2CreateAddress)]); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index b4db56f42d..ab5e379572 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -15,6 +15,7 @@ import { PutPendingApproval } from './v1/pendingApproval'; import { PostSignTransaction } from './v1/signTransaction'; import { PostKeychainLocal } from './v2/keychainLocal'; import { GetLightningState } from './v2/lightningState'; +import { PostKeychainChangePassword } from './v2/keychainChangePassword'; import { PostLightningInitWallet } from './v2/lightningInitWallet'; import { PostUnlockLightningWallet } from './v2/unlockWallet'; import { PostVerifyCoinAddress } from './v2/verifyAddress'; @@ -27,73 +28,151 @@ import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; -export const ExpressApi = apiSpec({ +// Too large types can cause the following error +// +// > error TS7056: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. +// +// As a workaround, only construct expressApi with a single key and add it to the type union at the end + +export const ExpressPingApiSpec = apiSpec({ 'express.ping': { get: GetPing, }, +}); + +export const ExpressPingExpressApiSpec = apiSpec({ 'express.pingExpress': { get: GetPingExpress, }, +}); + +export const ExpressLoginApiSpec = apiSpec({ 'express.login': { post: PostLogin, }, +}); + +export const ExpressDecryptApiSpec = apiSpec({ 'express.decrypt': { post: PostDecrypt, }, +}); + +export const ExpressEncryptApiSpec = apiSpec({ 'express.encrypt': { post: PostEncrypt, }, +}); + +export const ExpressVerifyAddressApiSpec = apiSpec({ 'express.verifyaddress': { post: PostVerifyAddress, }, +}); + +export const ExpressVerifyCoinAddressApiSpec = apiSpec({ + 'express.verifycoinaddress': { + post: PostVerifyCoinAddress, + }, +}); + +export const ExpressCalculateMinerFeeInfoApiSpec = apiSpec({ + 'express.calculateminerfeeinfo': { + post: PostCalculateMinerFeeInfo, + }, +}); + +export const ExpressV1WalletAcceptShareApiSpec = apiSpec({ 'express.v1.wallet.acceptShare': { post: PostAcceptShare, }, +}); + +export const ExpressV1WalletSimpleCreateApiSpec = apiSpec({ 'express.v1.wallet.simplecreate': { post: PostSimpleCreate, }, +}); + +export const ExpressV1PendingApprovalsApiSpec = apiSpec({ 'express.v1.pendingapprovals': { put: PutPendingApproval, }, +}); + +export const ExpressV1WalletSignTransactionApiSpec = apiSpec({ 'express.v1.wallet.signTransaction': { post: PostSignTransaction, }, - 'express.keychain.local': { - post: PostKeychainLocal, - }, - 'express.lightning.getState': { - get: GetLightningState, - }, - 'express.lightning.initWallet': { - post: PostLightningInitWallet, - }, - 'express.lightning.unlockWallet': { - post: PostUnlockLightningWallet, - }, - 'express.verifycoinaddress': { - post: PostVerifyCoinAddress, - }, - 'express.v2.wallet.createAddress': { - post: PostCreateAddress, - }, - 'express.calculateminerfeeinfo': { - post: PostCalculateMinerFeeInfo, - }, +}); + +export const ExpressV1KeychainDeriveApiSpec = apiSpec({ 'express.v1.keychain.derive': { post: PostDeriveLocalKeyChain, }, +}); + +export const ExpressV1KeychainLocalApiSpec = apiSpec({ 'express.v1.keychain.local': { post: PostCreateLocalKeyChain, }, +}); + +export const ExpressV1PendingApprovalConstructTxApiSpec = apiSpec({ 'express.v1.pendingapproval.constructTx': { put: PutConstructPendingApprovalTx, }, +}); + +export const ExpressV1WalletConsolidateUnspentsApiSpec = apiSpec({ 'express.v1.wallet.consolidateunspents': { put: PutConsolidateUnspents, }, +}); + +export const ExpressV1WalletFanoutUnspentsApiSpec = apiSpec({ 'express.v1.wallet.fanoutunspents': { put: PutFanoutUnspents, }, +}); + +export const ExpressV2WalletCreateAddressApiSpec = apiSpec({ + 'express.v2.wallet.createAddress': { + post: PostCreateAddress, + }, +}); + +export const ExpressKeychainLocalApiSpec = apiSpec({ + 'express.keychain.local': { + post: PostKeychainLocal, + }, +}); + +export const ExpressKeychainChangePasswordApiSpec = apiSpec({ + 'express.keychain.changePassword': { + post: PostKeychainChangePassword, + }, +}); + +export const ExpressLightningGetStateApiSpec = apiSpec({ + 'express.lightning.getState': { + get: GetLightningState, + }, +}); + +export const ExpressLightningInitWalletApiSpec = apiSpec({ + 'express.lightning.initWallet': { + post: PostLightningInitWallet, + }, +}); + +export const ExpressLightningUnlockWalletApiSpec = apiSpec({ + 'express.lightning.unlockWallet': { + post: PostUnlockLightningWallet, + }, +}); + +export const ExpressOfcSignPayloadApiSpec = apiSpec({ 'express.ofc.signPayload': { post: PostOfcSignPayload, }, @@ -102,7 +181,57 @@ export const ExpressApi = apiSpec({ }, }); -export type ExpressApi = typeof ExpressApi; +export type ExpressApi = typeof ExpressPingApiSpec & + typeof ExpressPingExpressApiSpec & + typeof ExpressLoginApiSpec & + typeof ExpressDecryptApiSpec & + typeof ExpressEncryptApiSpec & + typeof ExpressVerifyAddressApiSpec & + typeof ExpressVerifyCoinAddressApiSpec & + typeof ExpressCalculateMinerFeeInfoApiSpec & + typeof ExpressV1WalletAcceptShareApiSpec & + typeof ExpressV1WalletSimpleCreateApiSpec & + typeof ExpressV1PendingApprovalsApiSpec & + typeof ExpressV1WalletSignTransactionApiSpec & + typeof ExpressV1KeychainDeriveApiSpec & + typeof ExpressV1KeychainLocalApiSpec & + typeof ExpressV1PendingApprovalConstructTxApiSpec & + typeof ExpressV1WalletConsolidateUnspentsApiSpec & + typeof ExpressV1WalletFanoutUnspentsApiSpec & + typeof ExpressV2WalletCreateAddressApiSpec & + typeof ExpressKeychainLocalApiSpec & + typeof ExpressKeychainChangePasswordApiSpec & + typeof ExpressLightningGetStateApiSpec & + typeof ExpressLightningInitWalletApiSpec & + typeof ExpressLightningUnlockWalletApiSpec & + typeof ExpressOfcSignPayloadApiSpec; + +export const ExpressApi: ExpressApi = { + ...ExpressPingApiSpec, + ...ExpressPingExpressApiSpec, + ...ExpressLoginApiSpec, + ...ExpressDecryptApiSpec, + ...ExpressEncryptApiSpec, + ...ExpressVerifyAddressApiSpec, + ...ExpressVerifyCoinAddressApiSpec, + ...ExpressCalculateMinerFeeInfoApiSpec, + ...ExpressV1WalletAcceptShareApiSpec, + ...ExpressV1WalletSimpleCreateApiSpec, + ...ExpressV1PendingApprovalsApiSpec, + ...ExpressV1WalletSignTransactionApiSpec, + ...ExpressV1KeychainDeriveApiSpec, + ...ExpressV1KeychainLocalApiSpec, + ...ExpressV1PendingApprovalConstructTxApiSpec, + ...ExpressV1WalletConsolidateUnspentsApiSpec, + ...ExpressV1WalletFanoutUnspentsApiSpec, + ...ExpressV2WalletCreateAddressApiSpec, + ...ExpressKeychainLocalApiSpec, + ...ExpressKeychainChangePasswordApiSpec, + ...ExpressLightningGetStateApiSpec, + ...ExpressLightningInitWalletApiSpec, + ...ExpressLightningUnlockWalletApiSpec, + ...ExpressOfcSignPayloadApiSpec, +}; type ExtractDecoded = T extends t.Type ? O : never; type FlattenDecoded = T extends Record diff --git a/modules/express/src/typedRoutes/api/v2/keychainChangePassword.ts b/modules/express/src/typedRoutes/api/v2/keychainChangePassword.ts new file mode 100644 index 0000000000..7e55887a83 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/keychainChangePassword.ts @@ -0,0 +1,51 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for changing a keychain's password + */ +export const KeychainChangePasswordParams = { + /** Coin identifier (e.g. btc, tbtc, eth) */ + coin: t.string, + /** The keychain id */ + id: t.string, +} as const; + +/** + * Request body for changing a keychain's password + */ +export const KeychainChangePasswordBody = { + /** The old password used to encrypt the keychain */ + oldPassword: t.string, + /** The new password to re-encrypt the keychain */ + newPassword: t.string, + /** Optional OTP to unlock the session if required */ + otp: optional(t.string), +} as const; + +/** + * Response for changing a keychain's password + */ +export const KeychainChangePasswordResponse = { + /** Successful update */ + 200: t.unknown, + /** Invalid request or not found */ + 400: BitgoExpressError, + 404: BitgoExpressError, +} as const; + +/** + * Change a keychain's passphrase, re-encrypting the key to a new password. + * + * @operationId express.v2.keychain.changePassword + */ +export const PostKeychainChangePassword = httpRoute({ + path: '/api/v2/{coin}/keychain/{id}/changepassword', + method: 'POST', + request: httpRequest({ + params: KeychainChangePasswordParams, + body: KeychainChangePasswordBody, + }), + response: KeychainChangePasswordResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts b/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts index fd4b0cebb0..b98a027714 100644 --- a/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts +++ b/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts @@ -1,14 +1,12 @@ import * as sinon from 'sinon'; - import 'should-http'; import 'should-sinon'; import '../../lib/asserts'; - -import * as express from 'express'; - import { handleKeychainChangePassword } from '../../../src/clientRoutes'; - import { BitGo } from 'bitgo'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; +import { decodeOrElse } from '@bitgo/sdk-core'; +import { KeychainChangePasswordResponse } from '../../../src/typedRoutes/api/v2/keychainChangePassword'; describe('Change Wallet Password', function () { it('should change wallet password', async function () { @@ -36,19 +34,32 @@ describe('Change Wallet Password', function () { }), }); + const coin = 'talgo'; + const id = '23423423423423'; + const oldPassword = 'oldPasswordString'; + const newPassword = 'newPasswordString'; const mockRequest = { bitgo: stubBitgo, params: { - coin: 'talgo', - id: '23423423423423', + coin, + id, }, body: { - oldPassword: 'oldPasswordString', - newPassword: 'newPasswordString', + oldPassword, + newPassword, }, - }; + decoded: { + oldPassword, + newPassword, + coin, + id, + }, + } as unknown as ExpressApiRouteRequest<'express.keychain.changePassword', 'post'>; - const result = await handleKeychainChangePassword(mockRequest as express.Request & typeof mockRequest); + const result = await handleKeychainChangePassword(mockRequest); + decodeOrElse('KeychainChangePasswordResponse200', KeychainChangePasswordResponse[200], result, (errors) => { + throw new Error(`Response did not match expected codec: ${errors}`); + }); ({ result: '200 OK' }).should.be.eql(result); }); }); diff --git a/modules/express/test/unit/typedRoutes/decodeTests/keychainChangePassword.ts b/modules/express/test/unit/typedRoutes/decodeTests/keychainChangePassword.ts new file mode 100644 index 0000000000..e2b7dcedf3 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/decodeTests/keychainChangePassword.ts @@ -0,0 +1,32 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + KeychainChangePasswordBody, + KeychainChangePasswordParams, +} from '../../../../src/typedRoutes/api/v2/keychainChangePassword'; +import { assertDecode } from '../decode'; + +describe('express.keychain.changePassword', function () { + it('decodes params', function () { + // missing id + assert.throws(() => assertDecode(t.type(KeychainChangePasswordParams), { coin: 'btc' })); + // invalid coin type + assert.throws(() => assertDecode(t.type(KeychainChangePasswordParams), { coin: 123, id: 'abc' })); + // valid params + assertDecode(t.type(KeychainChangePasswordParams), { coin: 'btc', id: 'abc' }); + }); + + it('decodes body', function () { + // missing required fields + assert.throws(() => assertDecode(t.type(KeychainChangePasswordBody), {})); + assert.throws(() => assertDecode(t.type(KeychainChangePasswordBody), { oldPassword: 'a' })); + assert.throws(() => assertDecode(t.type(KeychainChangePasswordBody), { newPassword: 'b' })); + // invalid types + assert.throws(() => assertDecode(t.type(KeychainChangePasswordBody), { oldPassword: 1, newPassword: 'b' })); + assert.throws(() => assertDecode(t.type(KeychainChangePasswordBody), { oldPassword: 'a', newPassword: 2 })); + // valid minimal + assertDecode(t.type(KeychainChangePasswordBody), { oldPassword: 'a', newPassword: 'b' }); + // valid with optional otp + assertDecode(t.type(KeychainChangePasswordBody), { oldPassword: 'a', newPassword: 'b', otp: '123456' }); + }); +});