diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index f4d36d60ba..09afff511c 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1031,27 +1031,23 @@ 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); - } const updatedKeychain = coin.keychains().updateSingleKeychainPassword({ keychain, @@ -1586,6 +1582,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ); router.post('express.v1.wallet.signTransaction', [prepareBitGo(config), typedPromiseWrapper(handleSignTransaction)]); + router.get('express.lightning.getState', [prepareBitGo(config), typedPromiseWrapper(handleGetLightningWalletState)]); app.post('/api/v1/wallet/:id/simpleshare', parseBody, prepareBitGo(config), promiseWrapper(handleShareWallet)); router.post('express.v1.wallet.acceptShare', [prepareBitGo(config), typedPromiseWrapper(handleAcceptShare)]); @@ -1621,12 +1618,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), + ]); // create address app.post('/api/v2/:coin/wallet/:id/address', parseBody, prepareBitGo(config), promiseWrapper(handleV2CreateAddress)); @@ -1787,5 +1782,4 @@ export function setupLightningSignerNodeRoutes(app: express.Application, config: prepareBitGo(config), promiseWrapper(handleCreateSignerMacaroon) ); - app.get('/api/v2/:coin/wallet/:id/state', prepareBitGo(config), promiseWrapper(handleGetLightningWalletState)); } diff --git a/modules/express/src/lightning/lightningSignerRoutes.ts b/modules/express/src/lightning/lightningSignerRoutes.ts index 05ac16e757..8ca7c6ac07 100644 --- a/modules/express/src/lightning/lightningSignerRoutes.ts +++ b/modules/express/src/lightning/lightningSignerRoutes.ts @@ -170,15 +170,13 @@ export async function handleCreateSignerMacaroon(req: express.Request): Promise< /** * Handle the request to get the state of a wallet from the signer. */ -export async function handleGetLightningWalletState(req: express.Request): Promise { - const coinName = req.params.coin; +export async function handleGetLightningWalletState( + req: ExpressApiRouteRequest<'express.lightning.getState', 'get'> +): Promise { + const { coin: coinName, walletId } = req.decoded; if (!isLightningCoinName(coinName)) { throw new ApiResponseError(`Invalid coin to get lightning wallet state: ${coinName}`, 400); } - const walletId = req.params.id; - if (typeof walletId !== 'string') { - throw new ApiResponseError(`Invalid wallet id: ${walletId}`, 400); - } const lndSignerClient = await LndSignerClient.create(walletId, req.config); return await lndSignerClient.getWalletState(); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index fa5871ff9b..76bf85303e 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -14,6 +14,8 @@ import { PostSimpleCreate } from './v1/simpleCreate'; import { PutPendingApproval } from './v1/pendingApproval'; import { PostSignTransaction } from './v1/signTransaction'; import { PostKeychainLocal } from './v2/keychainLocal'; +import { PostKeychainChangePassword } from './v2/keychainChangePassword'; +import { GetLightningState } from './v2/lightningState'; import { PostLightningInitWallet } from './v2/lightningInitWallet'; import { PostUnlockLightningWallet } from './v2/unlockWallet'; import { PostVerifyCoinAddress } from './v2/verifyAddress'; @@ -53,6 +55,12 @@ export const ExpressApi = apiSpec({ 'express.keychain.local': { post: PostKeychainLocal, }, + 'express.keychain.changePassword': { + post: PostKeychainChangePassword, + }, + 'express.lightning.getState': { + get: GetLightningState, + }, 'express.lightning.initWallet': { post: PostLightningInitWallet, }, 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/src/typedRoutes/api/v2/lightningState.ts b/modules/express/src/typedRoutes/api/v2/lightningState.ts new file mode 100644 index 0000000000..5c38159052 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/lightningState.ts @@ -0,0 +1,44 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { WalletState } from '../../../lightning/codecs'; + +/** + * Path parameters for getting lightning node state + */ +export const LightningStateParams = { + /** A lightning coin name (e.g., lnbtc or tlnbtc) */ + coin: t.string, + /** The ID of the lightning self-custody wallet */ + walletId: t.string, +} as const; + +export const LightningStateResponse200 = t.type({ + state: WalletState, +}); + +/** + * Response for getting lightning node state + */ +export const LightningStateResponse = { + /** Current Lightning wallet/node state('NON_EXISTING' | 'LOCKED' | 'UNLOCKED' | 'RPC_ACTIVE' | 'SERVER_ACTIVE' | 'WAITING_TO_START') */ + 200: LightningStateResponse200, + /** BitGo Express error payload when the request is invalid (e.g., invalid coin or wallet not a self-custody lightning wallet). */ + 400: BitgoExpressError, +} as const; + +/** + * Lightning - Get node state + * + * This is only used for self-custody lightning. Get the current state of the lightning node. + * + * @operationId express.lightning.getState + */ +export const GetLightningState = httpRoute({ + method: 'GET', + path: '/api/v2/{coin}/wallet/{walletId}/state', + request: httpRequest({ + params: LightningStateParams, + }), + response: LightningStateResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts b/modules/express/test/unit/clientRoutes/changeKeychainPassword.ts index fd4b0cebb0..19ce5fc930 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 'modules/express/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/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 82f693644c..805de7ebdd 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -16,6 +16,7 @@ import { } from '../../../../src/lightning/lightningSignerRoutes'; import { ExpressApiRouteRequest } from '../../../../src/typedRoutes/api'; import { PostLightningInitWallet } from '../../../../src/typedRoutes/api/v2/lightningInitWallet'; +import { LightningStateResponse } from '../../../../src/typedRoutes/api/v2/lightningState'; describe('Lightning signer routes', () => { let bitgo: TestBitGoAPI; @@ -165,13 +166,21 @@ describe('Lightning signer routes', () => { params: { coin: 'tlnbtc', id: apiData.wallet.id, + walletId: apiData.wallet.id, + }, + decoded: { + coin: 'tlnbtc', + walletId: apiData.wallet.id, }, config: { lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', }, - } as unknown as express.Request; + } as unknown as ExpressApiRouteRequest<'express.lightning.getState', 'get'>; - await handleGetLightningWalletState(req); + const res = await handleGetLightningWalletState(req); + decodeOrElse('LightningStateResponse200', LightningStateResponse[200], res, () => { + throw new Error('Response did not match expected codec'); + }); walletStateNock.done(); readFileStub.calledOnceWith('lightningSignerFileSystemPath').should.be.true(); diff --git a/modules/express/test/unit/typedRoutes/decode.ts b/modules/express/test/unit/typedRoutes/decode.ts index 4385b786b1..f06d6d9065 100644 --- a/modules/express/test/unit/typedRoutes/decode.ts +++ b/modules/express/test/unit/typedRoutes/decode.ts @@ -7,11 +7,16 @@ import { VerifyAddressBody } from '../../../src/typedRoutes/api/common/verifyAdd import { VerifyAddressV2Body, VerifyAddressV2Params } from '../../../src/typedRoutes/api/v2/verifyAddress'; import { SimpleCreateRequestBody } from '../../../src/typedRoutes/api/v1/simpleCreate'; import { KeychainLocalRequestParams } from '../../../src/typedRoutes/api/v2/keychainLocal'; +import { LightningStateParams } from '../../../src/typedRoutes/api/v2/lightningState'; import { LightningInitWalletBody, LightningInitWalletParams, } from '../../../src/typedRoutes/api/v2/lightningInitWallet'; import { UnlockLightningWalletBody, UnlockLightningWalletParams } from '../../../src/typedRoutes/api/v2/unlockWallet'; +import { + KeychainChangePasswordBody, + KeychainChangePasswordParams, +} from '../../../src/typedRoutes/api/v2/keychainChangePassword'; export function assertDecode(codec: t.Type, input: unknown): T { const result = codec.decode(input); @@ -157,6 +162,12 @@ describe('io-ts decode tests', function () { coin: 'tbtc', }); }); + it('express.lightning.getState params valid', function () { + assertDecode(t.type(LightningStateParams), { coin: 'lnbtc', walletId: 'wallet123' }); + }); + it('express.lightning.getState params invalid', function () { + assert.throws(() => assertDecode(t.type(LightningStateParams), { coin: 'lnbtc' })); + }); it('express.lightning.initWallet params', function () { // missing walletId assert.throws(() => assertDecode(t.type(LightningInitWalletParams), { coin: 'ltc' })); @@ -183,4 +194,24 @@ describe('io-ts decode tests', function () { // valid body assertDecode(t.type(UnlockLightningWalletBody), { passphrase: 'secret' }); }); + + it('express.keychain.changePassword', 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' }); + // 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' }); + }); });