Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/express/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ BITGO_EXTERNAL_SIGNER_WALLET_IDS=

# Examples
# BITGO_EXTERNAL_SIGNER_WALLET_IDS="{"tbtc":[{"walletId":"xxx","walletPassword":"xxx","secret":"xxx"}],"hteth":["walletId":"xxx","walletPassword":"xxx"]}"
BITGO_CA_BUNDLE_PATH=
BITGO_SPKI_PINS=
BITGO_TLS_HOSTS=
30 changes: 30 additions & 0 deletions modules/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,33 @@ BitGo Express currently supports the following proxy protocols:
- SOCKSv5
- PAC

### Restricting trust for BitGo connections

By default BitGo Express trusts every certificate authority (CA) in the Node.js system store for all outbound HTTPS. `NODE_EXTRA_CA_CERTS` can only add trust, so it cannot remove a compromised CA. To shrink the blast radius of a CA compromise, Express can apply a stricter policy to BitGo hostnames only; every other destination (external signer, block explorers, webhooks, KMS) keeps using the system store.

- `BITGO_CA_BUNDLE_PATH`: PEM file with the CAs allowed to issue certificates for BitGo hostnames. It replaces the system store for those hostnames.
- `BITGO_SPKI_PINS`: optional comma-separated base64 SHA-256 digests of SubjectPublicKeyInfo. At least one certificate in the presented chain (leaf, intermediate or root) must match. Pinning a CA or intermediate key survives routine leaf rotation.
- `BITGO_TLS_HOSTS`: extra hostnames covered by the policy. The host of the configured BitGo URL (`BITGO_ENV` or `BITGO_CUSTOM_ROOT_URI`) is always covered. Use this for any additional BitGo API hostname (CDN or regional) you use.

Behavior to know before enabling:

- **Redirects are not followed** for requests to policy hosts, so a redirect cannot move a request onto a host outside the policy. A 3xx from BitGo surfaces as an error.
- **Cannot be combined with `BITGO_USE_PROXY`**; Express refuses to start, because a proxy agent owns the TLS connection and would bypass the policy.
- **A bad bundle or pin set blocks all BitGo traffic.** Express reads the bundle at startup and fails fast if it is missing or has no certificates, but a bundle that is valid yet lacks BitGo's current issuer fails at connection time.

Generate a pin from a certificate with:

```shell
openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
```

Rotation runbook:

1. Before a CA or key change at BitGo, add the new CA to the bundle and the new key's pin to `BITGO_SPKI_PINS` alongside the current ones, and deploy. Both old and new chains now validate.
2. After BitGo has cut over and traffic is healthy, remove the retired CA and pin.
3. Rollback: unset `BITGO_CA_BUNDLE_PATH` and `BITGO_SPKI_PINS` and restart; Express returns to the system store.
4. Ownership: whoever operates the Express deployment owns the bundle and pins. Alert on a rise in outbound TLS errors (`UNABLE_TO_GET_ISSUER_CERT`, `SPKI pin`) and track the expiry of every CA in the bundle.

### External Signing Mode

