diff --git a/modules/express/package.json b/modules/express/package.json index b9cd275df8..7808b12a8b 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@bitgo/sdk-core": "^27.8.0", + "@bitgo/utxo-lib": "^10.1.0", "argparse": "^1.0.10", "bitgo": "^38.18.0", "bluebird": "^3.5.3", @@ -48,7 +49,10 @@ "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", + "macaroon": "^3.0.4" }, "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 9f66442a09..e628bce588 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -47,6 +47,7 @@ import { Config } from './config'; import { ApiResponseError } from './errors'; import { promises as fs } from 'fs'; import { retryPromise } from './retryPromise'; +import { handleInitLightningWallet } from './lightning/lightningRoutes'; const { version } = require('bitgo/package.json'); const pjson = require('../package.json'); @@ -1658,3 +1659,7 @@ 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/:id', parseBody, prepareBitGo(config), promiseWrapper(handleInitLightningWallet)); +} diff --git a/modules/express/src/config.ts b/modules/express/src/config.ts index b198627af7..7c9c9085b9 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 { getLightningSignerConnections } from './lightning/lightningUtils'; +import { LightningSignerConnections } 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; + lightningSignerConnections?: LightningSignerConnections; } 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 lightningSignerConnections: LightningSignerConnections | undefined; + if (lightningSignerFileSystemPath) { + lightningSignerConnections = await getLightningSignerConnections(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, + lightningSignerConnections, }; } -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..d4d9905336 --- /dev/null +++ b/modules/express/src/lightning/codecs.ts @@ -0,0 +1,99 @@ +import * as t from 'io-ts'; +import { isIP } from 'net'; +import { NonEmptyString } from 'io-ts-types'; + +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 LightningSignerDetailsCodec = t.type({ + url: t.string, + tlsCert: t.string, +}); + +export type LightningSignerDetails = t.TypeOf; + +export const LightningSignerConnectionsCodec = t.record(t.string, LightningSignerDetailsCodec); + +export type LightningSignerConnections = 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; + +export const GetWalletStateResponseCodec = t.type( + { + state: t.number, + }, + 'GetWalletStateResponse' +); + +export type GetWalletStateResponse = t.TypeOf; + +export const InitLightningWalletRequestCodec = t.strict( + { + passphrase: NonEmptyString, + signerIP: IPCustomCodec, + signerTlsCert: NonEmptyString, + signerTlsKey: NonEmptyString, + watchOnlyIP: IPCustomCodec, + }, + 'InitLightningWalletRequest' +); + +export type InitLightningWalletRequest = t.TypeOf; + +export const InitWalletResponseCodec = t.type( + { + admin_macaroon: t.string, + }, + 'InitWalletResponse' +); + +export type InitWalletResponse = t.TypeOf; + +export const BakeMacaroonResponseCodec = t.type( + { + macaroon: t.string, + }, + '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..d66537e8a3 --- /dev/null +++ b/modules/express/src/lightning/lightningRoutes.ts @@ -0,0 +1,182 @@ +import * as express from 'express'; +import { + BaseCoin, + decodeOrElse, + Wallet, + createMessageSignature, + unwrapLightningCoinSpecific, + getUtxolibNetworkName, + getLightningNetwork, + isValidLightningNetworkName, +} from '@bitgo/sdk-core'; +import { bip32, Network } from '@bitgo/utxo-lib'; +import * as https from 'https'; +import { Buffer } from 'buffer'; + +import { + InitLightningWalletRequestCodec, + LightningAuthKeychain, + LightningAuthKeychainCodec, + LightningKeychain, + LightningKeychainCodec, +} from './codecs'; +import { + addIPCaveatToMacaroon, + createWatchOnly, + getLightningWalletSignerDetails, + signerMacaroonPermissions, + WalletState, +} from './lightningUtils'; +import { bakeMacaroon, createHttpAgent, getWalletState, initWallet } from './signerClient'; + +async function getLightningWalletKeychains( + coin: BaseCoin, + wallet: Wallet +): Promise<{ userKey: LightningKeychain; userAuthKey: LightningAuthKeychain; nodeAuthKey: LightningAuthKeychain }> { + if (wallet.keyIds().length !== 1) { + throw new Error('Invalid number of keys in wallet'); + } + const [userKeyId] = wallet.keyIds(); + const authKeysIds = wallet.coinSpecific()?.keys; + if (authKeysIds?.length !== 2) { + throw new Error('Invalid number of keys in wallet coinSpecific'); + } + const allKeyIds = [userKeyId, ...authKeysIds]; + const keychains = await Promise.all(allKeyIds.map((id) => coin.keychains().get({ id }))); + + const userKeychain = keychains.find((keychain) => keychain.id === userKeyId); + const userKey = decodeOrElse(LightningKeychainCodec.name, LightningKeychainCodec, userKeychain, (_) => { + throw new Error(`Invalid user key`); + }); + + const authKeys = authKeysIds.map((keyId) => { + const authKeychain = keychains.find((keychain) => keychain.id === keyId); + return decodeOrElse(LightningAuthKeychainCodec.name, LightningAuthKeychainCodec, authKeychain, (_) => { + throw new Error(`Invalid auth key`); + }); + }); + const [userAuthKey, nodeAuthKey] = (['userAuth', 'nodeAuth'] as const).map((purpose) => { + const key = authKeys.find((k) => unwrapLightningCoinSpecific(k.coinSpecific, coin.getChain()).purpose === purpose); + if (!key) { + throw new Error(`Missing ${purpose} key`); + } + return key; + }); + + return { userKey, userAuthKey, nodeAuthKey }; +} + +async function createSignerMacaroon( + httpConfig: { url: string; httpsAgent: https.Agent; adminMacaroonHex: string }, + watchOnlyIP: string +) { + const { macaroon } = await bakeMacaroon(httpConfig, signerMacaroonPermissions); + return addIPCaveatToMacaroon(macaroon, watchOnlyIP); +} + +function getMacaroonRootKey( + passphrase: string, + nodeAuthEncryptedPrv: string, + decrypt: (params: { input: string; password: string }) => string +) { + const hdNode = bip32.fromBase58(decrypt({ password: passphrase, input: nodeAuthEncryptedPrv })); + if (!hdNode.privateKey) { + throw new Error('nodeAuthEncryptedPrv is not a private key'); + } + return hdNode.privateKey.toString('base64'); +} + +function getNetwork(coinName: string): Network { + const networkName = getUtxolibNetworkName(coinName); + if (!isValidLightningNetworkName(networkName)) { + throw new Error(`Invalid lightning coin name: ${coinName}`); + } + return getLightningNetwork(networkName); +} + +export async function handleInitLightningWallet(req: express.Request): Promise { + const walletId = req.params.id; + if (!walletId) { + throw new Error('Missing required param: walletId'); + } + + const { url, tlsCert } = getLightningWalletSignerDetails(walletId, req.config); + + const { passphrase, watchOnlyIP, signerTlsKey, signerTlsCert, signerIP } = decodeOrElse( + InitLightningWalletRequestCodec.name, + InitLightningWalletRequestCodec, + req.body, + (_) => { + throw new Error('Invalid request body for initLightningWallet.'); + } + ); + + const bitgo = req.bitgo; + const coin = bitgo.coin(req.params.coin); + if (coin.getFamily() !== 'lnbtc') { + throw new Error('Invalid coin'); + } + + const { state } = await getWalletState({ url, httpsAgent }); + + if (state !== WalletState.NON_EXISTING) { + throw new Error(`Signer must be in NON_EXISTING state to initialize wallet, but it is in state: ${WalletState}`); + } + + const wallet = await coin.wallets().get({ id: walletId }); + + // TODO: check if wallet is ready for initialization + + const { userKey, userAuthKey, nodeAuthKey } = await getLightningWalletKeychains(coin, wallet); + + const macaroonRootKey = getMacaroonRootKey(passphrase, nodeAuthKey.encryptedPrv, bitgo.decrypt); + const extendedMasterPrvKey = bitgo.decrypt({ password: passphrase, input: userKey.encryptedPrv }); + + const httpsAgent = createHttpAgent(tlsCert); + + const { admin_macaroon: adminMacaroon } = await initWallet( + { url, httpsAgent }, + { + wallet_password: passphrase, + extended_master_key: extendedMasterPrvKey, + macaroon_root_key: macaroonRootKey, + } + ); + + const signerMacaroon = await createSignerMacaroon( + { url, httpsAgent, adminMacaroonHex: Buffer.from(adminMacaroon, 'base64').toString('hex') }, + watchOnlyIP + ); + + const encryptedAdminMacaroon = bitgo.encrypt({ password: passphrase, input: adminMacaroon }); + const encryptedSignerTlsKey = bitgo.encrypt({ password: passphrase, input: signerTlsKey }); + const isMainnet = coin.getChain() === 'lnbtc'; + const watchOnly = createWatchOnly(bip32.fromBase58(extendedMasterPrvKey), isMainnet); + + const coinSpecific = { + [coin.getChain()]: { + signerMacaroon, + encryptedAdminMacaroon, + signerIP, + signerTlsCert, + encryptedSignerTlsKey, + watchOnly, + }, + }; + + const signature = createMessageSignature( + coinSpecific, + bitgo.decrypt({ password: passphrase, input: userAuthKey.encryptedPrv }), + getNetwork(coin.getChain()) + ); + + async function updateWallet(): Promise { + const res = await bitgo.put(wallet.url()).send({ coinSpecific, signature }).result(); + if (res.status !== 200) { + throw new Error('Failed to update wallet'); + } + return res; + } + + return await updateWallet(); +} diff --git a/modules/express/src/lightning/lightningUtils.ts b/modules/express/src/lightning/lightningUtils.ts new file mode 100644 index 0000000000..fee3bc8877 --- /dev/null +++ b/modules/express/src/lightning/lightningUtils.ts @@ -0,0 +1,112 @@ +import { promises as fs } from 'fs'; +import { importMacaroon } from 'macaroon'; +import { decodeOrElse, WatchOnly, WatchOnlyAccount } from '@bitgo/sdk-core'; +import { BIP32Interface } from '@bitgo/utxo-lib'; +import { LightningSignerConnections, LightningSignerConnectionsCodec, LightningSignerDetails } from './codecs'; +import { _forceSecureUrl } from '../config'; + +export enum WalletState { + NON_EXISTING = 0, +} + +export const signerMacaroonPermissions = [ + { + entity: 'message', + action: 'write', + }, + { + entity: 'signer', + action: 'generate', + }, + { + entity: 'address', + action: 'read', + }, + { + entity: 'onchain', + action: 'write', + }, +] as const; + +export async function getLightningSignerConnections(path: string): Promise { + const urlFile = await fs.readFile(path, { encoding: 'utf8' }); + const urls: unknown = JSON.parse(urlFile); + const decoded = decodeOrElse( + LightningSignerConnectionsCodec.name, + LightningSignerConnectionsCodec, + urls, + (errors) => { + throw new Error(`Invalid lightning signer URL file: ${errors}`); + } + ); + const secureUrls: LightningSignerConnections = {}; + for (const [walletId, { url, tlsCert }] of Object.entries(decoded)) { + secureUrls[walletId] = { url: _forceSecureUrl(url), tlsCert }; + } + return secureUrls; +} + +export function getLightningWalletSignerDetails( + walletId: string, + config: { lightningSignerConnections?: LightningSignerConnections } +): LightningSignerDetails { + if (!config.lightningSignerConnections) { + throw new Error('Missing required configuration: lightningSignerConnections'); + } + + const lightningSignerDetails = config.lightningSignerConnections[walletId]; + if (!lightningSignerDetails) { + throw new Error(`Missing required configuration for walletId: ${walletId}`); + } + return lightningSignerDetails; +} + +export function addIPCaveatToMacaroon(macaroonBase64: string, ip: string): string { + const macaroon = importMacaroon(macaroonBase64); + macaroon.addFirstPartyCaveat(`ipaddr ${ip}`); + return macaroon.exportBinary().toString('hex'); +} + +// https://github.com/lightningnetwork/lnd/blob/master/docs/remote-signing.md#required-accounts +export function deriveWatchOnlyAccounts(masterHDNode: 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(masterHDNode: BIP32Interface, isMainnet: boolean): WatchOnly { + 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, isMainnet); + return { master_key_birthday_timestamp, master_key_fingerprint, accounts }; +} diff --git a/modules/express/src/lightning/signerClient.ts b/modules/express/src/lightning/signerClient.ts new file mode 100644 index 0000000000..610fdf7973 --- /dev/null +++ b/modules/express/src/lightning/signerClient.ts @@ -0,0 +1,92 @@ +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).type('json').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; adminMacaroonHex: string }, + data: readonly { + entity: string; + action: string; + }[] +): Promise { + const res = await retryPromise( + () => + superagent + .post(`${config.url}/v1/initwallet`) + .agent(config.httpsAgent) + .set('Grpc-Metadata-macaroon', config.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/config.ts b/modules/express/test/unit/config.ts index 39b253282a..0a4b7871b5 100644 --- a/modules/express/test/unit/config.ts +++ b/modules/express/test/unit/config.ts @@ -11,28 +11,28 @@ import * as args from '../../src/args'; describe('Config:', () => { it('should take command line config options', () => { 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', () => { 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', () => { 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', () => { 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(); }); @@ -42,8 +42,8 @@ describe('Config:', () => { 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(); }); @@ -51,8 +51,8 @@ describe('Config:', () => { it('should transform urls to secure urls when disableSSL is false', () => { 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(); }); @@ -60,8 +60,8 @@ describe('Config:', () => { it('should not transform urls to secure urls when disableSSL is true', () => { 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(); }); @@ -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', @@ -132,7 +132,7 @@ describe('Config:', () => { it('should correctly handle boolean config precedence', () => { 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(); }); @@ -149,7 +149,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(); @@ -166,7 +166,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().disableProxy.should.equal(true); + (await config()).disableProxy.should.equal(true); argStub.restore(); envStub.restore(); consoleStub.restore(); @@ -183,7 +183,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().disableEnvCheck.should.equal(true); + (await config()).disableEnvCheck.should.equal(true); argStub.restore(); envStub.restore(); consoleStub.restore(); 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/lightningUtils.ts b/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts index 0731c5f579..ef956d7bc9 100644 --- a/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts +++ b/modules/sdk-core/src/bitgo/lightning/lightningUtils.ts @@ -1,6 +1,19 @@ import * as statics from '@bitgo/statics'; import * as utxolib from '@bitgo/utxo-lib'; +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]; diff --git a/yarn.lock b/yarn.lock index 14ad24f21b..364f7c0ef6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11897,7 +11897,18 @@ html-minifier-terser@^6.0.2: tapable "^1.1.3" util.promisify "1.0.0" -"html-webpack-plugin-5@npm:html-webpack-plugin@^5", html-webpack-plugin@^5.5.0: +"html-webpack-plugin-5@npm:html-webpack-plugin@^5": + version "5.6.0" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" + integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== + dependencies: + "@types/html-minifier-terser" "^6.0.0" + html-minifier-terser "^6.0.2" + lodash "^4.17.21" + pretty-error "^4.0.0" + tapable "^2.0.0" + +html-webpack-plugin@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== @@ -13848,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" @@ -17921,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== @@ -18387,7 +18407,16 @@ string-argv@^0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18459,7 +18488,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -18473,6 +18502,13 @@ strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -19131,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== @@ -20301,7 +20337,7 @@ workerpool@6.2.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20319,6 +20355,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"