diff --git a/modules/express/package.json b/modules/express/package.json index 4b45a2ed85..e10babc665 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@bitgo/sdk-core": "^28.2.0", + "@bitgo/utxo-lib": "^10.2.0", "argparse": "^1.0.10", "bitgo": "^39.1.1", "bluebird": "^3.5.3", @@ -48,7 +49,9 @@ "express": "^4.17.3", "lodash": "^4.17.20", "morgan": "^1.9.1", - "superagent": "^9.0.1" + "superagent": "^9.0.1", + "io-ts": "2.1.3", + "io-ts-types": "0.5.16" }, "devDependencies": { "@bitgo/public-types": "2.33.4", diff --git a/modules/express/src/args.ts b/modules/express/src/args.ts index 11d92e2d84..704c7f724a 100644 --- a/modules/express/src/args.ts +++ b/modules/express/src/args.ts @@ -97,4 +97,8 @@ parser.addArgument(['--signerMode'], { parser.addArgument(['--signerFileSystemPath'], { help: 'Local path specifying where an Express signer machine keeps encrypted user private keys.', }); + +parser.addArgument(['--lightningSignerFileSystemPath'], { + help: 'Local path specifying where an Express machine keeps lightning signer urls.', +}); export const args = () => parser.parseArgs(); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index c82d8011cf..8a2f5543aa 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -47,6 +47,11 @@ import { Config } from './config'; import { ApiResponseError } from './errors'; import { promises as fs } from 'fs'; import { retryPromise } from './retryPromise'; +import { + handleCreateSignerMacaroon, + handleGetLightningWalletState, + handleInitLightningWallet, +} from './lightning/lightningRoutes'; const { version } = require('bitgo/package.json'); const pjson = require('../package.json'); @@ -1651,3 +1656,9 @@ export function setupSigningRoutes(app: express.Application, config: Config): vo promiseWrapper(handleV2OFCSignPayloadInExtSigningMode) ); } + +export function setupLightningRoutes(app: express.Application, config: Config): void { + app.post('/api/v2/:coin/initwallet', parseBody, prepareBitGo(config), promiseWrapper(handleInitLightningWallet)); + app.post('/api/v2/:coin/signermacaroon', parseBody, prepareBitGo(config), promiseWrapper(handleCreateSignerMacaroon)); + app.get('/api/v2/:coin/wallet/:id/state', prepareBitGo(config), promiseWrapper(handleGetLightningWalletState)); +} diff --git a/modules/express/src/config.ts b/modules/express/src/config.ts index b198627af7..40676af215 100644 --- a/modules/express/src/config.ts +++ b/modules/express/src/config.ts @@ -38,6 +38,7 @@ export interface Config { externalSignerUrl?: string; signerMode?: boolean; signerFileSystemPath?: string; + lightningSignerFileSystemPath?: string; } export const ArgConfig = (args): Partial => ({ @@ -59,6 +60,7 @@ export const ArgConfig = (args): Partial => ({ externalSignerUrl: args.externalSignerUrl, signerMode: args.signerMode, signerFileSystemPath: args.signerFileSystemPath, + lightningSignerFileSystemPath: args.lightningSignerFileSystemPath, }); export const EnvConfig = (): Partial => ({ @@ -80,6 +82,7 @@ export const EnvConfig = (): Partial => ({ externalSignerUrl: readEnvVar('BITGO_EXTERNAL_SIGNER_URL'), signerMode: readEnvVar('BITGO_SIGNER_MODE') ? true : undefined, signerFileSystemPath: readEnvVar('BITGO_SIGNER_FILE_SYSTEM_PATH'), + lightningSignerFileSystemPath: readEnvVar('BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH'), }); export const DefaultConfig: Config = { @@ -102,7 +105,7 @@ export const DefaultConfig: Config = { * @param url * @return {string} */ -function _forceSecureUrl(url: string): string { +export function _forceSecureUrl(url: string): string { const regex = new RegExp(/(^\w+:|^)\/\//); if (regex.test(url)) { return url.replace(/(^\w+:|^)\/\//, 'https://'); @@ -161,6 +164,7 @@ function mergeConfigs(...configs: Partial[]): Config { externalSignerUrl, signerMode: get('signerMode'), signerFileSystemPath: get('signerFileSystemPath'), + lightningSignerFileSystemPath: get('lightningSignerFileSystemPath'), }; } diff --git a/modules/express/src/errors.ts b/modules/express/src/errors.ts index 4897747e19..e47fbbb0bf 100644 --- a/modules/express/src/errors.ts +++ b/modules/express/src/errors.ts @@ -46,3 +46,9 @@ export class ExternalSignerConfigError extends BitGoJsError { super(message || 'External signer configuration is invalid'); } } + +export class LightningSignerConfigError extends BitGoJsError { + public constructor(message?: string) { + super(message || 'Lightning signer configuration is invalid'); + } +} diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index bbecfb18c1..fe5b2981af 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -17,7 +17,13 @@ import { Config, config } from './config'; const debug = debugLib('bitgo:express'); import { SSL_OP_NO_TLSv1 } from 'constants'; -import { IpcError, NodeEnvironmentError, TlsConfigurationError, ExternalSignerConfigError } from './errors'; +import { + IpcError, + NodeEnvironmentError, + TlsConfigurationError, + ExternalSignerConfigError, + LightningSignerConfigError, +} from './errors'; import { Environments } from 'bitgo'; import * as clientRoutes from './clientRoutes'; @@ -104,7 +110,7 @@ function createHttpServer(app: express.Application): http.Server { */ export function startup(config: Config, baseUri: string): () => void { return function () { - const { env, ipc, customRootUri, customBitcoinNetwork, signerMode } = config; + const { env, ipc, customRootUri, customBitcoinNetwork, signerMode, lightningSignerFileSystemPath } = config; /* eslint-disable no-console */ console.log('BitGo-Express running'); console.log(`Environment: ${env}`); @@ -122,6 +128,9 @@ export function startup(config: Config, baseUri: string): () => void { if (signerMode) { console.log(`External signer mode: ${signerMode}`); } + if (lightningSignerFileSystemPath) { + console.log(`Lightning signer file system path: ${lightningSignerFileSystemPath}`); + } /* eslint-enable no-console */ }; } @@ -156,13 +165,13 @@ export function createBaseUri(config: Config): string { } /** - * Check the that the json file containing the external signer private key exists + * Check the that the json file exists * @param path */ -function checkSignerPrvPath(path: string) { +function checkJsonFilePath(path: string) { try { - const privKeyFile = fs.readFileSync(path, { encoding: 'utf8' }); - JSON.parse(privKeyFile); + const jsonFile = fs.readFileSync(path, { encoding: 'utf8' }); + JSON.parse(jsonFile); } catch (e) { throw new Error(`Failed to parse ${path} - ${e.message}`); } @@ -186,6 +195,7 @@ function checkPreconditions(config: Config) { externalSignerUrl, signerMode, signerFileSystemPath, + lightningSignerFileSystemPath, } = config; // warn or throw if the NODE_ENV is not production when BITGO_ENV is production - this can leak system info from express @@ -229,8 +239,18 @@ function checkPreconditions(config: Config) { ); } + if (signerMode !== undefined && lightningSignerFileSystemPath !== undefined) { + throw new LightningSignerConfigError( + 'signerMode and lightningSignerFileSystemPath cannot be set at the same time.' + ); + } + if (signerFileSystemPath !== undefined) { - checkSignerPrvPath(signerFileSystemPath); + checkJsonFilePath(signerFileSystemPath); + } + + if (lightningSignerFileSystemPath !== undefined) { + checkJsonFilePath(lightningSignerFileSystemPath); } } @@ -238,6 +258,9 @@ export function setupRoutes(app: express.Application, config: Config): void { if (config.signerMode) { clientRoutes.setupSigningRoutes(app, config); } else { + if (config.lightningSignerFileSystemPath) { + clientRoutes.setupLightningRoutes(app, config); + } clientRoutes.setupAPIRoutes(app, config); } } diff --git a/modules/express/src/lightning/codecs.ts b/modules/express/src/lightning/codecs.ts new file mode 100644 index 0000000000..7f737216f0 --- /dev/null +++ b/modules/express/src/lightning/codecs.ts @@ -0,0 +1,77 @@ +import * as t from 'io-ts'; +import { NonEmptyString } from 'io-ts-types'; +import { IPCustomCodec } from '@bitgo/sdk-core'; + +export const WalletStateCodec = t.keyof({ + NON_EXISTING: 1, + LOCKED: 1, + UNLOCKED: 1, + RPC_ACTIVE: 1, + SERVER_ACTIVE: 1, + WAITING_TO_START: 1, +}); + +export type WalletState = t.TypeOf; + +export const LightningSignerConfigCodec = t.type({ + url: NonEmptyString, + tlsCert: NonEmptyString, +}); + +export type LightningSignerConfig = t.TypeOf; + +export const LightningSignerConfigsCodec = t.record(t.string, LightningSignerConfigCodec); + +export type LightningSignerConfigs = t.TypeOf; + +export const GetWalletStateResponseCodec = t.type( + { + state: WalletStateCodec, + }, + 'GetWalletStateResponse' +); + +export type GetWalletStateResponse = t.TypeOf; + +export const InitLightningWalletRequestCodec = t.strict( + { + walletId: NonEmptyString, + passphrase: NonEmptyString, + signerIP: IPCustomCodec, + signerTlsCert: NonEmptyString, + signerTlsKey: NonEmptyString, + expressIP: IPCustomCodec, + }, + 'InitLightningWalletRequest' +); + +export type InitLightningWalletRequest = t.TypeOf; + +export const CreateSignerMacaroonRequestCodec = t.strict( + { + walletId: NonEmptyString, + passphrase: NonEmptyString, + watchOnlyIP: IPCustomCodec, + }, + 'CreateSignerMacaroonRequest' +); + +export type CreateSignerMacaroonRequest = t.TypeOf; + +export const InitWalletResponseCodec = t.type( + { + admin_macaroon: NonEmptyString, + }, + 'InitWalletResponse' +); + +export type InitWalletResponse = t.TypeOf; + +export const BakeMacaroonResponseCodec = t.type( + { + macaroon: NonEmptyString, + }, + 'BakeMacaroonResponse' +); + +export type BakeMacaroonResponse = t.TypeOf; diff --git a/modules/express/src/lightning/lightningRoutes.ts b/modules/express/src/lightning/lightningRoutes.ts new file mode 100644 index 0000000000..ef6ab90bc2 --- /dev/null +++ b/modules/express/src/lightning/lightningRoutes.ts @@ -0,0 +1,172 @@ +import * as express from 'express'; +import { + decodeOrElse, + createMessageSignature, + getUtxolibNetwork, + signerMacaroonPermissions, + createWatchOnly, + addIPCaveatToMacaroon, +} from '@bitgo/sdk-core'; +import * as utxolib from '@bitgo/utxo-lib'; +import * as https from 'https'; +import { Buffer } from 'buffer'; + +import { CreateSignerMacaroonRequestCodec, GetWalletStateResponse, InitLightningWalletRequestCodec } from './codecs'; +import { getLightningSignerConfig } from './lightningUtils'; +import { bakeMacaroon, createHttpAgent, getWalletState, initWallet } from './signerClient'; +import { getLightningAuthKeychains, getLightningKeychain, updateWallet } from './lightningWallets'; + +type Decrypt = (params: { input: string; password: string }) => string; + +async function createSignerMacaroon( + config: { url: string; httpsAgent: https.Agent }, + header: { adminMacaroonHex: string }, + watchOnlyIP: string +) { + const { macaroon } = await bakeMacaroon(config, header, { permissions: signerMacaroonPermissions }); + const macaroonBase64 = addIPCaveatToMacaroon(Buffer.from(macaroon, 'hex').toString('base64'), watchOnlyIP); + return Buffer.from(macaroonBase64, 'base64').toString('hex'); +} + +function getSignerRootKey( + passphrase: string, + userMainnetEncryptedPrv: string, + network: utxolib.Network, + decrypt: Decrypt +) { + const userMainnetPrv = decrypt({ password: passphrase, input: userMainnetEncryptedPrv }); + return utxolib.bitgo.keyutil.changeExtendedKeyNetwork(userMainnetPrv, utxolib.networks.bitcoin, network); +} + +function getMacaroonRootKey(passphrase: string, nodeAuthEncryptedPrv: string, decrypt: Decrypt) { + const hdNode = utxolib.bip32.fromBase58(decrypt({ password: passphrase, input: nodeAuthEncryptedPrv })); + if (!hdNode.privateKey) { + throw new Error('nodeAuthEncryptedPrv is not a private key'); + } + return hdNode.privateKey.toString('base64'); +} + +export async function handleInitLightningWallet(req: express.Request): Promise { + const { walletId, passphrase, signerTlsKey, signerTlsCert, signerIP, expressIP } = decodeOrElse( + InitLightningWalletRequestCodec.name, + InitLightningWalletRequestCodec, + req.body, + (_) => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new Error('Invalid request body to initialise lightning wallet'); + } + ); + + const { url, tlsCert } = await getLightningSignerConfig(walletId, req.config); + + const bitgo = req.bitgo; + const coin = bitgo.coin(req.params.coin); + if (coin.getFamily() !== 'lnbtc') { + throw new Error('Invalid coin to initialise lightning wallet'); + } + + const wallet = await coin.wallets().get({ id: walletId }); + + const userKey = await getLightningKeychain(coin, wallet); + const { userAuthKey, nodeAuthKey } = await getLightningAuthKeychains(coin, wallet); + + const network = getUtxolibNetwork(coin.getChain()); + const signerRootKey = getSignerRootKey(passphrase, userKey.encryptedPrv, network, bitgo.decrypt); + const macaroonRootKey = getMacaroonRootKey(passphrase, nodeAuthKey.encryptedPrv, bitgo.decrypt); + + const httpsAgent = createHttpAgent(tlsCert); + const { admin_macaroon: adminMacaroon } = await initWallet( + { url, httpsAgent }, + { + wallet_password: passphrase, + extended_master_key: signerRootKey, + macaroon_root_key: macaroonRootKey, + } + ); + + const encryptedAdminMacaroon = bitgo.encrypt({ + password: passphrase, + input: addIPCaveatToMacaroon(adminMacaroon, expressIP), + }); + const encryptedSignerTlsKey = bitgo.encrypt({ password: passphrase, input: signerTlsKey }); + const watchOnly = createWatchOnly(signerRootKey, network); + + const coinSpecific = { + [coin.getChain()]: { + encryptedAdminMacaroon, + signerIP, + signerTlsCert, + encryptedSignerTlsKey, + watchOnly, + }, + }; + + const signature = createMessageSignature( + coinSpecific, + bitgo.decrypt({ password: passphrase, input: userAuthKey.encryptedPrv }) + ); + + return await updateWallet(bitgo, wallet, { coinSpecific, signature }); +} + +export async function handleCreateSignerMacaroon(req: express.Request): Promise { + const { walletId, passphrase, watchOnlyIP } = decodeOrElse( + CreateSignerMacaroonRequestCodec.name, + CreateSignerMacaroonRequestCodec, + req.body, + (_) => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new Error('Invalid request body for CreateSignerMacaroon.'); + } + ); + + const { url, tlsCert } = await getLightningSignerConfig(walletId, req.config); + + const bitgo = req.bitgo; + const coin = bitgo.coin(req.params.coin); + if (coin.getFamily() !== 'lnbtc') { + throw new Error('Invalid coin for CreateSignerMacaroon'); + } + + const wallet = await coin.wallets().get({ id: walletId }); + + const encryptedAdminMacaroon = wallet.coinSpecific()?.encryptedAdminMacaroon; + if (!encryptedAdminMacaroon) { + throw new Error('Missing encryptedAdminMacaroon in wallet'); + } + const adminMacaroon = bitgo.decrypt({ password: passphrase, input: encryptedAdminMacaroon }); + + const { userAuthKey } = await getLightningAuthKeychains(coin, wallet); + + const httpsAgent = createHttpAgent(tlsCert); + const signerMacaroon = await createSignerMacaroon( + { url, httpsAgent }, + { adminMacaroonHex: Buffer.from(adminMacaroon, 'base64').toString('hex') }, + watchOnlyIP + ); + + // TODO BTC-1465 - Encrypt the signer macaroon using ECDH with the user and LS key pairs + const coinSpecific = { + [coin.getChain()]: { + signerMacaroon, + }, + }; + + const signature = createMessageSignature( + coinSpecific, + bitgo.decrypt({ password: passphrase, input: userAuthKey.encryptedPrv }) + ); + + return await updateWallet(bitgo, wallet, { coinSpecific, signature }); +} + +export async function handleGetLightningWalletState(req: express.Request): Promise { + const coin = req.bitgo.coin(req.params.coin); + if (coin.getFamily() !== 'lnbtc') { + throw new Error('Invalid coin for lightning wallet state'); + } + + const { url, tlsCert } = await getLightningSignerConfig(req.params.id, req.config); + const httpsAgent = createHttpAgent(tlsCert); + return await getWalletState({ url, httpsAgent }); +} diff --git a/modules/express/src/lightning/lightningUtils.ts b/modules/express/src/lightning/lightningUtils.ts new file mode 100644 index 0000000000..ed7ace9eff --- /dev/null +++ b/modules/express/src/lightning/lightningUtils.ts @@ -0,0 +1,37 @@ +import { promises as fs } from 'fs'; +import { decodeOrElse } from '@bitgo/sdk-core'; +import { NonEmptyString } from 'io-ts-types'; +import { LightningSignerConfigs, LightningSignerConfigsCodec, LightningSignerConfig } from './codecs'; +import { _forceSecureUrl } from '../config'; + +export async function getLightningSignerConfigs(path: string): Promise { + const configFile = await fs.readFile(path, { encoding: 'utf8' }); + const configs: unknown = JSON.parse(configFile); + const decoded = decodeOrElse(LightningSignerConfigsCodec.name, LightningSignerConfigsCodec, configs, (errors) => { + throw new Error(`Invalid lightning signer config file: ${errors}`); + }); + const secureUrls: LightningSignerConfigs = {}; + for (const [walletId, { url, tlsCert }] of Object.entries(decoded)) { + const secureUrl = _forceSecureUrl(url); + if (!NonEmptyString.is(secureUrl)) { + throw new Error(`Invalid secure URL: ${secureUrl}`); + } + secureUrls[walletId] = { url: secureUrl, tlsCert }; + } + return secureUrls; +} + +export async function getLightningSignerConfig( + walletId: string, + config: { lightningSignerFileSystemPath?: string } +): Promise { + if (!config.lightningSignerFileSystemPath) { + throw new Error('Missing required configuration: lightningSignerFileSystemPath'); + } + const lightningSignerConfigs = await getLightningSignerConfigs(config.lightningSignerFileSystemPath); + const lightningSignerConfig = lightningSignerConfigs[walletId]; + if (!lightningSignerConfig) { + throw new Error(`Missing required configuration for walletId: ${walletId}`); + } + return lightningSignerConfig; +} diff --git a/modules/express/src/lightning/lightningWallets.ts b/modules/express/src/lightning/lightningWallets.ts new file mode 100644 index 0000000000..6ac695512d --- /dev/null +++ b/modules/express/src/lightning/lightningWallets.ts @@ -0,0 +1,70 @@ +import { + BaseCoin, + decodeOrElse, + LightningAuthKeychain, + LightningAuthKeychainCodec, + LightningKeychain, + LightningKeychainCodec, + unwrapLightningCoinSpecific, + Wallet, +} from '@bitgo/sdk-core'; +import { BitGo } from 'bitgo'; + +export async function getLightningKeychain(coin: BaseCoin, wallet: Wallet): Promise { + if (coin.getFamily() !== 'lnbtc') { + throw new Error('Invalid coin to get lightning Keychain'); + } + + if (wallet.coin() !== coin.getChain()) { + throw new Error('Invalid wallet coin to get lightning keychain'); + } + const keyIds = wallet.keyIds(); + if (keyIds.length !== 1) { + throw new Error(`Invalid number of key in lightning wallet: ${keyIds.length}`); + } + const keychain = await coin.keychains().get({ id: keyIds[0] }); + return decodeOrElse(LightningKeychainCodec.name, LightningKeychainCodec, keychain, (_) => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new Error(`Invalid user key`); + }); +} + +export async function getLightningAuthKeychains( + coin: BaseCoin, + wallet: Wallet +): Promise<{ userAuthKey: LightningAuthKeychain; nodeAuthKey: LightningAuthKeychain }> { + if (coin.getFamily() !== 'lnbtc') { + throw new Error('Invalid coin to get lightning auth keychains'); + } + + if (wallet.coin() !== coin.getChain()) { + throw new Error('Invalid wallet coin to get lightning auth keychains'); + } + + const authKeyIds = wallet.coinSpecific()?.keys; + if (authKeyIds?.length !== 2) { + throw new Error(`Invalid number of auth keys in lightning wallet: ${authKeyIds?.length}`); + } + const keychains = await Promise.all(authKeyIds.map((id) => coin.keychains().get({ id }))); + const authKeychains = keychains.map((keychain) => { + return decodeOrElse(LightningAuthKeychainCodec.name, LightningAuthKeychainCodec, keychain, (_) => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new Error(`Invalid lightning auth key: ${keychain?.id}`); + }); + }); + const [userAuthKey, nodeAuthKey] = (['userAuth', 'nodeAuth'] as const).map((purpose) => { + const keychain = authKeychains.find( + (k) => unwrapLightningCoinSpecific(k.coinSpecific, coin.getChain()).purpose === purpose + ); + if (!keychain) { + throw new Error(`Missing ${purpose} key`); + } + return keychain; + }); + + return { userAuthKey, nodeAuthKey }; +} + +export async function updateWallet(bitgo: BitGo, wallet: Wallet, data: Record): Promise { + return await bitgo.put(wallet.url()).send(data).result(); +} diff --git a/modules/express/src/lightning/signerClient.ts b/modules/express/src/lightning/signerClient.ts new file mode 100644 index 0000000000..e7d34de57d --- /dev/null +++ b/modules/express/src/lightning/signerClient.ts @@ -0,0 +1,95 @@ +import * as https from 'https'; +import * as superagent from 'superagent'; +import { decodeOrElse } from '@bitgo/sdk-core'; +import { retryPromise } from '../retryPromise'; +import { + BakeMacaroonResponse, + BakeMacaroonResponseCodec, + GetWalletStateResponse, + GetWalletStateResponseCodec, + InitWalletResponse, + InitWalletResponseCodec, +} from './codecs'; + +export function createHttpAgent(tlsCert: string): https.Agent { + return new https.Agent({ + ca: Buffer.from(tlsCert, 'base64').toString('utf-8'), + }); +} + +export async function getWalletState(config: { + url: string; + httpsAgent: https.Agent; +}): Promise { + const res = await retryPromise( + () => superagent.get(`${config.url}/v1/state`).agent(config.httpsAgent).send(), + (err, tryCount) => { + console.log(`failed to connect to lightning signer (attempt ${tryCount}, error: ${err.message})`); + } + ); + + if (res.status !== 200) { + throw new Error(`Failed to get wallet state with status: ${res.text}`); + } + + return decodeOrElse(GetWalletStateResponseCodec.name, GetWalletStateResponseCodec, res.body, (errors) => { + throw new Error(`Get wallet state failed: ${errors}`); + }); +} + +export async function initWallet( + config: { url: string; httpsAgent: https.Agent }, + data: { wallet_password: string; extended_master_key: string; macaroon_root_key: string } +): Promise { + const res = await retryPromise( + () => + superagent + .post(`${config.url}/v1/initwallet`) + .agent(config.httpsAgent) + .type('json') + .send({ ...data, stateless_init: true }), + (err, tryCount) => { + console.log(`failed to connect to lightning signer (attempt ${tryCount}, error: ${err.message})`); + } + ); + + if (res.status !== 200) { + throw new Error(`Failed to initialize wallet with status: ${res.status}`); + } + + return decodeOrElse(InitWalletResponseCodec.name, InitWalletResponseCodec, res.body, (_) => { + throw new Error(`Init wallet failed.`); + }); +} + +export async function bakeMacaroon( + config: { url: string; httpsAgent: https.Agent }, + header: { adminMacaroonHex: string }, + data: { + permissions: { + entity: string; + action: string; + }[]; + } +): Promise { + const res = await retryPromise( + () => + superagent + .post(`${config.url}/v1/macaroon`) + .agent(config.httpsAgent) + .set('Grpc-Metadata-macaroon', header.adminMacaroonHex) + .type('json') + .send(data), + (err, tryCount) => { + console.log(`failed to connect to lightning signer (attempt ${tryCount}, error: ${err.message})`); + } + ); + + if (res.status !== 200) { + throw new Error(`Failed to bake macaroon with status: ${res.text}`); + } + + return decodeOrElse(BakeMacaroonResponseCodec.name, BakeMacaroonResponseCodec, res.body, (errors) => { + throw new Error(`Bake macaroon failed: ${errors}`); + }); +} diff --git a/modules/express/test/unit/bitgoExpress.ts b/modules/express/test/unit/bitgoExpress.ts index 75ac6cb75f..ac3e70d6fe 100644 --- a/modules/express/test/unit/bitgoExpress.ts +++ b/modules/express/test/unit/bitgoExpress.ts @@ -26,6 +26,7 @@ describe('Bitgo Express', function () { describe('server initialization', function () { const validPrvJSON = '{"61f039aad587c2000745c687373e0fa9":"xprv9s21ZrQH143K3EuPWCBuqnWxydaQV6et9htQige4EswvcHKEzNmkVmwTwKoadyHzJYppuADB7Us7AbaNLToNvoFoSxuWqndQRYtnNy5DUY2"}'; + const validLightningSignerConfigJSON = '{"fakeid":{"url": "https://127.0.0.1:8080","tlsCert":"dummy"}}'; it('should require NODE_ENV to be production when running against prod env', function () { const envStub = sinon.stub(process, 'env').value({ NODE_ENV: 'production' }); @@ -230,6 +231,25 @@ describe('Bitgo Express', function () { logStub.restore(); }); + it('should output lightning signer file system path upon server startup', () => { + const logStub = sinon.stub(console, 'log'); + + const args: any = { + env: 'test', + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }; + + startup(args, 'base')(); + + logStub.should.have.callCount(4); + logStub.should.have.been.calledWith('BitGo-Express running'); + logStub.should.have.been.calledWith(`Environment: ${args.env}`); + logStub.should.have.been.calledWith('Base URI: base'); + logStub.should.have.been.calledWith(`Lightning signer file system path: ${args.lightningSignerFileSystemPath}`); + + logStub.restore(); + }); + it('should create http base URIs', () => { const args: any = { bind: '1', @@ -388,6 +408,26 @@ describe('Bitgo Express', function () { signerStub.restore(); }); + it('should only call setupLightningRoutes when running with lightningSignerFileSystemPath', () => { + const args: any = { + env: 'test', + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }; + + const readValidStub = sinon.stub(fs, 'readFileSync').returns(validLightningSignerConfigJSON); + const lightningSignerStub = sinon.stub(clientRoutes, 'setupLightningRoutes'); + const apiStub = sinon.stub(clientRoutes, 'setupAPIRoutes'); + const signerStub = sinon.stub(clientRoutes, 'setupSigningRoutes'); + + expressApp(args); + lightningSignerStub.should.have.been.calledOnce(); + apiStub.should.have.been.calledOnce(); + signerStub.called.should.be.false(); + apiStub.restore(); + signerStub.restore(); + readValidStub.restore(); + }); + it('should only call setupSigningRoutes when running in signer mode', () => { const args: any = { env: 'test', @@ -448,6 +488,25 @@ describe('Bitgo Express', function () { (() => expressApp(args)).should.not.throw(); }); + it('should require that signerMode and lightningSignerFileSystemPath not coexist', function () { + const args: any = { + env: 'test', + signerMode: 'signerMode', + signerFileSystemPath: 'signerFileSystemPath', + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }; + (() => expressApp(args)).should.throw({ + name: 'LightningSignerConfigError', + message: 'signerMode and lightningSignerFileSystemPath cannot be set at the same time.', + }); + + const readFileStub = sinon.stub(fs, 'readFileSync').returns(validLightningSignerConfigJSON); + args.signerMode = undefined; + args.signerFileSystemPath = undefined; + (() => expressApp(args)).should.not.throw(); + readFileStub.restore(); + }); + it('should require that an signerFileSystemPath contains a parsable json', function () { const args: any = { env: 'test', @@ -465,5 +524,22 @@ describe('Bitgo Express', function () { (() => expressApp(args)).should.not.throw(); readValidStub.restore(); }); + + it('should require that an lightningSignerFileSystemPath contains a parsable json', function () { + const args: any = { + env: 'test', + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }; + (() => expressApp(args)).should.throw(); + + const invalidPrv = '{"invalid json"}'; + const readInvalidStub = sinon.stub(fs, 'readFileSync').returns(invalidPrv); + (() => expressApp(args)).should.throw(); + readInvalidStub.restore(); + + const readValidStub = sinon.stub(fs, 'readFileSync').returns(validLightningSignerConfigJSON); + (() => expressApp(args)).should.not.throw(); + readValidStub.restore(); + }); }); }); diff --git a/modules/express/test/unit/clientRoutes/lightning/fixture.ts b/modules/express/test/unit/clientRoutes/lightning/fixture.ts new file mode 100644 index 0000000000..23bbc3c849 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/lightning/fixture.ts @@ -0,0 +1,81 @@ +export const apiData = { + initWalletRequestBody: { + walletId: 'fakeid', + passphrase: 'password123', + expressIP: '127.0.0.1', + signerIP: '127.0.0.1', + signerTlsCert: + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNQRENDQWVLZ0F3SUJBZ0lSQU02TEFoaGxOMGo4ZlhxV2dLTWdENmN3Q2dZSUtvWkl6ajBFQXdJd09ERWYKTUIwR0ExVUVDaE1XYkc1a0lHRjFkRzluWlc1bGNtRjBaV1FnWTJWeWRERVZNQk1HQTFVRUF4TU1aV1UxTVdZeApOREV4TUdVMk1CNFhEVEkwTURneE9ERXlNVE14TWxvWERUSTFNVEF4TXpFeU1UTXhNbG93T0RFZk1CMEdBMVVFCkNoTVdiRzVrSUdGMWRHOW5aVzVsY21GMFpXUWdZMlZ5ZERFVk1CTUdBMVVFQXhNTVpXVTFNV1l4TkRFeE1HVTIKTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFclA0d2NXWFEwUWFFazhsVFNVTXBCa1d3ditFbQpxNTNyOWVSeVJUOTRkZGdVR0tTMFlRK0liZzFseVBRU3hiN0dXYloyWG9GUFdiK1VOM0lFMVlMQ2thT0J6RENCCnlUQU9CZ05WSFE4QkFmOEVCQU1DQXFRd0V3WURWUjBsQkF3d0NnWUlLd1lCQlFVSEF3RXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVb3JmUkNVQytmaUNjZlE4cEhEUTFWaE1uMXBBd2NnWURWUjBSQkdzdwphWUlNWldVMU1XWXhOREV4TUdVMmdnbHNiMk5oYkdodmMzU0NDbk5wWjI1bGNtNXZaR1dDQ1d4dlkyRnNhRzl6CmRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtWTI5dWJvY0Vmd0FBQVljUUFBQUFBQUFBQUFBQUFBQUEKQUFBQUFZY0VyQlFBQWpBS0JnZ3Foa2pPUFFRREFnTklBREJGQWlFQXJuQ0xRTlgzeDZ1NjhIM2xCOG9wOUFKaApBd2RrUjhXOXNSaUZnZDJKM2tZQ0lHczFOVGM0T0toRzByNzVHUWpXb2x0SkJyOUtjWWVyR1V3aklCaCtvZ1h0Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + signerTlsKey: + 'LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUFFamQ0Qng3M3VPYllGSW42VlZpZTJmeG9lbXVYZFBob2FkS2JscHpnaTBvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFclA0d2NXWFEwUWFFazhsVFNVTXBCa1d3ditFbXE1M3I5ZVJ5UlQ5NGRkZ1VHS1MwWVErSQpiZzFseVBRU3hiN0dXYloyWG9GUFdiK1VOM0lFMVlMQ2tRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=', + }, + signerMacaroonRequestBody: { + walletId: 'fakeid', + passphrase: 'password123', + watchOnlyIP: '127.0.0.1', + }, + wallet: { + id: 'fakeid', + coin: 'tlnbtc', + keys: ['abc'], + coinSpecific: { + keys: ['def', 'ghi'], + encryptedAdminMacaroon: + '{"iv":"mf/5PSEGdLTKlc8t1IwOBA==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"mZcamRzITwg=","ct":"KCCc+/ly37EZPRoVBgE9T2mAUufWuqmtSadZAAcECevmNbGgGtAhi7P8/zpge49EdsKOP1Mx1DkwnZBMqCVQBTIWZO4XrFI+OOI0YWDrJIaGcnXDFgZWCbgGaomzYNRvt3EoJ1+yMn1EsYdFYgM0NBS0YsvNHx6PsK2eSLpAK2UrhHAkm9X2uhVRMOjjiGr0UW6r4BKuzxCA06fKKQk6bb8LEF54EZFwigjLSztebW5ivNVT/6MxMnjlO7YPW1ClwM9cqJy1oNLUuRK1vnr6hHCas+3F0PCt5XhJJlsgsm1Vz45wWEGdZiyb0XbqOKHyxCI2WOF5Nj1ALiA0D4o9bqfzasNgrvYlMJ4Ld7ayHDtfhiFve/cUZkcQdVqNbS1TPuyvYT8vPKmL5JwuABoTkLH2LtBOh0afz9UFZajo7pxmJ9TtN+B+/GUoiR9v4e2Jw+IpMIIv3ATMqQl9Kot6yefiuP+1DfYNBPvcUqJMc8ibpP56BUA0qWLoAIg5DoocoMybXi0+eA1S0c8Lhe0PsA=="}', + }, + source: 'user', + }, + userKey: { + id: 'abc', + pub: 'xpub661MyMwAqRbcGTnAqrXTV1ZCxrjQuHG87GjDEaeH4VLyxq6LGPJyj3KmDybAiHkJPpRXgp8dMW3iJdFYDmQmNAEVwKLJbimaNua8XzYUJCh', + encryptedPrv: + '{"iv":"lByUPi8LsNP/3wpLGEcglg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Z/J2QDtyU84=","ct":"ShhLIUyG0GoxwzjpH/zuGss8SXEgnPyZQBlntRum2zkhoagIB0zptMOik9KFSll/fi5Z8b6huAmEo922/ZJsszLhlKQqW7PHvzFE19dCbv0WSriZCElpJpZRgRpE2GQ2AbPBLu7ddqf4+5/8/lRhRC1NaKxOQCo="}\n', + source: 'user', + }, + userAuthKey: { + id: 'def', + pub: 'xpub661MyMwAqRbcGYjYsnsDj1SHdiXynWEXNnfNgMSpokN54FKyMqbu7rWEfVNDs6uAJmz86UVFtq4sefhQpXZhSAzQcL9zrEPtiLNNZoeSxCG', + encryptedPrv: + '{"iv":"zYhhaNdW0wPfJEoBjZ4pvg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"tgAMua9jjhw=","ct":"HcrbxQvNlWG5tLMndYzdNCYa1l+1h7o+vSsweA0+q1le3tWt6jLUJSEjZN+JI8lTZ2KPFQgLulQQhsUa+ytUCBi0vSgjF7x7CprT7l2Cfjkew00XsEd7wnmtJUsrQk8m69Co7tIRA3oEgzrnYwy4qOM81lbNNyQ="}', + source: 'user', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }, + nodeAuthKey: { + id: 'ghi', + pub: 'xpub661MyMwAqRbcG9xnTnAnRbJPo3MAHyRtH4zeehN8exYk4VFz5buepUzebhix33BKhS5Eb4V3LEfW5pYiSR8qmaEnyrpeghhKY8JfzAsUDpq', + encryptedPrv: + '{"iv":"bH6eGbnl9x8PZECPrgvcng==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"o8yknV6nTI8=","ct":"nGyzAToIzYkQeIdcVafoWHtMx7+Fgj0YldCme3WA1yxJAA0QulZVhblMZN/7efCRIumA0NNmpH7dxH6n8cVlz/Z+RUgC2q9lgvZKUoJcYNTjWUfkmkJutXX2tr8yVxm+eC/hnRiyfVLZ2qPxctvDlBVBfgLuPyc="}', + source: 'user', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }, +}; + +export const signerApiData = { + initWallet: { + admin_macaroon: + 'AgEDbG5kAvgBAwoQMgwTCPxnm083LA4FNK6ihRIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaIQoIbWFjYXJvb24SCGdlbmVyYXRlEgRyZWFkEgV3cml0ZRoWCgdtZXNzYWdlEgRyZWFkEgV3cml0ZRoXCghvZmZjaGFpbhIEcmVhZBIFd3JpdGUaFgoHb25jaGFpbhIEcmVhZBIFd3JpdGUaFAoFcGVlcnMSBHJlYWQSBXdyaXRlGhgKBnNpZ25lchIIZ2VuZXJhdGUSBHJlYWQAAAYgKhT1l6GR8VcNdIpr1qSl464ykg+bxD/sjXweqLwxLfk=', + }, + walletState: { + state: 'NON_EXISTING', + }, + bakeMacaroon: { + macaroon: + '0201036c6e64025f030a10330c1308fc679b4f372c0e0534aea2851201301a0f0a07616464726573731204726561641a100a076d657373616765120577726974651a100a076f6e636861696e120577726974651a120a067369676e6572120867656e657261746500000620542319d8958ea70a6a13f757e60b266c4bf6b6bfb48cd94db9a7f04d64d2499c', + }, +}; + +export const lightningSignerConfigs = { + fakeid: { + url: 'https://127.0.0.1:8080', + tlsCert: + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNQRENDQWVLZ0F3SUJBZ0lSQU02TEFoaGxOMGo4ZlhxV2dLTWdENmN3Q2dZSUtvWkl6ajBFQXdJd09ERWYKTUIwR0ExVUVDaE1XYkc1a0lHRjFkRzluWlc1bGNtRjBaV1FnWTJWeWRERVZNQk1HQTFVRUF4TU1aV1UxTVdZeApOREV4TUdVMk1CNFhEVEkwTURneE9ERXlNVE14TWxvWERUSTFNVEF4TXpFeU1UTXhNbG93T0RFZk1CMEdBMVVFCkNoTVdiRzVrSUdGMWRHOW5aVzVsY21GMFpXUWdZMlZ5ZERFVk1CTUdBMVVFQXhNTVpXVTFNV1l4TkRFeE1HVTIKTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFclA0d2NXWFEwUWFFazhsVFNVTXBCa1d3ditFbQpxNTNyOWVSeVJUOTRkZGdVR0tTMFlRK0liZzFseVBRU3hiN0dXYloyWG9GUFdiK1VOM0lFMVlMQ2thT0J6RENCCnlUQU9CZ05WSFE4QkFmOEVCQU1DQXFRd0V3WURWUjBsQkF3d0NnWUlLd1lCQlFVSEF3RXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVb3JmUkNVQytmaUNjZlE4cEhEUTFWaE1uMXBBd2NnWURWUjBSQkdzdwphWUlNWldVMU1XWXhOREV4TUdVMmdnbHNiMk5oYkdodmMzU0NDbk5wWjI1bGNtNXZaR1dDQ1d4dlkyRnNhRzl6CmRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtWTI5dWJvY0Vmd0FBQVljUUFBQUFBQUFBQUFBQUFBQUEKQUFBQUFZY0VyQlFBQWpBS0JnZ3Foa2pPUFFRREFnTklBREJGQWlFQXJuQ0xRTlgzeDZ1NjhIM2xCOG9wOUFKaApBd2RrUjhXOXNSaUZnZDJKM2tZQ0lHczFOVGM0T0toRzByNzVHUWpXb2x0SkJyOUtjWWVyR1V3aklCaCtvZ1h0Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, +}; diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSigner.ts b/modules/express/test/unit/clientRoutes/lightning/lightningSigner.ts new file mode 100644 index 0000000000..4323468b10 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSigner.ts @@ -0,0 +1,137 @@ +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGo } from 'bitgo'; +import { common } from '@bitgo/sdk-core'; +import * as nock from 'nock'; +import * as express from 'express'; +import * as sinon from 'sinon'; +import * as fs from 'fs'; + +import { lightningSignerConfigs, apiData, signerApiData } from './fixture'; +import { + handleCreateSignerMacaroon, + handleGetLightningWalletState, + handleInitLightningWallet, +} from '../../../../src/lightning/lightningRoutes'; + +describe('Lightning signer', () => { + let bitgo: TestBitGoAPI; + let bgUrl; + + before(async function () { + if (!nock.isActive()) { + nock.activate(); + } + + bitgo = TestBitGo.decorate(BitGo, { env: 'test' }); + bitgo.initializeTestVars(); + + bgUrl = common.Environments[bitgo.getEnv()].uri; + + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + after(() => { + if (nock.isActive()) { + nock.restore(); + } + }); + + it('should initialize lightning signer wallet', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + const wpWalletnock = nock(bgUrl).get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200, apiData.wallet); + + const wpKeychainNocks = [ + nock(bgUrl).get(`/api/v2/tlnbtc/key/${apiData.userKey.id}`).reply(200, apiData.userKey), + 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 signerInitWalletNock = nock(lightningSignerConfigs.fakeid.url) + .post(`/v1/initwallet`) + .reply(200, signerApiData.initWallet); + + const wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); + + const req = { + bitgo: bitgo, + body: apiData.initWalletRequestBody, + params: { + coin: 'tlnbtc', + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as express.Request; + + await handleInitLightningWallet(req); + + wpWalletUpdateNock.done(); + signerInitWalletNock.done(); + wpKeychainNocks.forEach((s) => s.done()); + wpWalletnock.done(); + readFileStub.calledOnceWith('lightningSignerFileSystemPath').should.be.true(); + readFileStub.restore(); + }); + + it('should get signer wallet state', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + const walletStateNock = nock(lightningSignerConfigs.fakeid.url) + .get(`/v1/state`) + .reply(200, signerApiData.walletState); + + const req = { + bitgo: bitgo, + body: apiData.signerMacaroonRequestBody, + params: { + coin: 'tlnbtc', + id: apiData.wallet.id, + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as express.Request; + + await handleGetLightningWalletState(req); + + walletStateNock.done(); + readFileStub.calledOnceWith('lightningSignerFileSystemPath').should.be.true(); + readFileStub.restore(); + }); + + it('should create signer macaroon', async () => { + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify(lightningSignerConfigs)); + const wpWalletnock = nock(bgUrl).get(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).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, + params: { + coin: 'tlnbtc', + }, + config: { + lightningSignerFileSystemPath: 'lightningSignerFileSystemPath', + }, + } as unknown as express.Request; + + await handleCreateSignerMacaroon(req); + + wpWalletUpdateNock.done(); + signerMacaroon.done(); + wpKeychainNocks.forEach((s) => s.done()); + wpWalletnock.done(); + readFileStub.calledOnceWith('lightningSignerFileSystemPath').should.be.true(); + readFileStub.restore(); + }); +}); diff --git a/modules/express/test/unit/clientRoutes/lightning/lightningSignerConfigs.json b/modules/express/test/unit/clientRoutes/lightning/lightningSignerConfigs.json new file mode 100644 index 0000000000..10f6258f36 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerConfigs.json @@ -0,0 +1,6 @@ +{ + "fakeid": { + "url": "https://127.0.0.1:8080", + "tlsCert": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNQRENDQWVLZ0F3SUJBZ0lSQU02TEFoaGxOMGo4ZlhxV2dLTWdENmN3Q2dZSUtvWkl6ajBFQXdJd09ERWYKTUIwR0ExVUVDaE1XYkc1a0lHRjFkRzluWlc1bGNtRjBaV1FnWTJWeWRERVZNQk1HQTFVRUF4TU1aV1UxTVdZeApOREV4TUdVMk1CNFhEVEkwTURneE9ERXlNVE14TWxvWERUSTFNVEF4TXpFeU1UTXhNbG93T0RFZk1CMEdBMVVFCkNoTVdiRzVrSUdGMWRHOW5aVzVsY21GMFpXUWdZMlZ5ZERFVk1CTUdBMVVFQXhNTVpXVTFNV1l4TkRFeE1HVTIKTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFclA0d2NXWFEwUWFFazhsVFNVTXBCa1d3ditFbQpxNTNyOWVSeVJUOTRkZGdVR0tTMFlRK0liZzFseVBRU3hiN0dXYloyWG9GUFdiK1VOM0lFMVlMQ2thT0J6RENCCnlUQU9CZ05WSFE4QkFmOEVCQU1DQXFRd0V3WURWUjBsQkF3d0NnWUlLd1lCQlFVSEF3RXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVb3JmUkNVQytmaUNjZlE4cEhEUTFWaE1uMXBBd2NnWURWUjBSQkdzdwphWUlNWldVMU1XWXhOREV4TUdVMmdnbHNiMk5oYkdodmMzU0NDbk5wWjI1bGNtNXZaR1dDQ1d4dlkyRnNhRzl6CmRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtWTI5dWJvY0Vmd0FBQVljUUFBQUFBQUFBQUFBQUFBQUEKQUFBQUFZY0VyQlFBQWpBS0JnZ3Foa2pPUFFRREFnTklBREJGQWlFQXJuQ0xRTlgzeDZ1NjhIM2xCOG9wOUFKaApBd2RrUjhXOXNSaUZnZDJKM2tZQ0lHczFOVGM0T0toRzByNzVHUWpXb2x0SkJyOUtjWWVyR1V3aklCaCtvZ1h0Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } +} diff --git a/modules/express/test/unit/config.ts b/modules/express/test/unit/config.ts index 39b253282a..ae070f5238 100644 --- a/modules/express/test/unit/config.ts +++ b/modules/express/test/unit/config.ts @@ -85,6 +85,7 @@ describe('Config:', () => { externalSignerUrl: 'argexternalSignerUrl', signerMode: 'argsignerMode', signerFileSystemPath: 'argsignerFileSystemPath', + lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath', }); const envStub = sinon.stub(process, 'env').value({ BITGO_PORT: 'env12345', @@ -104,6 +105,7 @@ describe('Config:', () => { BITGO_EXTERNAL_SIGNER_URL: 'envexternalSignerUrl', BITGO_SIGNER_MODE: 'envsignerMode', BITGO_SIGNER_FILE_SYSTEM_PATH: 'envsignerFileSystemPath', + BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH: 'envlightningSignerFileSystemPath', }); config().should.eql({ port: 23456, @@ -124,6 +126,7 @@ describe('Config:', () => { externalSignerUrl: 'https://argexternalSignerUrl', signerMode: 'argsignerMode', signerFileSystemPath: 'argsignerFileSystemPath', + lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath', }); argStub.restore(); envStub.restore(); diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 2af2e00784..684db89288 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -71,7 +71,8 @@ "strip-hex-prefix": "^1.0.0", "superagent": "^9.0.1", "tweetnacl": "^1.0.3", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "macaroon": "^3.0.4" }, "devDependencies": { "@bitgo/sdk-opensslbytes": "^2.0.0", diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index afca696dd6..44c0f8320a 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -24,10 +24,6 @@ export interface KeychainWebauthnDevice { encryptedPrv: string; } -export interface KeychainCoinSpecific { - purpose?: 'userAuth' | 'nodeAuth'; -} - export interface Keychain { id: string; pub?: string; @@ -45,7 +41,7 @@ export interface Keychain { walletHSMGPGPublicKeySigs?: string; type: KeyType; source?: SourceType; - coinSpecific?: KeychainCoinSpecific; + coinSpecific?: { [coinName: string]: unknown }; // Alternative encryptedPrv using webauthn and the prf extension webauthnDevices?: KeychainWebauthnDevice[]; } diff --git a/modules/sdk-core/src/bitgo/lightning/codecs.ts b/modules/sdk-core/src/bitgo/lightning/codecs.ts new file mode 100644 index 0000000000..d435b2890e --- /dev/null +++ b/modules/sdk-core/src/bitgo/lightning/codecs.ts @@ -0,0 +1,50 @@ +import * as t from 'io-ts'; +import { isIP } from 'net'; +import { NonEmptyString } from 'io-ts-types'; + +export function getCodecPair( + innerCodec: C +): t.UnionC<[t.TypeC<{ lnbtc: C }>, t.TypeC<{ tlnbtc: C }>]> { + return t.union([t.type({ lnbtc: innerCodec }), t.type({ tlnbtc: innerCodec })]); +} + +export const IPCustomCodec = new t.Type( + 'IPAddress', + t.string.is, + (input, context) => (typeof input === 'string' && isIP(input) ? t.success(input) : t.failure(input, context)), + t.identity +); + +export type IPAddress = t.TypeOf; + +export const KeyPurposeCodec = t.union([t.literal('userAuth'), t.literal('nodeAuth')], 'KeyPurpose'); + +export type KeyPurpose = t.TypeOf; + +export const LightningAuthKeychainCoinSpecificCodec = getCodecPair(t.type({ purpose: KeyPurposeCodec })); + +export const LightningKeychainCodec = t.strict( + { + id: NonEmptyString, + pub: NonEmptyString, + encryptedPrv: NonEmptyString, + coinSpecific: t.undefined, + source: t.literal('user'), + }, + 'LightningKeychain' +); + +export type LightningKeychain = t.TypeOf; + +export const LightningAuthKeychainCodec = t.strict( + { + id: NonEmptyString, + pub: NonEmptyString, + encryptedPrv: NonEmptyString, + coinSpecific: LightningAuthKeychainCoinSpecificCodec, + source: t.literal('user'), + }, + 'LightningAuthKeychain' +); + +export type LightningAuthKeychain = t.TypeOf; diff --git a/modules/sdk-core/src/bitgo/lightning/index.ts b/modules/sdk-core/src/bitgo/lightning/index.ts index 77016d2dc5..8658ade8da 100644 --- a/modules/sdk-core/src/bitgo/lightning/index.ts +++ b/modules/sdk-core/src/bitgo/lightning/index.ts @@ -1,3 +1,4 @@ export * from './signableJson'; export * from './signature'; export * from './lightningUtils'; +export * from './codecs'; diff --git a/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts b/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts index 0731c5f579..5a38611f01 100644 --- a/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts +++ b/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts @@ -1,5 +1,38 @@ import * as statics from '@bitgo/statics'; import * as utxolib from '@bitgo/utxo-lib'; +import { importMacaroon, bytesToBase64 } from 'macaroon'; + +export const signerMacaroonPermissions = [ + { + entity: 'message', + action: 'write', + }, + { + entity: 'signer', + action: 'generate', + }, + { + entity: 'address', + action: 'read', + }, + { + entity: 'onchain', + action: 'write', + }, +]; + +export interface WatchOnlyAccount { + purpose: number; + coin_type: number; + account: number; + xpub: string; +} + +export interface WatchOnly { + master_key_birthday_timestamp: string; + master_key_fingerprint: string; + accounts: WatchOnlyAccount[]; +} export const lightningNetworkName = ['bitcoin', 'testnet'] as const; export type LightningNetworkName = (typeof lightningNetworkName)[number]; @@ -40,6 +73,14 @@ export function getUtxolibNetworkName(coinName: string): string | undefined { return coin instanceof statics.LightningCoin ? coin.network.utxolibName : undefined; } +export function getUtxolibNetwork(coinName: string): utxolib.Network { + const networkName = getUtxolibNetworkName(coinName); + if (!isValidLightningNetworkName(networkName)) { + throw new Error('invalid lightning network'); + } + return getLightningNetwork(networkName); +} + /** * Returns coin specific data for a lightning coin. */ @@ -55,3 +96,54 @@ export function unwrapLightningCoinSpecific(obj: { lnbtc: V } | { tlnbtc: V } } throw new Error('invalid lightning coin specific'); } + +export function addIPCaveatToMacaroon(macaroonBase64: string, ip: string): string { + const macaroon = importMacaroon(macaroonBase64); + macaroon.addFirstPartyCaveat(`ipaddr ${ip}`); + return bytesToBase64(macaroon.exportBinary()); +} + +// https://github.com/lightningnetwork/lnd/blob/master/docs/remote-signing.md#required-accounts +export function deriveWatchOnlyAccounts(masterHDNode: utxolib.BIP32Interface, isMainnet: boolean): WatchOnlyAccount[] { + if (masterHDNode.isNeutered()) { + throw new Error('masterHDNode must not be neutered'); + } + + const accounts: WatchOnlyAccount[] = []; + + const purposes = [49, 84, 86, 1017] as const; + const coinType = isMainnet ? 0 : 1; + + purposes.forEach((purpose) => { + const maxAccount = purpose === 1017 ? 255 : 0; + + for (let account = 0; account <= maxAccount; account++) { + const path = `m/${purpose}'/${coinType}'/${account}'`; + const derivedNode = masterHDNode.derivePath(path); + + // Ensure the node is neutered (i.e., converted to public key only) + const neuteredNode = derivedNode.neutered(); + const xpub = neuteredNode.toBase58(); + + accounts.push({ + purpose, + coin_type: coinType, + account, + xpub, + }); + } + }); + + return accounts; +} + +export function createWatchOnly(signerRootKey: string, network: utxolib.Network): WatchOnly { + const masterHDNode = utxolib.bip32.fromBase58(signerRootKey, network); + const getCurrentUnixTimestamp = () => { + return Math.floor(Date.now() / 1000); + }; + const master_key_birthday_timestamp = getCurrentUnixTimestamp().toString(); + const master_key_fingerprint = masterHDNode.fingerprint.toString('hex'); + const accounts = deriveWatchOnlyAccounts(masterHDNode, utxolib.isMainnet(network)); + return { master_key_birthday_timestamp, master_key_fingerprint, accounts }; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index e68f2808b4..9f1c56c6f4 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -245,6 +245,7 @@ export interface WalletCoinSpecific { hashAlgorithm?: string; pendingEcdsaTssInitialization?: boolean; keys?: string[]; + encryptedAdminMacaroon?: string; } export interface PaginationOptions { diff --git a/modules/utxo-lib/src/bitgo/keyutil.ts b/modules/utxo-lib/src/bitgo/keyutil.ts index 97b3599cd3..71b60373f6 100644 --- a/modules/utxo-lib/src/bitgo/keyutil.ts +++ b/modules/utxo-lib/src/bitgo/keyutil.ts @@ -1,6 +1,7 @@ import { ECPairInterface } from 'ecpair'; +import * as bs58check from 'bs58check'; import { Network } from '../networks'; -import { ECPair } from '../noble_ecc'; +import { bip32, ECPair } from '../noble_ecc'; /** * Create an ECPair from the raw private key bytes @@ -36,3 +37,16 @@ export function privateKeyBufferFromECPair(ecPair: ECPairInterface): Buffer { return privkey; } + +export function changeExtendedKeyNetwork(extendedKey: string, fromNetwork: Network, targetNetwork: Network): string { + if (fromNetwork === targetNetwork) { + return extendedKey; + } + + const hdNode = bip32.fromBase58(extendedKey, fromNetwork); + const data = bs58check.decode(extendedKey); + const targetVersionBytes = Buffer.alloc(4); + targetVersionBytes.writeUInt32BE(hdNode.isNeutered() ? targetNetwork.bip32.public : targetNetwork.bip32.private, 0); + targetVersionBytes.copy(data, 0, 0, 4); + return bs58check.encode(data); +} diff --git a/yarn.lock b/yarn.lock index a3dbaddcf7..3359684756 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13848,6 +13848,15 @@ lz-string@^1.5.0: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== +macaroon@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/macaroon/-/macaroon-3.0.4.tgz#dac1a4b17cd973c1000703f40b19bdbb1d6191ce" + integrity sha512-Tja2jvupseKxltPZbu5RPSz2Pgh6peYA3O46YCTcYL8PI1VqtGwDqRhGfP8pows26xx9wTiygk+en62Bq+Y8JA== + dependencies: + sjcl "^1.0.6" + tweetnacl "^1.0.0" + tweetnacl-util "^0.15.0" + magic-string@^0.30.10: version "0.30.10" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.10.tgz#123d9c41a0cb5640c892b041d4cfb3bd0aa4b39e" @@ -17921,7 +17930,7 @@ sinon@^7.5.0: nise "^1.5.2" supports-color "^5.5.0" -sjcl@1.0.8: +sjcl@1.0.8, sjcl@^1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.8.tgz#f2ec8d7dc1f0f21b069b8914a41a8f236b0e252a" integrity sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ== @@ -19131,7 +19140,7 @@ tweetnacl-util@^0.15.0, tweetnacl-util@^0.15.1: resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz#b80fcdb5c97bcc508be18c44a4be50f022eea00b" integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw== -tweetnacl@1.0.3, tweetnacl@^1.0.1, tweetnacl@^1.0.3: +tweetnacl@1.0.3, tweetnacl@^1.0.0, tweetnacl@^1.0.1, tweetnacl@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==