diff --git a/modules/express/package.json b/modules/express/package.json index 492ef74d5d..52eb6fd53d 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@bitgo/sdk-core": "^27.9.0", + "@bitgo/utxo-lib": "^10.1.0", "argparse": "^1.0.10", "bitgo": "^38.18.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..7e9c47a090 100644 --- a/modules/express/src/config.ts +++ b/modules/express/src/config.ts @@ -3,6 +3,8 @@ import { isNil, isNumber } from 'lodash'; import 'dotenv/config'; import { args } from './args'; +import { getLightningSignerConfigs } from './lightning/lightningUtils'; +import { LightningSignerConfigs } from './lightning/codecs'; function readEnvVar(name, ...deprecatedAliases): string | undefined { if (process.env[name] !== undefined && process.env[name] !== '') { @@ -38,6 +40,8 @@ export interface Config { externalSignerUrl?: string; signerMode?: boolean; signerFileSystemPath?: string; + lightningSignerFileSystemPath?: string; + lightningSignerConfigs?: LightningSignerConfigs; } export const ArgConfig = (args): Partial => ({ @@ -59,6 +63,7 @@ export const ArgConfig = (args): Partial => ({ externalSignerUrl: args.externalSignerUrl, signerMode: args.signerMode, signerFileSystemPath: args.signerFileSystemPath, + lightningSignerFileSystemPath: args.lightningSignerFileSystemPath, }); export const EnvConfig = (): Partial => ({ @@ -80,6 +85,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 +108,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://'); @@ -115,7 +121,7 @@ function _forceSecureUrl(url: string): string { * * Later configs have higher precedence over earlier configs. */ -function mergeConfigs(...configs: Partial[]): Config { +async function mergeConfigs(...configs: Partial[]): Promise { function isNilOrNaN(val: unknown): val is null | undefined | number { return isNil(val) || (isNumber(val) && isNaN(val)); } @@ -142,6 +148,12 @@ function mergeConfigs(...configs: Partial[]): Config { } } + const lightningSignerFileSystemPath = get('lightningSignerFileSystemPath'); + let lightningSignerConfigs: LightningSignerConfigs | undefined; + if (lightningSignerFileSystemPath) { + lightningSignerConfigs = await getLightningSignerConfigs(lightningSignerFileSystemPath); + } + return { port: get('port'), bind: get('bind'), @@ -161,11 +173,13 @@ function mergeConfigs(...configs: Partial[]): Config { externalSignerUrl, signerMode: get('signerMode'), signerFileSystemPath: get('signerFileSystemPath'), + lightningSignerFileSystemPath, + lightningSignerConfigs: lightningSignerConfigs, }; } -export const config = () => { +export const config = async () => { const arg = ArgConfig(args()); const env = EnvConfig(); - return mergeConfigs(env, arg); + return await mergeConfigs(env, arg); }; diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index bbecfb18c1..e64b4e84f3 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -104,7 +104,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 +122,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 */ }; } @@ -240,6 +243,9 @@ export function setupRoutes(app: express.Application, config: Config): void { } else { clientRoutes.setupAPIRoutes(app, config); } + if (config.lightningSignerFileSystemPath) { + clientRoutes.setupLightningRoutes(app, config); + } } export function app(cfg: Config): express.Application { @@ -308,7 +314,7 @@ export async function prepareIpc(ipcSocketFilePath: string) { } export async function init(): Promise { - const cfg = config(); + const cfg = await config(); const expressApp = app(cfg); const server = await createServer(cfg, expressApp); 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..903572e6f5 --- /dev/null +++ b/modules/express/src/lightning/lightningRoutes.ts @@ -0,0 +1,171 @@ +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 getUserRootKey( + 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 } = 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 extendedMasterPrvKey = getUserRootKey(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: extendedMasterPrvKey, + 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(extendedMasterPrvKey, 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 } = 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 + ); + + 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 } = 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..ae21662465 --- /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 function getLightningSignerConfig( + walletId: string, + config: { lightningSignerConfigs?: LightningSignerConfigs } +): LightningSignerConfig { + if (!config.lightningSignerConfigs) { + throw new Error('Missing required configuration: lightningSignerConfigs'); + } + + const lightningSignerConfig = config.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/clientRoutes/lightning/fixture.ts b/modules/express/test/unit/clientRoutes/lightning/fixture.ts new file mode 100644 index 0000000000..354cc2b73a --- /dev/null +++ b/modules/express/test/unit/clientRoutes/lightning/fixture.ts @@ -0,0 +1,67 @@ +export const apiData = { + initWalletRequestBody: { + walletId: 'fakeid', + passphrase: 'password123', + expressIP: '127.0.0.1', + signerIP: '127.0.0.1', + signerTlsCert: + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNKRENDQWNxZ0F3SUJBZ0lRU0E4VUdScy95MzF5WStXNFFvVXJDekFLQmdncWhrak9QUVFEQWpBNE1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1SVXdFd1lEVlFRREV3d3hOR1JsTURjeQpPVGsxTURBd0hoY05NalF3T0RFeE1URTFNVEF5V2hjTk1qVXhNREEyTVRFMU1UQXlXakE0TVI4d0hRWURWUVFLCkV4WnNibVFnWVhWMGIyZGxibVZ5WVhSbFpDQmpaWEowTVJVd0V3WURWUVFERXd3eE5HUmxNRGN5T1RrMU1EQXcKV1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVJUc0dHNS9nSnBJNUl4VE9pQWF2ZEtOVlVicHpsRgpNaS9MaEIxUVZySkNROEJ3NDlNeU9sZ3hBd0pFblVVSHByRzV6VHJZQ1daZkpUTDIySjlucXZlQW80RzFNSUd5Ck1BNEdBMVVkRHdFQi93UUVBd0lDcERBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEUKQlRBREFRSC9NQjBHQTFVZERnUVdCQlQvd0VITDV6dzdPYzVEakV0TmVISEZ4Y1kwNFRCYkJnTlZIUkVFVkRCUwpnZ3d4TkdSbE1EY3lPVGsxTURDQ0NXeHZZMkZzYUc5emRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtClkyOXVib2NFZndBQUFZY1FBQUFBQUFBQUFBQUFBQUFBQUFBQUFZY0VyQkVBQWpBS0JnZ3Foa2pPUFFRREFnTkkKQURCRkFpQTNyYjIwdjVhMURZTlJXL0RXYjN2bEN5Wk5SeFlaTUx4UzZ4MHExUitGQkFJaEFOS0dGN043aWtYbApBaEdVbm5zOW03VEFyRmtySUNpNG50eTNXYzI0VHkrSgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==', + signerTlsKey: + 'LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUNSSURpZUFmMkVHaG9mMDlacndJTlVYeW5laVVnTS9CaVFMTk5hQUFpeFdvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFVTdCaHVmNENhU09TTVV6b2dHcjNTalZWRzZjNVJUSXZ5NFFkVUZheVFrUEFjT1BUTWpwWQpNUU1DUkoxRkI2YXh1YzA2MkFsbVh5VXk5dGlmWjZyM2dBPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=', + }, + 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 lightningSignerConfigs = { + fakeid: { + url: 'https://127.0.0.1:8080', + tlsCert: + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNKRENDQWNxZ0F3SUJBZ0lRU0E4VUdScy95MzF5WStXNFFvVXJDekFLQmdncWhrak9QUVFEQWpBNE1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1SVXdFd1lEVlFRREV3d3hOR1JsTURjeQpPVGsxTURBd0hoY05NalF3T0RFeE1URTFNVEF5V2hjTk1qVXhNREEyTVRFMU1UQXlXakE0TVI4d0hRWURWUVFLCkV4WnNibVFnWVhWMGIyZGxibVZ5WVhSbFpDQmpaWEowTVJVd0V3WURWUVFERXd3eE5HUmxNRGN5T1RrMU1EQXcKV1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVJUc0dHNS9nSnBJNUl4VE9pQWF2ZEtOVlVicHpsRgpNaS9MaEIxUVZySkNROEJ3NDlNeU9sZ3hBd0pFblVVSHByRzV6VHJZQ1daZkpUTDIySjlucXZlQW80RzFNSUd5Ck1BNEdBMVVkRHdFQi93UUVBd0lDcERBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEUKQlRBREFRSC9NQjBHQTFVZERnUVdCQlQvd0VITDV6dzdPYzVEakV0TmVISEZ4Y1kwNFRCYkJnTlZIUkVFVkRCUwpnZ3d4TkdSbE1EY3lPVGsxTURDQ0NXeHZZMkZzYUc5emRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtClkyOXVib2NFZndBQUFZY1FBQUFBQUFBQUFBQUFBQUFBQUFBQUFZY0VyQkVBQWpBS0JnZ3Foa2pPUFFRREFnTkkKQURCRkFpQTNyYjIwdjVhMURZTlJXL0RXYjN2bEN5Wk5SeFlaTUx4UzZ4MHExUitGQkFJaEFOS0dGN043aWtYbApBaEdVbm5zOW03VEFyRmtySUNpNG50eTNXYzI0VHkrSgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==', + }, +}; 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..0ff4d02c77 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSigner.ts @@ -0,0 +1,110 @@ +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 { lightningSignerConfigs, apiData } 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 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 wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); + + const req = { + bitgo: bitgo, + body: apiData.initWalletRequestBody, + params: { + coin: 'tlnbtc', + }, + config: { + lightningSignerConfigs, + }, + } as unknown as express.Request; + + await handleInitLightningWallet(req); + + wpWalletUpdateNock.done(); + wpKeychainNocks.forEach((s) => s.done()); + wpWalletnock.done(); + }); + + it('should get signer wallet state', async () => { + const req = { + bitgo: bitgo, + body: apiData.signerMacaroonRequestBody, + params: { + coin: 'tlnbtc', + id: apiData.wallet.id, + }, + config: { + lightningSignerConfigs, + }, + } as unknown as express.Request; + + await handleGetLightningWalletState(req); + }); + + it('should create signer macaroon', async () => { + 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 wpWalletUpdateNock = nock(bgUrl).put(`/api/v2/tlnbtc/wallet/${apiData.wallet.id}`).reply(200); + + const req = { + bitgo: bitgo, + body: apiData.signerMacaroonRequestBody, + params: { + coin: 'tlnbtc', + }, + config: { + lightningSignerConfigs, + }, + } as unknown as express.Request; + + await handleCreateSignerMacaroon(req); + + wpWalletUpdateNock.done(); + wpKeychainNocks.forEach((s) => s.done()); + wpWalletnock.done(); + }); +}); 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..81b0977d27 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/lightning/lightningSignerConfigs.json @@ -0,0 +1,6 @@ +{ + "fakeid": { + "url": "https://127.0.0.1:8080", + "tlsCert": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNKRENDQWNxZ0F3SUJBZ0lRU0E4VUdScy95MzF5WStXNFFvVXJDekFLQmdncWhrak9QUVFEQWpBNE1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1SVXdFd1lEVlFRREV3d3hOR1JsTURjeQpPVGsxTURBd0hoY05NalF3T0RFeE1URTFNVEF5V2hjTk1qVXhNREEyTVRFMU1UQXlXakE0TVI4d0hRWURWUVFLCkV4WnNibVFnWVhWMGIyZGxibVZ5WVhSbFpDQmpaWEowTVJVd0V3WURWUVFERXd3eE5HUmxNRGN5T1RrMU1EQXcKV1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVJUc0dHNS9nSnBJNUl4VE9pQWF2ZEtOVlVicHpsRgpNaS9MaEIxUVZySkNROEJ3NDlNeU9sZ3hBd0pFblVVSHByRzV6VHJZQ1daZkpUTDIySjlucXZlQW80RzFNSUd5Ck1BNEdBMVVkRHdFQi93UUVBd0lDcERBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEUKQlRBREFRSC9NQjBHQTFVZERnUVdCQlQvd0VITDV6dzdPYzVEakV0TmVISEZ4Y1kwNFRCYkJnTlZIUkVFVkRCUwpnZ3d4TkdSbE1EY3lPVGsxTURDQ0NXeHZZMkZzYUc5emRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtClkyOXVib2NFZndBQUFZY1FBQUFBQUFBQUFBQUFBQUFBQUFBQUFZY0VyQkVBQWpBS0JnZ3Foa2pPUFFRREFnTkkKQURCRkFpQTNyYjIwdjVhMURZTlJXL0RXYjN2bEN5Wk5SeFlaTUx4UzZ4MHExUitGQkFJaEFOS0dGN043aWtYbApBaEdVbm5zOW03VEFyRmtySUNpNG50eTNXYzI0VHkrSgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==" + } +} diff --git a/modules/express/test/unit/config.ts b/modules/express/test/unit/config.ts index 39b253282a..e8acdbc3ff 100644 --- a/modules/express/test/unit/config.ts +++ b/modules/express/test/unit/config.ts @@ -9,64 +9,64 @@ import { config, DefaultConfig } from '../../src/config'; import * as args from '../../src/args'; describe('Config:', () => { - it('should take command line config options', () => { + it('should take command line config options', async () => { const argStub = sinon.stub(args, 'args').returns({ port: 12345 }); - config().port.should.equal(12345); + (await config()).port.should.equal(12345); argStub.restore(); }); - it('should take environment variable config options', () => { + it('should take environment variable config options', async () => { const argStub = sinon.stub(args, 'args').returns({}); const envStub = sinon.stub(process, 'env').value({ BITGO_PORT: '12345' }); - config().port.should.equal(12345); + (await config()).port.should.equal(12345); argStub.restore(); envStub.restore(); }); - it('should fall back to default config options', () => { + it('should fall back to default config options', async () => { const argStub = sinon.stub(args, 'args').returns({}); - config().port.should.equal(DefaultConfig.port); + (await config()).port.should.equal(DefaultConfig.port); argStub.restore(); }); - it('should correctly handle config precedence', () => { + it('should correctly handle config precedence', async () => { const argStub = sinon.stub(args, 'args').returns({ port: 23456 }); const envStub = sinon.stub(process, 'env').value({ BITGO_PORT: '12345' }); - config().port.should.equal(23456); + (await config()).port.should.equal(23456); argStub.restore(); envStub.restore(); }); - it('should transform urls to secure urls when disableSSL is undefined', () => { + it('should transform urls to secure urls when disableSSL is undefined', async () => { const argStub = sinon.stub(args, 'args').returns({ disableSSL: undefined, customrooturi: 'test.com' }); const envStub = sinon .stub(process, 'env') .value({ BITGO_DISABLE_SSL: undefined, BITGO_CUSTOM_ROOT_URI: 'test.com' }); - config().disableSSL.should.equal(false); - config().should.have.property('customRootUri', 'https://test.com'); + (await config()).disableSSL.should.equal(false); + (await config()).should.have.property('customRootUri', 'https://test.com'); argStub.restore(); envStub.restore(); }); - it('should transform urls to secure urls when disableSSL is false', () => { + it('should transform urls to secure urls when disableSSL is false', async () => { const argStub = sinon.stub(args, 'args').returns({ disableSSL: false, customrooturi: 'test.com' }); const envStub = sinon.stub(process, 'env').value({ BITGO_DISABLE_SSL: false, BITGO_CUSTOM_ROOT_URI: 'test.com' }); - config().disableSSL.should.equal(false); - config().should.have.property('customRootUri', 'https://test.com'); + (await config()).disableSSL.should.equal(false); + (await config()).should.have.property('customRootUri', 'https://test.com'); argStub.restore(); envStub.restore(); }); - it('should not transform urls to secure urls when disableSSL is true', () => { + it('should not transform urls to secure urls when disableSSL is true', async () => { const argStub = sinon.stub(args, 'args').returns({ disableSSL: true, customrooturi: 'test.com' }); const envStub = sinon.stub(process, 'env').value({ BITGO_DISABLE_SSL: true, BITGO_CUSTOM_ROOT_URI: 'test.com' }); - config().disableSSL.should.equal(true); - config().should.have.property('customRootUri', 'test.com'); + (await config()).disableSSL.should.equal(true); + (await config()).should.have.property('customRootUri', 'test.com'); argStub.restore(); envStub.restore(); }); - it('should correctly handle config precedence for a complete config', () => { + it('should correctly handle config precedence for a complete config', async () => { const argStub = sinon.stub(args, 'args').returns({ port: 23456, bind: 'argbind', @@ -105,7 +105,7 @@ describe('Config:', () => { BITGO_SIGNER_MODE: 'envsignerMode', BITGO_SIGNER_FILE_SYSTEM_PATH: 'envsignerFileSystemPath', }); - config().should.eql({ + (await config()).should.eql({ port: 23456, bind: 'argbind', ipc: 'argipc', @@ -124,20 +124,22 @@ describe('Config:', () => { externalSignerUrl: 'https://argexternalSignerUrl', signerMode: 'argsignerMode', signerFileSystemPath: 'argsignerFileSystemPath', + lightningSignerConfigs: undefined, + lightningSignerFileSystemPath: undefined, }); argStub.restore(); envStub.restore(); }); - it('should correctly handle boolean config precedence', () => { + it('should correctly handle boolean config precedence', async () => { const argStub = sinon.stub(args, 'args').returns({ disablessl: true }); const envStub = sinon.stub(process, 'env').value({ BITGO_DISABLE_SSL: undefined }); - config().disableSSL.should.equal(true); + (await config()).disableSSL.should.equal(true); argStub.restore(); envStub.restore(); }); - it('should allow all DISABLE_SSL option forms, including deprecated', () => { + it('should allow all DISABLE_SSL option forms, including deprecated', async () => { const optionForms = [ { deprecated: true, DISABLESSL: true }, { deprecated: true, DISABLE_SSL: true }, @@ -149,7 +151,7 @@ describe('Config:', () => { const argStub = sinon.stub(args, 'args').returns({}); const envStub = sinon.stub(process, 'env').value(form); const consoleStub = sinon.stub(console, 'warn').returns(undefined); - config().disableSSL.should.equal(true); + (await config()).disableSSL.should.equal(true); argStub.restore(); envStub.restore(); consoleStub.restore(); @@ -159,14 +161,14 @@ describe('Config:', () => { } }); - it('should allow all DISABLE_PROXY option forms, including deprecated', () => { + it('should allow all DISABLE_PROXY option forms, including deprecated', async () => { const optionForms = [{ deprecated: true, DISABLE_PROXY: true }, { BITGO_DISABLE_PROXY: true }]; for (const { deprecated = false, ...form } of optionForms) { const argStub = sinon.stub(args, 'args').returns({}); const envStub = sinon.stub(process, 'env').value(form); const consoleStub = sinon.stub(console, 'warn').returns(undefined); - config().disableProxy.should.equal(true); + (await config()).disableProxy.should.equal(true); argStub.restore(); envStub.restore(); consoleStub.restore(); @@ -176,14 +178,14 @@ describe('Config:', () => { } }); - it('should allow all DISABLE_ENV_CHECK option forms, including deprecated', () => { + it('should allow all DISABLE_ENV_CHECK option forms, including deprecated', async () => { const optionForms = [{ BITGO_DISABLE_ENV_CHECK: true }, { deprecated: true, DISABLE_ENV_CHECK: true }]; for (const { deprecated = false, ...form } of optionForms) { const argStub = sinon.stub(args, 'args').returns({}); const envStub = sinon.stub(process, 'env').value(form); const consoleStub = sinon.stub(console, 'warn').returns(undefined); - config().disableEnvCheck.should.equal(true); + (await config()).disableEnvCheck.should.equal(true); argStub.restore(); envStub.restore(); consoleStub.restore(); diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index ec3acaea85..687c8c5e5b 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": { "@openpgp/web-stream-tools": "0.0.14", 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..85d4d49990 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(extendedMasterPrvKey: string, network: utxolib.Network): WatchOnly { + const masterHDNode = utxolib.bip32.fromBase58(extendedMasterPrvKey, 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 810a8cf5b6..f419456980 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -244,6 +244,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 abecca3f9f..18b9d8a703 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13859,6 +13859,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" @@ -17932,7 +17941,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== @@ -19158,7 +19167,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==