diff --git a/modules/abstract-lightning/src/codecs/api/wallet.ts b/modules/abstract-lightning/src/codecs/api/wallet.ts index 7c0760cb43..6e4faca9a4 100644 --- a/modules/abstract-lightning/src/codecs/api/wallet.ts +++ b/modules/abstract-lightning/src/codecs/api/wallet.ts @@ -52,13 +52,32 @@ export const WatchOnly = t.type({ export type WatchOnly = t.TypeOf; -export const UpdateLightningWalletSignedRequest = t.partial({ - encryptedSignerMacaroon: t.string, - encryptedSignerAdminMacaroon: t.string, +const CommonLightningUpdateWalletFields = t.partial({ signerHost: t.string, - encryptedSignerTlsKey: t.string, signerTlsCert: t.string, watchOnlyAccounts: WatchOnly, }); -export type UpdateLightningWalletSignedRequest = t.TypeOf; +export const UpdateLightningWalletEncryptedRequest = t.intersection([ + CommonLightningUpdateWalletFields, + t.partial({ + encryptedSignerMacaroon: t.string, + encryptedSignerAdminMacaroon: t.string, + encryptedSignerTlsKey: t.string, + }), +]); + +export const UpdateLightningWalletClientRequest = t.intersection([ + CommonLightningUpdateWalletFields, + t.type({ + passphrase: t.string, + }), + t.partial({ + signerMacaroon: t.string, + signerAdminMacaroon: t.string, + signerTlsKey: t.string, + }), +]); + +export type UpdateLightningWalletEncryptedRequest = t.TypeOf; +export type UpdateLightningWalletClientRequest = t.TypeOf; diff --git a/modules/abstract-lightning/src/wallet/lightning.ts b/modules/abstract-lightning/src/wallet/lightning.ts index b16ae5cac0..4803e2670f 100644 --- a/modules/abstract-lightning/src/wallet/lightning.ts +++ b/modules/abstract-lightning/src/wallet/lightning.ts @@ -9,7 +9,7 @@ import { TxRequestState, } from '@bitgo/sdk-core'; import * as t from 'io-ts'; -import { createMessageSignature, unwrapLightningCoinSpecific } from '../lightning'; +import { createMessageSignature, deriveLightningServiceSharedSecret, unwrapLightningCoinSpecific } from '../lightning'; import { CreateInvoiceBody, Invoice, @@ -19,7 +19,8 @@ import { LightningKeychain, LndCreatePaymentResponse, SubmitPaymentParams, - UpdateLightningWalletSignedRequest, + UpdateLightningWalletClientRequest, + UpdateLightningWalletEncryptedRequest, } from '../codecs'; import { LightningPaymentIntent, LightningPaymentRequest } from '@bitgo/public-types'; @@ -80,19 +81,19 @@ export interface ILightningWallet { /** * Updates the coin-specific configuration for a Lightning Wallet. * - * @param {UpdateLightningWalletSignedRequest} params - The parameters containing the updated wallet-specific details. + * @param {UpdateLightningWalletClientRequest} params - The parameters containing the updated wallet-specific details. * - `encryptedSignerMacaroon` (optional): This macaroon is used by the watch-only node to ask the signer node to sign transactions. * Encrypted with ECDH secret key from private key of wallet's user auth key and public key of lightning service. * - `encryptedSignerAdminMacaroon` (optional): Generated when initializing the wallet of the signer node. * Encrypted with client's wallet passphrase. * - `signerHost` (optional): The host address of the Lightning signer node. * - `encryptedSignerTlsKey` (optional): The wallet passphrase encrypted TLS key of the signer. + * - `passphrase` (required): The wallet passphrase. * - `signerTlsCert` (optional): The TLS certificate of the signer. * - `watchOnlyAccounts` (optional): These are the accounts used to initialize the watch-only wallet. - * @param {string} passphrase - wallet passphrase. * @returns {Promise} A promise resolving to the updated wallet response or throwing an error if the update fails. */ - updateWalletCoinSpecific(params: UpdateLightningWalletSignedRequest, passphrase: string): Promise; + updateWalletCoinSpecific(params: UpdateLightningWalletClientRequest): Promise; } export class SelfCustodialLightningWallet implements ILightningWallet { @@ -225,24 +226,65 @@ export class SelfCustodialLightningWallet implements ILightningWallet { return { userAuthKey, nodeAuthKey }; } - async updateWalletCoinSpecific(params: UpdateLightningWalletSignedRequest, passphrase: string): Promise { + private encryptWalletUpdateRequest( + params: UpdateLightningWalletClientRequest, + userAuthKey: LightningAuthKeychain + ): UpdateLightningWalletEncryptedRequest { + const coinName = this.wallet.coin() as 'tlnbtc' | 'lnbtc'; + + const requestWithEncryption: Partial = { + ...params, + }; + + const userAuthXprv = this.wallet.bitgo.decrypt({ + password: params.passphrase, + input: userAuthKey.encryptedPrv, + }); + + if (params.signerTlsKey) { + requestWithEncryption.encryptedSignerTlsKey = this.wallet.bitgo.encrypt({ + password: params.passphrase, + input: params.signerTlsKey, + }); + } + + if (params.signerAdminMacaroon) { + requestWithEncryption.encryptedSignerAdminMacaroon = this.wallet.bitgo.encrypt({ + password: params.passphrase, + input: params.signerAdminMacaroon, + }); + } + + if (params.signerMacaroon) { + requestWithEncryption.encryptedSignerMacaroon = this.wallet.bitgo.encrypt({ + password: deriveLightningServiceSharedSecret(coinName, userAuthXprv).toString('hex'), + input: params.signerMacaroon, + }); + } + + return t.exact(UpdateLightningWalletEncryptedRequest).encode(requestWithEncryption); + } + + async updateWalletCoinSpecific(params: UpdateLightningWalletClientRequest): Promise { sdkcore.decodeOrElse( - UpdateLightningWalletSignedRequest.name, - UpdateLightningWalletSignedRequest, + UpdateLightningWalletClientRequest.name, + UpdateLightningWalletClientRequest, params, (errors) => { // DON'T throw errors from decodeOrElse. It could leak sensitive information. - throw new Error(`Invalid params for lightning specific update wallet: ${errors}`); + throw new Error(`Invalid params for lightning specific update wallet`); } ); + const { userAuthKey } = await this.getLightningAuthKeychains(); + const updateRequestWithEncryption = this.encryptWalletUpdateRequest(params, userAuthKey); const signature = createMessageSignature( - params, - this.wallet.bitgo.decrypt({ password: passphrase, input: userAuthKey.encryptedPrv }) + updateRequestWithEncryption, + this.wallet.bitgo.decrypt({ password: params.passphrase, input: userAuthKey.encryptedPrv }) ); const coinSpecific = { [this.wallet.coin()]: { - signedRequest: params, + signedRequest: updateRequestWithEncryption, signature, }, }; diff --git a/modules/abstract-lightning/test/unit/lightning/codecs.ts b/modules/abstract-lightning/test/unit/lightning/codecs.ts index b71a74e022..19994e9243 100644 --- a/modules/abstract-lightning/test/unit/lightning/codecs.ts +++ b/modules/abstract-lightning/test/unit/lightning/codecs.ts @@ -2,7 +2,7 @@ import * as t from 'io-ts'; import assert from 'assert'; import * as E from 'fp-ts/Either'; import { pipe } from 'fp-ts/function'; -import { LightningAuthKeychain, LightningKeychain, UpdateLightningWalletSignedRequest } from '../../../src/codecs'; +import { LightningAuthKeychain, LightningKeychain, UpdateLightningWalletClientRequest } from '../../../src/codecs'; function describeCodec(c: t.Type, valid: unknown[], invalid: unknown[]) { describe('Codec ' + c.name, function () { @@ -90,24 +90,26 @@ describe('Codecs', function () { ); describeCodec( - UpdateLightningWalletSignedRequest, + UpdateLightningWalletClientRequest, [ { - encryptedSignerAdminMacaroon: 'encryptedSignerAdminMacaroon', + signerAdminMacaroon: 'signerAdminMacaroon', signerHost: '127.0.0.1', signerTlsCert: 'signerTlsCert', - encryptedSignerTlsKey: 'encryptedSignerTlsKey', + signerTlsKey: 'signerTlsKey', watchOnly: { master_key_birthday_timestamp: 'master_key_birthday_timestamp', master_key_fingerprint: 'master_key_fingerprint', accounts: [{ purpose: 1, coin_type: 1, account: 1, xpub: 'xpub' }], }, - encryptedSignerMacaroon: 'encryptedSignerMacaroon', + signerMacaroon: 'signerMacaroon', + passphrase: 'passphrase', }, { - encryptedSignerAdminMacaroon: 'encryptedSignerAdminMacaroon', + signerAdminMacaroon: 'signerAdminMacaroon', + passphrase: 'passphrase', }, - {}, + { passphrase: 'passphrase' }, ], [null, 'abg', 1] ); diff --git a/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts b/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts index b0c86c6bd8..59e90009ac 100644 --- a/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts +++ b/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts @@ -11,6 +11,7 @@ import { LndCreatePaymentResponse, SelfCustodialLightningWallet, SubmitPaymentParams, + UpdateLightningWalletClientRequest, } from '@bitgo/abstract-lightning'; import { BitGo, common, GenerateLightningWalletOptions, Wallet, Wallets } from '../../../../src'; @@ -569,16 +570,6 @@ describe('Lightning wallets', function () { }, ], }; - - const params = { - encryptedSignerMacaroon: 'test encryptedSignerMacaroon', - encryptedSignerAdminMacaroon: 'test encryptedSignerAdminMacaroon', - signerHost: '1.1.1.1', - encryptedSignerTlsKey: 'test encryptedSignerTlsKey', - signerTlsCert: 'test signerTlsCert', - watchOnlyAccounts, - }; - it('should update wallet', async function () { const wallet = getLightningWallet(new Wallet(bitgo, basecoin, walletData)); @@ -588,12 +579,43 @@ describe('Lightning wallets', function () { const nodeAuthKeyNock = nock(bgUrl) .get('/api/v2/' + coinName + '/key/ghi') .reply(200, nodeAuthKey); - const wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${walletData.id}`).reply(200); + let capturedBody; + const wpWalletUpdateNock = nock(bgUrl) + .put(`/api/v2/tlnbtc/wallet/${walletData.id}`) + .reply(function (uri, requestBody) { + capturedBody = requestBody; + return [200]; + }); + + const params: UpdateLightningWalletClientRequest = { + signerMacaroon: 'signerMacaroon', + signerAdminMacaroon: 'signerAdminMacaroon', + signerTlsKey: 'signerTlsKey', + signerTlsCert: 'signerTlsCert', + watchOnlyAccounts, + passphrase: 'password123', + }; - await assert.doesNotReject(async () => await wallet.updateWalletCoinSpecific(params, 'password123')); - userAuthKeyNock.done(); - nodeAuthKeyNock.done(); - wpWalletUpdateNock.done(); + await assert.doesNotReject(async () => await wallet.updateWalletCoinSpecific(params)); + assert(userAuthKeyNock.isDone()); + assert(nodeAuthKeyNock.isDone()); + assert(wpWalletUpdateNock.isDone()); + + // Verify structure and required fields + assert.ok(capturedBody.coinSpecific?.tlnbtc?.signedRequest, 'signedRequest should exist'); + const signedRequest = capturedBody.coinSpecific.tlnbtc.signedRequest; + + assert.ok(signedRequest.signerTlsCert, 'signerTlsCert should exist'); + assert.ok(signedRequest.watchOnlyAccounts, 'watchOnlyAccounts should exist'); + assert.ok(signedRequest.encryptedSignerTlsKey, 'encryptedSignerTlsKey should exist'); + assert.ok(signedRequest.encryptedSignerAdminMacaroon, 'encryptedSignerAdminMacaroon should exist'); + assert.ok(signedRequest.encryptedSignerMacaroon, 'encryptedSignerMacaroon should exist'); + + // Verify signature exists + assert.ok(capturedBody.coinSpecific.tlnbtc.signature, 'signature should exist'); + + // we should not pass passphrase to the backend + assert.strictEqual(signedRequest.passphrase, undefined, 'passphrase should not exist in request'); }); }); }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 06908132b3..c340c43cc4 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -54,7 +54,12 @@ import { handleUnlockLightningWallet, } from './lightning/lightningSignerRoutes'; import { handleCreateLightningInvoice, handlePayLightningInvoice } from './lightning/lightningInvoiceRoutes'; +import { + handleListLightningInvoices, + handleUpdateLightningWalletCoinSpecific, +} from './lightning/lightningWalletRoutes'; import { ProxyAgent } from 'proxy-agent'; +import { isLightningCoinName } from '@bitgo/abstract-lightning'; const { version } = require('bitgo/package.json'); const pjson = require('../package.json'); @@ -1004,6 +1009,23 @@ export async function handleV2EnableTokens(req: express.Request) { } } +/** + * Handle Update Wallet + * @param req + */ +async function handleWalletUpdate(req: express.Request): Promise { + // If it's a lightning coin, use the lightning-specific handler + if (isLightningCoinName(req.params.coin)) { + return handleUpdateLightningWalletCoinSpecific(req); + } + + const bitgo = req.bitgo; + const coin = bitgo.coin(req.params.coin); + // For non-lightning coins, directly update the wallet + const wallet = await coin.wallets().get({ id: req.params.id }); + return await bitgo.put(wallet.url()).send(req.body).result(); +} + /** * handle any other API call * @param req @@ -1554,6 +1576,8 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { // generate wallet app.post('/api/v2/:coin/wallet/generate', parseBody, prepareBitGo(config), promiseWrapper(handleV2GenerateWallet)); + app.put('/api/v2/:coin/wallet/:id', parseBody, prepareBitGo(config), promiseWrapper(handleWalletUpdate)); + // create address app.post('/api/v2/:coin/wallet/:id/address', parseBody, prepareBitGo(config), promiseWrapper(handleV2CreateAddress)); @@ -1670,6 +1694,12 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { prepareBitGo(config), promiseWrapper(handlePayLightningInvoice) ); + // lightning - list invoices + app.get( + '/api/v2/:coin/wallet/:id/lightning/invoices', + prepareBitGo(config), + promiseWrapper(handleListLightningInvoices) + ); // everything else should use the proxy handler if (config.disableProxy !== true) { diff --git a/modules/express/src/lightning/lightningSignerRoutes.ts b/modules/express/src/lightning/lightningSignerRoutes.ts index 104f5422f3..30c3b34ddf 100644 --- a/modules/express/src/lightning/lightningSignerRoutes.ts +++ b/modules/express/src/lightning/lightningSignerRoutes.ts @@ -7,7 +7,6 @@ import { createWatchOnly, addIPCaveatToMacaroon, isLightningCoinName, - deriveLightningServiceSharedSecret, getLightningWallet, } from '@bitgo/abstract-lightning'; import * as utxolib from '@bitgo/utxo-lib'; @@ -101,19 +100,12 @@ export async function handleInitLightningWallet(req: express.Request): Promise { + const bitgo = req.bitgo; + + const params = decodeOrElse(InvoiceQuery.name, InvoiceQuery, req.query, (error) => { + throw new ApiResponseError(`Invalid query parameters for listing lightning invoices: ${error}`, 400); + }); + + const coin = bitgo.coin(req.params.coin); + const wallet = await coin.wallets().get({ id: req.params.id }); + const lightningWallet = getLightningWallet(wallet); + + return await lightningWallet.listInvoices(params); +} + +export async function handleUpdateLightningWalletCoinSpecific(req: express.Request): Promise { + const bitgo = req.bitgo; + + const params = decodeOrElse( + 'UpdateLightningWalletClientRequest', + UpdateLightningWalletClientRequest, + req.body, + (_) => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new ApiResponseError('Invalid request body to update lightning wallet coin specific', 400); + } + ); + + const coin = bitgo.coin(req.params.coin); + const wallet = await coin.wallets().get({ id: req.params.id }); + const lightningWallet = getLightningWallet(wallet); + + return await lightningWallet.updateWalletCoinSpecific(params); +} diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 8c76c52c12..c373ec163d 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -100,8 +100,6 @@ describe('Lightning signer routes', () => { const wpKeychainNocks = [ nock(bgUrl).get(`/api/v2/tlnbtc/key/${apiData.userAuthKey.id}`).reply(200, apiData.userAuthKey), nock(bgUrl).get(`/api/v2/tlnbtc/key/${apiData.nodeAuthKey.id}`).reply(200, apiData.nodeAuthKey), - nock(bgUrl).get(`/api/v2/tlnbtc/key/${apiData.userAuthKey.id}`).reply(200, apiData.userAuthKey), - nock(bgUrl).get(`/api/v2/tlnbtc/key/${apiData.nodeAuthKey.id}`).reply(200, apiData.nodeAuthKey), ]; const signerMacaroon = nock(lightningSignerConfigs.fakeid.url) diff --git a/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts b/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts new file mode 100644 index 0000000000..7ad765cdc0 --- /dev/null +++ b/modules/express/test/unit/lightning/lightningWalletRoutes.test.ts @@ -0,0 +1,186 @@ +import * as sinon from 'sinon'; +import * as should from 'should'; +import * as express from 'express'; +import { + handleListLightningInvoices, + handleUpdateLightningWalletCoinSpecific, +} from '../../../src/lightning/lightningWalletRoutes'; +import { BitGo } from 'bitgo'; +import { InvoiceInfo } from '@bitgo/abstract-lightning'; + +describe('Lightning Wallet Routes', () => { + let bitgo; + const coin = 'tlnbtc'; + + const mockRequestObject = (params: { body?: any; params?: any; query?: any; bitgo?: any }) => { + const req: Partial = {}; + req.body = params.body || {}; + req.params = params.params || {}; + req.query = params.query || {}; + req.bitgo = params.bitgo; + return req as express.Request; + }; + + afterEach(() => { + sinon.restore(); + }); + + describe('List Lightning Invoices', () => { + it('should successfully list lightning invoices', async () => { + const queryParams = { + status: 'open', + limit: '10', + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + const mockResponse: InvoiceInfo[] = [ + { + invoice: 'lntb100u1p3h2jk3pp5...', + paymentHash: 'abc123', + valueMsat: 10000n, + walletId: 'testWalletId', + status: 'open', + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const listInvoicesSpy = sinon.stub().resolves(mockResponse); + const mockLightningWallet = { + listInvoices: listInvoicesSpy, + }; + + const proxyquire = require('proxyquire'); + const lightningRoutes = proxyquire('../../../src/lightning/lightningWalletRoutes', { + '@bitgo/abstract-lightning': { + getLightningWallet: () => mockLightningWallet, + }, + }); + + const walletStub = {}; + const coinStub = { + wallets: () => ({ get: sinon.stub().resolves(walletStub) }), + }; + const stubBitgo = sinon.createStubInstance(BitGo as any, { coin: coinStub }); + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + query: queryParams, + bitgo: stubBitgo, + }); + + const result = await lightningRoutes.handleListLightningInvoices(req); + + should(result).deepEqual(mockResponse); + should(listInvoicesSpy).be.calledOnce(); + const [firstArg] = listInvoicesSpy.getCall(0).args; + + should(firstArg).have.property('status', 'open'); + should(firstArg).have.property('limit', 10n); + should(firstArg.startDate).be.instanceOf(Date); + should(firstArg.endDate).be.instanceOf(Date); + }); + + it('should handle invalid query parameters', async () => { + const invalidQuery = { + status: 'invalidStatus', + limit: 'notANumber', + }; + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + query: invalidQuery, + }); + req.bitgo = bitgo; + + await should(handleListLightningInvoices(req)).be.rejectedWith( + /Invalid query parameters for listing lightning invoices: Invalid value '"invalidStatus"' supplied to InvoiceQuery\.status\.0, expected "open"/ + ); + }); + }); + + describe('Update Wallet Coin Specific', () => { + it('should successfully update wallet coin specific data', async () => { + const inputParams = { + signerMacaroon: 'encrypted-macaroon-data', + signerHost: 'signer.example.com', + passphrase: 'wallet-password-123', + }; + + const expectedResponse = { + coinSpecific: { + updated: true, + }, + }; + + const updateStub = sinon.stub().resolves(expectedResponse); + const mockLightningWallet = { + updateWalletCoinSpecific: updateStub, + }; + + const proxyquire = require('proxyquire'); + const lightningRoutes = proxyquire('../../../src/lightning/lightningWalletRoutes', { + '@bitgo/abstract-lightning': { + getLightningWallet: () => mockLightningWallet, + }, + }); + + const walletStub = {}; + const coinStub = { + wallets: () => ({ get: sinon.stub().resolves(walletStub) }), + }; + const stubBitgo = sinon.createStubInstance(BitGo as any, { coin: coinStub }); + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + body: inputParams, + bitgo: stubBitgo, + }); + + const result = await lightningRoutes.handleUpdateLightningWalletCoinSpecific(req); + + should(result).deepEqual(expectedResponse); + should(updateStub).be.calledOnce(); + const [firstArg] = updateStub.getCall(0).args; + should(firstArg).have.property('signerMacaroon', 'encrypted-macaroon-data'); + should(firstArg).have.property('signerHost', 'signer.example.com'); + should(firstArg).have.property('passphrase', 'wallet-password-123'); + }); + + it('should throw error when passphrase is missing', async () => { + const invalidParams = { + signerMacaroon: 'encrypted-data', + signerHost: 'signer.example.com', + }; + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + body: invalidParams, + }); + req.bitgo = bitgo; + + await should(handleUpdateLightningWalletCoinSpecific(req)).be.rejectedWith( + 'Invalid request body to update lightning wallet coin specific' + ); + }); + + it('should handle invalid request body', async () => { + const invalidParams = { + signerHost: 12345, // invalid type + passphrase: 'valid-pass', + }; + + const req = mockRequestObject({ + params: { id: 'testWalletId', coin }, + body: invalidParams, + }); + req.bitgo = bitgo; + + await should(handleUpdateLightningWalletCoinSpecific(req)).be.rejectedWith( + 'Invalid request body to update lightning wallet coin specific' + ); + }); + }); +});