diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 35e2e8aae8..e4941dfbc0 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1553,11 +1553,6 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.decrypt', [prepareBitGo(config), typedPromiseWrapper(handleDecrypt)]); router.post('express.encrypt', [prepareBitGo(config), typedPromiseWrapper(handleEncrypt)]); router.post('express.verifyaddress', [prepareBitGo(config), typedPromiseWrapper(handleVerifyAddress)]); - router.post('express.lightning.initWallet', [prepareBitGo(config), typedPromiseWrapper(handleInitLightningWallet)]); - router.post('express.lightning.unlockWallet', [ - prepareBitGo(config), - typedPromiseWrapper(handleUnlockLightningWallet), - ]); router.post('express.calculateminerfeeinfo', [ prepareBitGo(config), typedPromiseWrapper(handleCalculateMinerFeeInfo), @@ -1580,7 +1575,6 @@ 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)]); @@ -1747,10 +1741,16 @@ export function setupSigningRoutes(app: express.Application, config: Config): vo } export function setupLightningSignerNodeRoutes(app: express.Application, config: Config): void { - app.post( - '/api/v2/:coin/wallet/:id/signermacaroon', - parseBody, + const router = createExpressRouter(); + app.use(router); + router.post('express.lightning.initWallet', [prepareBitGo(config), typedPromiseWrapper(handleInitLightningWallet)]); + router.post('express.lightning.signerMacaroon', [ prepareBitGo(config), - promiseWrapper(handleCreateSignerMacaroon) - ); + typedPromiseWrapper(handleCreateSignerMacaroon), + ]); + router.post('express.lightning.unlockWallet', [ + prepareBitGo(config), + typedPromiseWrapper(handleUnlockLightningWallet), + ]); + router.get('express.lightning.getState', [prepareBitGo(config), typedPromiseWrapper(handleGetLightningWalletState)]); } diff --git a/modules/express/src/lightning/lightningSignerRoutes.ts b/modules/express/src/lightning/lightningSignerRoutes.ts index 8ca7c6ac07..dc0a814745 100644 --- a/modules/express/src/lightning/lightningSignerRoutes.ts +++ b/modules/express/src/lightning/lightningSignerRoutes.ts @@ -1,6 +1,4 @@ import { isIP } from 'net'; -import * as express from 'express'; -import { decodeOrElse } from '@bitgo/sdk-core'; import { getUtxolibNetwork, signerMacaroonPermissions, @@ -13,11 +11,11 @@ import { } from '@bitgo/abstract-lightning'; import * as utxolib from '@bitgo/utxo-lib'; import { Buffer } from 'buffer'; -import { ExpressApiRouteRequest } from '../typedRoutes/api'; -import { CreateSignerMacaroonRequest, GetWalletStateResponse } from './codecs'; +import { GetWalletStateResponse } from './codecs'; import { LndSignerClient } from './lndSignerClient'; import { ApiResponseError } from '../errors'; +import { ExpressApiRouteRequest } from '../typedRoutes/api'; type Decrypt = (params: { input: string; password: string }) => string; @@ -106,28 +104,19 @@ export async function handleInitLightningWallet( /** * Handle the request to create a signer macaroon from remote signer LND for a wallet. */ -export async function handleCreateSignerMacaroon(req: express.Request): Promise { +export async function handleCreateSignerMacaroon( + req: ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'> +): Promise { const bitgo = req.bitgo; - const coinName = req.params.coin; + const { coin: coinName, walletId, passphrase, addIpCaveatToMacaroon } = req.decoded; if (!isLightningCoinName(coinName)) { throw new ApiResponseError(`Invalid coin to create signer macaroon: ${coinName}. Must be a lightning coin.`, 400); } const coin = bitgo.coin(coinName); - const walletId = req.params.id; if (typeof walletId !== 'string') { throw new ApiResponseError(`Invalid wallet id: ${walletId}`, 400); } - const { passphrase, addIpCaveatToMacaroon } = decodeOrElse( - CreateSignerMacaroonRequest.name, - CreateSignerMacaroonRequest, - req.body, - (_) => { - // DON'T throw errors from decodeOrElse. It could leak sensitive information. - throw new ApiResponseError('Invalid request body to create signer macaroon', 400); - } - ); - const wallet = await coin.wallets().get({ id: walletId, includeBalance: false }); if (wallet.subType() !== 'lightningSelfCustody') { throw new ApiResponseError(`not a self custodial lighting wallet ${walletId}`, 400); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 5dd44e82bd..0f8589776c 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -28,6 +28,7 @@ import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; import { PostGenerateWallet } from './v2/generateWallet'; +import { PostSignerMacaroon } from './v2/signerMacaroon'; import { PostCoinSignTx } from './v2/coinSignTx'; import { PostWalletSignTx } from './v2/walletSignTx'; import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; @@ -211,6 +212,9 @@ export const ExpressOfcSignPayloadApiSpec = apiSpec({ 'express.wallet.generate': { post: PostGenerateWallet, }, + 'express.lightning.signerMacaroon': { + post: PostSignerMacaroon, + }, }); export type ExpressApi = typeof ExpressPingApiSpec & diff --git a/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts new file mode 100644 index 0000000000..0def3b3070 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/signerMacaroon.ts @@ -0,0 +1,61 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for creating a signer macaroon + * @property {string} coin - A lightning coin name (e.g, lnbtc). + * @property {string} walletId - The ID of the wallet. + */ +export const SignerMacaroonParams = { + /** A lightning coin name (e.g, lnbtc). */ + coin: t.string, + /** The ID of the wallet. */ + walletId: t.string, +} as const; + +/** + * Request body for creating a signer macaroon + * @property {string} passphrase - Passphrase to decrypt the admin macaroon of the signer node. + * @property {boolean} addIpCaveatToMacaroon - If true, adds an IP caveat to the generated signer macaroon. + */ +export const SignerMacaroonBody = { + /** Passphrase to decrypt the admin macaroon of the signer node. */ + passphrase: t.string, + /** If true, adds an IP caveat to the generated signer macaroon. */ + addIpCaveatToMacaroon: optional(t.boolean), +} as const; + +/** + * Response + * - 200: Returns the updated wallet. On success, the wallet's `coinSpecific` includes the generated signer macaroon (derived from the signer node admin macaroon), optionally with an IP caveat. + * - 400: BitGo Express error payload when macaroon creation cannot proceed (e.g., invalid coin, wallet not self‑custody lightning, missing encrypted signer admin macaroon, or external IP not set when an IP caveat is requested). + * + * See platform spec: POST /api/v2/{coin}/wallet/{walletId}/signermacaroon + */ +export const SignerMacaroonResponse = { + /** The updated wallet with the generated signer macaroon. */ + 200: t.UnknownRecord, + /** BitGo Express error payload. */ + 400: BitgoExpressError, +} as const; + +/** + * Lightning - Create signer macaroon + * + * This is only used for self-custody lightning. + * Create the signer macaroon for the watch-only Lightning Network Daemon (LND) node. + * This macaroon derives from the signer node admin macaroon and is used by the watch-only node to request signatures from the signer node for operational tasks. + * Returns the updated wallet with the encrypted signer macaroon in the `coinSpecific` response field. + * + * @operationId express.lightning.signerMacaroon + */ +export const PostSignerMacaroon = httpRoute({ + method: 'POST', + path: '/api/v2/{coin}/wallet/{walletId}/signermacaroon', + request: httpRequest({ + params: SignerMacaroonParams, + body: SignerMacaroonBody, + }), + response: SignerMacaroonResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts index 805de7ebdd..970df78dc6 100644 --- a/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerRoutes.ts @@ -2,11 +2,10 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGo } from 'bitgo'; import { common, decodeOrElse } from '@bitgo/sdk-core'; import nock from 'nock'; -import * as express from 'express'; import * as sinon from 'sinon'; import * as fs from 'fs'; import { UnlockLightningWalletResponse } from '../../../../src/typedRoutes/api/v2/unlockWallet'; - +import { SignerMacaroonResponse } from '../../../../src/typedRoutes/api/v2/signerMacaroon'; import { lightningSignerConfigs, apiData, signerApiData } from './lightningSignerFixture'; import { handleCreateSignerMacaroon, @@ -100,60 +99,198 @@ describe('Lightning signer routes', () => { }); } - for (const addIpCaveatToMacaroon of [true, false]) { - for (const includeWatchOnlyIp of [true, false]) { - it(`create signer macaroon ${addIpCaveatToMacaroon ? 'with' : 'without'} including IP caveat when it ${ - includeWatchOnlyIp ? 'does' : `doesn't` - } exist`, async () => { - const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); - const wpWalletnock = nock(bgUrl) - .get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`) - .query({ includeBalance: false }) - .reply(200, { - ...apiData.wallet, - ...(includeWatchOnlyIp ? {} : { watchOnlyExternalIp: null }), - }); - - 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), - ]; - - const signerMacaroon = nock(lightningSignerConfigs.fakeid.url) - .post(`/v1/macaroon`) - .reply(200, signerApiData.bakeMacaroon); - - const wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); - - const req = { - bitgo: bitgo, - body: { ...apiData.signerMacaroonRequestBody, addIpCaveatToMacaroon }, - params: { - coin: 'tlnbtc', - id: 'fakeid', - }, - config: { - lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', - }, - } as unknown as express.Request; - - try { - await handleCreateSignerMacaroon(req); - } catch (e) { - if (!includeWatchOnlyIp || addIpCaveatToMacaroon) { - throw e; - } - } - - wpWalletUpdateNock.done(); - signerMacaroon.done(); - wpKeychainNocks.forEach((s) => s.done()); - wpWalletnock.done(); - readFileStub.calledOnceWith('lightningSignerFileSystemPath').should.be.true(); - readFileStub.restore(); + it('should create signer macaroon with IP caveat when watchOnlyExternalIp exists', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + + const wpWalletnock = nock(bgUrl) + .get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`) + .query({ includeBalance: false }) + .reply(200, apiData.wallet); + + 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), + ]; + + const signerMacaroon = nock(lightningSignerConfigs.fakeid.url) + .post(`/v1/macaroon`) + .reply(200, signerApiData.bakeMacaroon); + + const wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); + + const req = { + bitgo: bitgo, + body: { ...apiData.signerMacaroonRequestBody, addIpCaveatToMacaroon: true }, + params: { + coin: 'tlnbtc', + walletId: 'fakeid', + }, + decoded: { + coin: 'tlnbtc', + walletId: 'fakeid', + passphrase: apiData.signerMacaroonRequestBody.passphrase, + addIpCaveatToMacaroon: true, + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; + + const res = await handleCreateSignerMacaroon(req); + decodeOrElse('SignerMacaroonResponse200', SignerMacaroonResponse[200], res, (_) => { + throw new Error('Response did not match expected codec'); + }); + + wpWalletnock.done(); + wpKeychainNocks.forEach((s) => s.done()); + signerMacaroon.done(); + wpWalletUpdateNock.done(); + readFileStub.restore(); + }); + + it('should fail to create signer macaroon with IP caveat when watchOnlyExternalIp does not exist', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + + const wpWalletnock = nock(bgUrl) + .get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`) + .query({ includeBalance: false }) + .reply(200, { + ...apiData.wallet, + coinSpecific: { + ...apiData.wallet.coinSpecific, + watchOnlyExternalIp: null, + }, }); - } - } + + const req = { + bitgo: bitgo, + body: { ...apiData.signerMacaroonRequestBody, addIpCaveatToMacaroon: true }, + params: { + coin: 'tlnbtc', + walletId: 'fakeid', + }, + decoded: { + coin: 'tlnbtc', + walletId: 'fakeid', + passphrase: apiData.signerMacaroonRequestBody.passphrase, + addIpCaveatToMacaroon: true, + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; + + await handleCreateSignerMacaroon(req).should.be.rejectedWith( + /Cannot create signer macaroon because the external IP is not set/ + ); + + wpWalletnock.done(); + readFileStub.restore(); + }); + + it('should create signer macaroon without IP caveat when watchOnlyExternalIp exists', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + + const wpWalletnock = nock(bgUrl) + .get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`) + .query({ includeBalance: false }) + .reply(200, apiData.wallet); + + 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), + ]; + + const signerMacaroon = nock(lightningSignerConfigs.fakeid.url) + .post(`/v1/macaroon`) + .reply(200, signerApiData.bakeMacaroon); + + const wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); + + const req = { + bitgo: bitgo, + body: { ...apiData.signerMacaroonRequestBody, addIpCaveatToMacaroon: false }, + params: { + coin: 'tlnbtc', + walletId: 'fakeid', + }, + decoded: { + coin: 'tlnbtc', + walletId: 'fakeid', + passphrase: apiData.signerMacaroonRequestBody.passphrase, + addIpCaveatToMacaroon: false, + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; + + const res = await handleCreateSignerMacaroon(req); + decodeOrElse('SignerMacaroonResponse200', SignerMacaroonResponse[200], res, (_) => { + throw new Error('Response did not match expected codec'); + }); + + wpWalletnock.done(); + wpKeychainNocks.forEach((s) => s.done()); + signerMacaroon.done(); + wpWalletUpdateNock.done(); + readFileStub.restore(); + }); + + it('should create signer macaroon without IP caveat when watchOnlyExternalIp does not exist', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + + const wpWalletnock = nock(bgUrl) + .get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`) + .query({ includeBalance: false }) + .reply(200, { + ...apiData.wallet, + coinSpecific: { + ...apiData.wallet.coinSpecific, + watchOnlyExternalIp: null, + }, + }); + + 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), + ]; + + const signerMacaroon = nock(lightningSignerConfigs.fakeid.url) + .post(`/v1/macaroon`) + .reply(200, signerApiData.bakeMacaroon); + + const wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); + + const req = { + bitgo: bitgo, + body: { ...apiData.signerMacaroonRequestBody, addIpCaveatToMacaroon: false }, + params: { + coin: 'tlnbtc', + walletId: 'fakeid', + }, + decoded: { + coin: 'tlnbtc', + walletId: 'fakeid', + passphrase: apiData.signerMacaroonRequestBody.passphrase, + addIpCaveatToMacaroon: false, + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as ExpressApiRouteRequest<'express.lightning.signerMacaroon', 'post'>; + + const res = await handleCreateSignerMacaroon(req); + decodeOrElse('SignerMacaroonResponse200', SignerMacaroonResponse[200], res, (_) => { + throw new Error('Response did not match expected codec'); + }); + + wpWalletnock.done(); + wpKeychainNocks.forEach((s) => s.done()); + signerMacaroon.done(); + wpWalletUpdateNock.done(); + readFileStub.restore(); + }); it('should get signer wallet state', async () => { const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); diff --git a/modules/express/test/unit/typedRoutes/decode.ts b/modules/express/test/unit/typedRoutes/decode.ts index 0859c5aa5b..17d1d0cc73 100644 --- a/modules/express/test/unit/typedRoutes/decode.ts +++ b/modules/express/test/unit/typedRoutes/decode.ts @@ -19,6 +19,7 @@ import { ExpressWalletUpdateBody, ExpressWalletUpdateParams, } from '../../../src/typedRoutes/api/v2/expressWalletUpdate'; +import { SignerMacaroonBody, SignerMacaroonParams } from '../../../src/typedRoutes/api/v2/signerMacaroon'; export function assertDecode(codec: t.Type, input: unknown): T { const result = codec.decode(input); @@ -293,4 +294,16 @@ describe('io-ts decode tests', function () { signerMacaroon: 'mac', }); }); + it('express.lightning.signerMacaroon body valid', function () { + assertDecode(t.type(SignerMacaroonBody), { passphrase: 'pw', addIpCaveatToMacaroon: true }); + }); + it('express.lightning.signerMacaroon body valid (missing addIpCaveatToMacaroon)', function () { + assertDecode(t.type(SignerMacaroonBody), { passphrase: 'pw' }); + }); + it('express.lightning.signerMacaroon params valid', function () { + assertDecode(t.type(SignerMacaroonParams), { coin: 'lnbtc', walletId: 'wid123' }); + }); + it('express.lightning.signerMacaroon params invalid', function () { + assert.throws(() => assertDecode(t.type(SignerMacaroonParams), { coin: 'lnbtc' })); + }); }); diff --git a/modules/express/test/unit/typedRoutes/signerMacaroon.ts b/modules/express/test/unit/typedRoutes/signerMacaroon.ts new file mode 100644 index 0000000000..fe3102657c --- /dev/null +++ b/modules/express/test/unit/typedRoutes/signerMacaroon.ts @@ -0,0 +1,456 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as request from 'supertest'; +import proxyquire from 'proxyquire'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import { BitGo } from 'bitgo'; +import { PostSignerMacaroon } from '../../../src/typedRoutes/api/v2/signerMacaroon'; +import { LndSignerClient } from '../../../src/lightning/lndSignerClient'; + +proxyquire.noPreserveCache(); + +describe('Signer Macaroon Typed Routes Tests', function () { + let agent: request.SuperAgentTest; + + before(function () { + const validLightningSignerConfigJSON = '{}'; + const { app } = proxyquire('../../../src/expressApp', { + fs: { + readFileSync: () => validLightningSignerConfigJSON, + }, + }); + + const args: any = { + debug: false, + env: 'test', + logfile: '/dev/null', + lightningSignerFileSystemPath: '/mock/path/lightning-signer.json', + }; + + const testApp = app(args); + agent = request.agent(testApp); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Success Cases', function () { + it('should successfully create signer macaroon without IP caveat', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyWalletPassphrase123'; + + const walletResponse = { + id: walletId, + coin, + coinSpecific: { + [coin]: { + encryptedSignerMacaroon: 'encrypted_new_signer_macaroon', + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + signerHost: 'https://signer.example.com', + signerTlsCert: 'base64cert==', + watchOnlyExternalIp: '192.168.1.100', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }, + }, + }; + + // Stub LndSignerClient.create + // Use a valid base64 macaroon converted to hex for the mock + const validMacaroonBase64 = + 'AgEDbG5kAvgBAwoQMgU7rDi802Yqg/tHll24nhIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaIQoIbWFjYXJvb24SCGdlbmVyYXRlEgRyZWFkEgV3cml0ZRoWCgdtZXNzYWdlEgRyZWFkEgV3cml0ZRoXCghvZmZjaGFpbhIEcmVhZBIFd3JpdGUaFgoHb25jaGFpbhIEcmVhZBIFd3JpdGUaFAoFcGVlcnMSBHJlYWQSBXdyaXRlGhgKBnNpZ25lchIIZ2VuZXJhdGUSBHJlYWQAAAYgZKiUvEzxGd2QKGUS+9R5ZWevG09S06fMJUnt+k1XXXQ='; + const validMacaroonHex = Buffer.from(validMacaroonBase64, 'base64').toString('hex'); + + const mockLndClient = { + bakeMacaroon: sinon.stub().resolves({ macaroon: validMacaroonHex }), + } as any; + sinon.stub(LndSignerClient, 'create').resolves(mockLndClient); + + // Mock keychains for updateWalletCoinSpecific + const userAuthKey = { + id: 'userAuthKeyId', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encrypted_user_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'userAuth' as const }, + }, + }; + + const nodeAuthKey = { + id: 'nodeAuthKeyId', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encrypted_node_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'nodeAuth' as const }, + }, + }; + + const keychainsGetStub = sinon.stub(); + keychainsGetStub.withArgs({ id: 'userAuthKeyId' }).resolves(userAuthKey); + keychainsGetStub.withArgs({ id: 'nodeAuthKeyId' }).resolves(nodeAuthKey); + const keychainsStub = { get: keychainsGetStub } as any; + + // Stub the BitGo.put call that updateWalletCoinSpecific makes + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(walletResponse), + }), + }); + + // Stub wallet methods + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coin: sinon.stub().returns(coin), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + watchOnlyExternalIp: '192.168.1.100', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }), + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + bitgo: { + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + encrypt: sinon.stub().callsFake(({ input }: { input: string }) => `encrypted_${input}`), + put: putStub, + }, + baseCoin: { + getFamily: sinon.stub().returns('lnbtc'), + getChain: sinon.stub().returns(coin), + keychains: sinon.stub().returns(keychainsStub), + }, + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { + wallets: sinon.stub().returns(walletsStub), + keychains: sinon.stub().returns(keychainsStub), + } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + sinon.stub(BitGo.prototype, 'decrypt').callsFake(walletStub.bitgo.decrypt); + sinon.stub(BitGo.prototype, 'put').callsFake(putStub as any); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', walletId); + res.body.should.have.property('coin', coin); + res.body.should.have.property('coinSpecific'); + res.body.coinSpecific.should.have.property(coin); + res.body.coinSpecific[coin].should.have.property('encryptedSignerMacaroon'); + + getWalletStub.should.have.been.calledOnceWith({ id: walletId, includeBalance: false }); + }); + + it('should successfully create signer macaroon with IP caveat', async function () { + const coin = 'lnbtc'; + const walletId = 'lightningWallet456'; + const passphrase = 'MyWalletPassphrase456'; + + const walletResponse = { + id: walletId, + coin, + coinSpecific: { + [coin]: { + encryptedSignerMacaroon: 'encrypted_new_signer_macaroon_with_ip', + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + signerHost: 'https://signer.example.com', + signerTlsCert: 'base64cert==', + watchOnlyExternalIp: '10.0.0.5', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }, + }, + }; + + // Stub LndSignerClient.create + // Use a valid base64 macaroon converted to hex for the mock + const validMacaroonBase64 = + 'AgEDbG5kAvgBAwoQMgU7rDi802Yqg/tHll24nhIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaIQoIbWFjYXJvb24SCGdlbmVyYXRlEgRyZWFkEgV3cml0ZRoWCgdtZXNzYWdlEgRyZWFkEgV3cml0ZRoXCghvZmZjaGFpbhIEcmVhZBIFd3JpdGUaFgoHb25jaGFpbhIEcmVhZBIFd3JpdGUaFAoFcGVlcnMSBHJlYWQSBXdyaXRlGhgKBnNpZ25lchIIZ2VuZXJhdGUSBHJlYWQAAAYgZKiUvEzxGd2QKGUS+9R5ZWevG09S06fMJUnt+k1XXXQ='; + const validMacaroonHex = Buffer.from(validMacaroonBase64, 'base64').toString('hex'); + + const mockLndClient = { + bakeMacaroon: sinon.stub().resolves({ macaroon: validMacaroonHex }), + } as any; + sinon.stub(LndSignerClient, 'create').resolves(mockLndClient); + + // Mock keychains for updateWalletCoinSpecific + const userAuthKey = { + id: 'userAuthKeyId', + pub: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + encryptedPrv: 'encrypted_user_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'userAuth' as const }, + }, + }; + + const nodeAuthKey = { + id: 'nodeAuthKeyId', + pub: 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa', + encryptedPrv: 'encrypted_node_auth_prv', + source: 'user' as const, + coinSpecific: { + [coin]: { purpose: 'nodeAuth' as const }, + }, + }; + + const keychainsGetStub = sinon.stub(); + keychainsGetStub.withArgs({ id: 'userAuthKeyId' }).resolves(userAuthKey); + keychainsGetStub.withArgs({ id: 'nodeAuthKeyId' }).resolves(nodeAuthKey); + const keychainsStub = { get: keychainsGetStub } as any; + + // Stub the BitGo.put call that updateWalletCoinSpecific makes + const putStub = sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(walletResponse), + }), + }); + + // Stub wallet methods + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coin: sinon.stub().returns(coin), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + watchOnlyExternalIp: '10.0.0.5', + keys: ['userAuthKeyId', 'nodeAuthKeyId'], + }), + url: sinon.stub().returns(`/api/v2/${coin}/wallet/${walletId}`), + bitgo: { + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + encrypt: sinon.stub().callsFake(({ input }: { input: string }) => `encrypted_${input}`), + put: putStub, + }, + baseCoin: { + getFamily: sinon.stub().returns('lnbtc'), + getChain: sinon.stub().returns(coin), + keychains: sinon.stub().returns(keychainsStub), + }, + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { + wallets: sinon.stub().returns(walletsStub), + keychains: sinon.stub().returns(keychainsStub), + } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + sinon.stub(BitGo.prototype, 'decrypt').callsFake(walletStub.bitgo.decrypt); + sinon.stub(BitGo.prototype, 'put').callsFake(putStub as any); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', walletId); + res.body.should.have.property('coin', coin); + res.body.should.have.property('coinSpecific'); + res.body.coinSpecific.should.have.property(coin); + res.body.coinSpecific[coin].should.have.property('encryptedSignerMacaroon'); + + getWalletStub.should.have.been.calledOnceWith({ id: walletId, includeBalance: false }); + }); + }); + + describe('Codec Validation', function () { + it('should return 400 when passphrase is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/passphrase/); + }); + + it('should return 400 when passphrase is not a string', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase: 12345, + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/passphrase/); + }); + + it('should return 400 when addIpCaveatToMacaroon is not a boolean', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase: 'MyPassphrase', + addIpCaveatToMacaroon: 'true', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/addIpCaveatToMacaroon/); + }); + }); + + describe('Handler Validation', function () { + it('should return 400 when coin is not a lightning coin', async function () { + const coin = 'tbtc'; // Not a lightning coin + const walletId = 'wallet123'; + const passphrase = 'MyPassphrase'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/Invalid coin to create signer macaroon/); + }); + + it('should return 400 when wallet is not self-custody lightning', async function () { + const coin = 'tlnbtc'; + const walletId = 'custodialWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub wallet that is NOT self-custody + const walletStub = { + subType: sinon.stub().returns('lightningCustody'), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/not a self custodial lighting wallet/); + }); + + it('should return 400 when encrypted admin macaroon is missing', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub LndSignerClient.create + const mockLndClient = {} as any; + sinon.stub(LndSignerClient, 'create').resolves(mockLndClient); + + // Stub wallet without encryptedSignerAdminMacaroon + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coinSpecific: sinon.stub().returns({ + // Missing encryptedSignerAdminMacaroon + watchOnlyExternalIp: '192.168.1.100', + }), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/Missing encryptedSignerAdminMacaroon/); + }); + + it('should return 400 when IP caveat is requested but external IP is not set', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub wallet without watchOnlyExternalIp + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + // Missing watchOnlyExternalIp + }), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + res.body.error.should.match(/Cannot create signer macaroon because the external IP is not set/); + }); + + it('should return 500 when external IP is invalid', async function () { + const coin = 'tlnbtc'; + const walletId = 'lightningWallet123'; + const passphrase = 'MyPassphrase'; + + // Stub wallet with invalid IP + const walletStub = { + subType: sinon.stub().returns('lightningSelfCustody'), + coinSpecific: sinon.stub().returns({ + encryptedSignerAdminMacaroon: 'encrypted_admin_macaroon', + watchOnlyExternalIp: 'not-an-ip-address', + }), + } as any; + + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/signermacaroon`).send({ + passphrase, + addIpCaveatToMacaroon: true, + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + res.body.error.should.match(/Invalid IP address/); + }); + }); + + describe('Route Definition', function () { + it('should have correct route metadata', function () { + assert.strictEqual(PostSignerMacaroon.method, 'POST'); + assert.strictEqual(PostSignerMacaroon.path, '/api/v2/{coin}/wallet/{walletId}/signermacaroon'); + }); + }); +});