BitGo Express can be run in an external signing mode, where the signing of transactions is performed in a separate instance of BitGo Express that has access to the private keys.
Expand Down Expand Up @@ -239,6 +266,9 @@ BitGo Express is able to take configuration options from either command line arg
| N/A | --lightningSignerFileSystemPath | `BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH` | N/A | Local path specifying how to contact the lightning signer node. |
| N/A | --keepalivetimeout | `BITGO_KEEP_ALIVE_TIMEOUT` | N/A | BitGo Express server KeepAliveTimeout node. |
| N/A | --headerstimeout | `BITGO_HEADERS_TIMEOUT` | N/A | BitGo Express server HeadersTimeout node. |
| N/A | --bitgocabundlepath | `BITGO_CA_BUNDLE_PATH` | N/A | Path to a PEM file of the only CAs trusted for BitGo hostnames. See [Restricting trust for BitGo connections](#restricting-trust-for-bitgo-connections). |
| N/A | --bitgospkipins | `BITGO_SPKI_PINS` | N/A | Comma-separated base64 SHA-256 SPKI pins for BitGo hostnames. Include a backup pin. |
| N/A | --bitgotlshosts | `BITGO_TLS_HOSTS` | N/A | Comma-separated extra hostnames the policy applies to. Defaults to the host of the configured BitGo URL. A leading dot matches subdomains. |

\[0]: BitGo will also check the additional environment variables for some options for backwards compatibility, but these environment variables should be considered deprecated:

Expand Down
12 changes: 12 additions & 0 deletions modules/express/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ parser.addArgument(['-n', '--custombitcoinnetwork'], {
help: 'Force custom bitcoin network (e.g. testnet)',
});

parser.addArgument(['--bitgocabundlepath'], {
help: 'Path to a PEM file of the only CAs trusted for BitGo hostnames. Other hostnames keep using the system CA store.',
});

parser.addArgument(['--bitgospkipins'], {
help: 'Comma-separated base64 SHA-256 SPKI pins for BitGo hostnames. Include a backup pin.',
});

parser.addArgument(['--bitgotlshosts'], {
help: 'Comma-separated extra hostnames the BitGo TLS policy applies to (default: host of the configured BitGo URL). A leading dot matches subdomains.',
});

parser.addArgument(['-l', '--logfile'], {
help: 'Filepath to write the access log',
});
Expand Down
3 changes: 3 additions & 0 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
import { handlePayLightningInvoice } from './lightning/lightningInvoiceRoutes';
import { handleUpdateLightningWalletCoinSpecific } from './lightning/lightningWalletRoutes';
import { ProxyAgent } from 'proxy-agent';
import { getBitGoTlsPolicy } from './tlsPolicy';
import { isLightningCoinName } from '@bitgo/abstract-lightning';
import { handleLightningWithdraw } from './lightning/lightningWithdrawRoutes';
import createExpressRouter from './typedRoutes';
Expand Down Expand Up @@ -1803,6 +1804,7 @@ function parseBody(req: express.Request, res: express.Response, next: express.Ne
*/
function prepareBitGo(config: Config) {
const { env, customRootUri, customBitcoinNetwork } = config;
const tlsPolicy = getBitGoTlsPolicy(config);

return function prepBitGo(req: express.Request, res: express.Response, next: express.NextFunction) {
// Get access token
Expand All @@ -1824,6 +1826,7 @@ function prepareBitGo(config: Config) {
customBitcoinNetwork,
accessToken,
userAgent,
tlsPolicy,
...(useProxyUrl
? {
customProxyAgent: new ProxyAgent({
Expand Down
20 changes: 20 additions & 0 deletions modules/express/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ export interface Config {
lightningSignerFileSystemPath?: string;
keepAliveTimeout?: number;
headersTimeout?: number;
bitgoCaBundlePath?: string;
bitgoSpkiPins?: string[];
bitgoTlsHosts?: string[];
}

function splitList(value: string | undefined): string[] | undefined {
const items = (value ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
return items.length > 0 ? items : undefined;
}

export const ArgConfig = (args): Partial<Config> => ({
Expand All @@ -69,6 +80,9 @@ export const ArgConfig = (args): Partial<Config> => ({
lightningSignerFileSystemPath: args.lightningSignerFileSystemPath,
keepAliveTimeout: args.keepalivetimeout,
headersTimeout: args.headerstimeout,
bitgoCaBundlePath: args.bitgocabundlepath,
bitgoSpkiPins: splitList(args.bitgospkipins),
bitgoTlsHosts: splitList(args.bitgotlshosts),
});

export const EnvConfig = (): Partial<Config> => ({
Expand All @@ -95,6 +109,9 @@ export const EnvConfig = (): Partial<Config> => ({
lightningSignerFileSystemPath: readEnvVar('BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH'),
keepAliveTimeout: Number(readEnvVar('BITGO_KEEP_ALIVE_TIMEOUT')),
headersTimeout: Number(readEnvVar('BITGO_HEADERS_TIMEOUT')),
bitgoCaBundlePath: readEnvVar('BITGO_CA_BUNDLE_PATH'),
bitgoSpkiPins: splitList(readEnvVar('BITGO_SPKI_PINS')),
bitgoTlsHosts: splitList(readEnvVar('BITGO_TLS_HOSTS')),
});

export const DefaultConfig: Config = {
Expand Down Expand Up @@ -181,6 +198,9 @@ function mergeConfigs(...configs: Partial<Config>[]): Config {
lightningSignerFileSystemPath: get('lightningSignerFileSystemPath'),
keepAliveTimeout: get('keepAliveTimeout'),
headersTimeout: get('headersTimeout'),
bitgoCaBundlePath: get('bitgoCaBundlePath'),
bitgoSpkiPins: get('bitgoSpkiPins'),
bitgoTlsHosts: get('bitgoTlsHosts'),
};
}

Expand Down
38 changes: 38 additions & 0 deletions modules/express/src/tlsPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as fs from 'fs';
import { BitGoTlsPolicy, createBitGoTlsPolicy, Environments } from 'bitgo';
import { Config } from './config';

const policies = new WeakMap<Config, BitGoTlsPolicy | undefined>();

function defaultHosts(config: Config): string[] {
const baseUrl = config.customRootUri ?? Environments[config.env]?.uri;
return baseUrl ? [new URL(baseUrl).hostname] : [];
}

/**
* Builds (once per config) the outbound TLS policy for BitGo hostnames, or returns undefined when neither a CA
* bundle nor SPKI pins are configured. Throws at startup on misconfiguration so a bad bundle cannot silently
* fall back to the system trust store.
*/
export function getBitGoTlsPolicy(config: Config): BitGoTlsPolicy | undefined {
if (policies.has(config)) {
return policies.get(config);
}

const { bitgoCaBundlePath, bitgoSpkiPins, bitgoTlsHosts } = config;
let policy: BitGoTlsPolicy | undefined;
if (bitgoCaBundlePath || bitgoSpkiPins) {
if (process.env.BITGO_USE_PROXY) {
throw new Error('BITGO_CA_BUNDLE_PATH / BITGO_SPKI_PINS cannot be combined with BITGO_USE_PROXY');
}
const ca = bitgoCaBundlePath ? fs.readFileSync(bitgoCaBundlePath) : undefined;
if (ca && !ca.includes('BEGIN CERTIFICATE')) {
throw new Error(`CA bundle at ${bitgoCaBundlePath} contains no PEM certificates`);
}
const hosts = [...defaultHosts(config), ...(bitgoTlsHosts ?? [])];
policy = createBitGoTlsPolicy({ ca, hosts, spkiPins: bitgoSpkiPins });
}

policies.set(config, policy);
return policy;
}
8 changes: 8 additions & 0 deletions modules/express/test/unit/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ describe('Config:', () => {
BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH: 'envlightningSignerFileSystemPath',
BITGO_KEEP_ALIVE_TIMETOUT: 'envkeepalivetimeout',
BITGO_HEADERS_TIMETOUT: 'envheaderstimeout',
BITGO_CA_BUNDLE_PATH: 'envcabundle',
BITGO_SPKI_PINS: 'envpin1, envpin2',
BITGO_TLS_HOSTS: 'envhost',
});

const { config: proxyConfig } = proxyquire('../../src/config', {
Expand Down Expand Up @@ -156,6 +159,8 @@ describe('Config:', () => {
lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath',
keepalivetimeout: 'argkeepalivetimeout',
headerstimeout: 'argheaderstimeout',
bitgocabundlepath: 'argcabundle',
bitgospkipins: 'argpin1,argpin2',
};
},
},
Expand Down Expand Up @@ -184,6 +189,9 @@ describe('Config:', () => {
lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath',
keepAliveTimeout: 'argkeepalivetimeout',
headersTimeout: 'argheaderstimeout',
bitgoCaBundlePath: 'argcabundle',
bitgoSpkiPins: ['argpin1', 'argpin2'],
bitgoTlsHosts: ['envhost'],
});
envStub.restore();
});
Expand Down
75 changes: 75 additions & 0 deletions modules/express/test/unit/tlsPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as sinon from 'sinon';
import 'should';
import { Config, DefaultConfig } from '../../src/config';
import { getBitGoTlsPolicy } from '../../src/tlsPolicy';

const PEM = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n';

describe('getBitGoTlsPolicy', function () {
let dir: string;
let bundle: string;

before(function () {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'express-tls-'));
bundle = path.join(dir, 'ca.pem');
fs.writeFileSync(bundle, PEM);
});

after(function () {
fs.rmSync(dir, { recursive: true, force: true });
});

afterEach(function () {
sinon.restore();
});

const cfg = (overrides: Partial<Config> = {}): Config => ({ ...DefaultConfig, ...overrides });

it('is disabled when nothing is configured', function () {
(getBitGoTlsPolicy(cfg()) === undefined).should.be.true();
});

it('covers the host of the configured environment by default', function () {
const policy = getBitGoTlsPolicy(cfg({ env: 'prod', bitgoCaBundlePath: bundle }));
policy!.appliesTo('https://app.bitgo.com/api/v2/ping').should.be.true();
policy!.appliesTo('https://api.etherscan.io/v2').should.be.false();
});

it('covers a custom root URI and extra hosts', function () {
const policy = getBitGoTlsPolicy(
cfg({
customRootUri: 'https://bitgo.internal.example',
bitgoCaBundlePath: bundle,
bitgoTlsHosts: ['.cdn.example'],
})
);
policy!.appliesTo('https://bitgo.internal.example/x').should.be.true();
policy!.appliesTo('https://a.cdn.example/x').should.be.true();
policy!.appliesTo('https://app.bitgo.com').should.be.false();
});

it('supports pins without a CA bundle', function () {
const policy = getBitGoTlsPolicy(cfg({ bitgoSpkiPins: ['abc='] }));
(policy !== undefined).should.be.true();
});

it('returns the same policy for the same config', function () {
const c = cfg({ bitgoCaBundlePath: bundle });
(getBitGoTlsPolicy(c) === getBitGoTlsPolicy(c)).should.be.true();
});

it('fails when the bundle is missing or has no certificates', function () {
(() => getBitGoTlsPolicy(cfg({ bitgoCaBundlePath: path.join(dir, 'missing.pem') }))).should.throw(/ENOENT/);
const empty = path.join(dir, 'empty.pem');
fs.writeFileSync(empty, 'garbage');
(() => getBitGoTlsPolicy(cfg({ bitgoCaBundlePath: empty }))).should.throw(/no PEM certificates/);
});

it('refuses to run with BITGO_USE_PROXY', function () {
sinon.stub(process, 'env').value({ BITGO_USE_PROXY: 'http://proxy:1' });
(() => getBitGoTlsPolicy(cfg({ bitgoCaBundlePath: bundle }))).should.throw(/BITGO_USE_PROXY/);
});
});
11 changes: 11 additions & 0 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
VerifyShardsOptions,
WebhookOptions,
} from './types';
import { type BitGoTlsPolicy } from './tlsPolicy';
import shamir = require('secrets.js-grempe');
import pjson = require('../package.json');
const debug = debugLib('bitgo:api');
Expand Down Expand Up @@ -268,6 +269,7 @@ export class BitGoAPI implements BitGoBase {
protected _validate: boolean;
public readonly cookiesPropagationEnabled: boolean;
private _customProxyAgent?: Agent;
private _tlsPolicy?: BitGoTlsPolicy;
private _requestIdPrefix?: string;
private getAdditionalHeadersCb?: AdditionalHeadersCallback;
protected _hmacAuthStrategy: IHmacAuthStrategy;
Expand Down Expand Up @@ -445,7 +447,11 @@ export class BitGoAPI implements BitGoBase {
throw new Error('should not use https proxy while in browser');
}

if (params.tlsPolicy && params.customProxyAgent) {
throw new Error('tlsPolicy cannot be combined with customProxyAgent');
}
this._customProxyAgent = params.customProxyAgent;
this._tlsPolicy = params.tlsPolicy;
this._hmacAuthStrategy = params.hmacAuthStrategy ?? new DefaultHmacAuthStrategy();

// Only fetch constants from constructor if clientConstants was not provided
Expand Down Expand Up @@ -488,6 +494,11 @@ export class BitGoAPI implements BitGoBase {
if (this.cookiesPropagationEnabled) {
req = req.withCredentials();
}
if (this._tlsPolicy?.appliesTo(url)) {
// superagent keeps agent/ca on the request across redirects, so a redirect off the allowlisted hosts
// would be sent with the restricted trust. Refuse redirects instead of following them.
req.agent(this._tlsPolicy.agent).redirects(0);
}
return req;
}
/**
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export * from './decryptV1';
export * from './encrypt';
export * from './encryptionSession';
export * from './encryptV2';
export * from './tlsPolicy';
export * from './util';
export * from './types';
Loading
Loading