From 9780b96ca047782055450141cc19cb890bf18550 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Thu, 22 Oct 2026 12:49:02 -0400 Subject: [PATCH 1/2] feat(express): add host-scoped CA trust and SPKI pinning for BitGo connections Add an opt-in outbound TLS policy applied only to BitGo hostnames. A CA bundle (BITGO_CA_BUNDLE_PATH) replaces the system trust store for those hosts, and optional SPKI pins (BITGO_SPKI_PINS) must match a certificate in the presented chain. All other destinations keep the system store. The policy is attached in BitGoAPI.getAgentRequest, the single builder for BitGo-bound requests. Requests to policy hosts do not follow redirects, and the policy cannot be combined with BITGO_USE_PROXY since a proxy agent would bypass it. TICKET: WCN-3731 Co-Authored-By: Claude Sonnet 5.5 --- modules/express/.env.example | 3 + modules/express/README.md | 30 ++++ modules/express/src/args.ts | 12 ++ modules/express/src/clientRoutes.ts | 3 + modules/express/src/config.ts | 20 +++ modules/express/src/tlsPolicy.ts | 38 +++++ modules/express/test/unit/config.ts | 8 + modules/express/test/unit/tlsPolicy.ts | 75 +++++++++ modules/sdk-api/src/bitgoAPI.ts | 11 ++ modules/sdk-api/src/index.ts | 1 + modules/sdk-api/src/tlsPolicy.ts | 89 +++++++++++ modules/sdk-api/src/types.ts | 6 + modules/sdk-api/test/unit/tlsPolicy.ts | 206 +++++++++++++++++++++++++ 13 files changed, 502 insertions(+) create mode 100644 modules/express/src/tlsPolicy.ts create mode 100644 modules/express/test/unit/tlsPolicy.ts create mode 100644 modules/sdk-api/src/tlsPolicy.ts create mode 100644 modules/sdk-api/test/unit/tlsPolicy.ts diff --git a/modules/express/.env.example b/modules/express/.env.example index a6212b9fd34..77e83b1157f 100644 --- a/modules/express/.env.example +++ b/modules/express/.env.example @@ -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= diff --git a/modules/express/README.md b/modules/express/README.md index 12273b7af05..996741fc857 100644 --- a/modules/express/README.md +++ b/modules/express/README.md @@ -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. @@ -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: diff --git a/modules/express/src/args.ts b/modules/express/src/args.ts index a4181343b58..64b5e49f312 100644 --- a/modules/express/src/args.ts +++ b/modules/express/src/args.ts @@ -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', }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 0dd314fbcbc..d1f17beb27f 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -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'; @@ -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 @@ -1824,6 +1826,7 @@ function prepareBitGo(config: Config) { customBitcoinNetwork, accessToken, userAgent, + tlsPolicy, ...(useProxyUrl ? { customProxyAgent: new ProxyAgent({ diff --git a/modules/express/src/config.ts b/modules/express/src/config.ts index 05977f2e447..aab800d13f2 100644 --- a/modules/express/src/config.ts +++ b/modules/express/src/config.ts @@ -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 => ({ @@ -69,6 +80,9 @@ export const ArgConfig = (args): Partial => ({ lightningSignerFileSystemPath: args.lightningSignerFileSystemPath, keepAliveTimeout: args.keepalivetimeout, headersTimeout: args.headerstimeout, + bitgoCaBundlePath: args.bitgocabundlepath, + bitgoSpkiPins: splitList(args.bitgospkipins), + bitgoTlsHosts: splitList(args.bitgotlshosts), }); export const EnvConfig = (): Partial => ({ @@ -95,6 +109,9 @@ export const EnvConfig = (): Partial => ({ 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 = { @@ -181,6 +198,9 @@ function mergeConfigs(...configs: Partial[]): Config { lightningSignerFileSystemPath: get('lightningSignerFileSystemPath'), keepAliveTimeout: get('keepAliveTimeout'), headersTimeout: get('headersTimeout'), + bitgoCaBundlePath: get('bitgoCaBundlePath'), + bitgoSpkiPins: get('bitgoSpkiPins'), + bitgoTlsHosts: get('bitgoTlsHosts'), }; } diff --git a/modules/express/src/tlsPolicy.ts b/modules/express/src/tlsPolicy.ts new file mode 100644 index 00000000000..78ffb5d8b92 --- /dev/null +++ b/modules/express/src/tlsPolicy.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import { BitGoTlsPolicy, createBitGoTlsPolicy, Environments } from 'bitgo'; +import { Config } from './config'; + +const policies = new WeakMap(); + +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; +} diff --git a/modules/express/test/unit/config.ts b/modules/express/test/unit/config.ts index 5eba08d31c5..f58de55ae36 100644 --- a/modules/express/test/unit/config.ts +++ b/modules/express/test/unit/config.ts @@ -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', { @@ -156,6 +159,8 @@ describe('Config:', () => { lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath', keepalivetimeout: 'argkeepalivetimeout', headerstimeout: 'argheaderstimeout', + bitgocabundlepath: 'argcabundle', + bitgospkipins: 'argpin1,argpin2', }; }, }, @@ -184,6 +189,9 @@ describe('Config:', () => { lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath', keepAliveTimeout: 'argkeepalivetimeout', headersTimeout: 'argheaderstimeout', + bitgoCaBundlePath: 'argcabundle', + bitgoSpkiPins: ['argpin1', 'argpin2'], + bitgoTlsHosts: ['envhost'], }); envStub.restore(); }); diff --git a/modules/express/test/unit/tlsPolicy.ts b/modules/express/test/unit/tlsPolicy.ts new file mode 100644 index 00000000000..eff8920eccb --- /dev/null +++ b/modules/express/test/unit/tlsPolicy.ts @@ -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 => ({ ...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/); + }); +}); diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index e3d3d750d94..5e7795c57f9 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -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'); @@ -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; @@ -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 @@ -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; } /** diff --git a/modules/sdk-api/src/index.ts b/modules/sdk-api/src/index.ts index 2e08de054d1..8c3bf09c3cf 100644 --- a/modules/sdk-api/src/index.ts +++ b/modules/sdk-api/src/index.ts @@ -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'; diff --git a/modules/sdk-api/src/tlsPolicy.ts b/modules/sdk-api/src/tlsPolicy.ts new file mode 100644 index 00000000000..9b5c123d04f --- /dev/null +++ b/modules/sdk-api/src/tlsPolicy.ts @@ -0,0 +1,89 @@ +import * as crypto from 'crypto'; +import * as https from 'https'; +import * as tls from 'tls'; + +export interface BitGoTlsPolicyOptions { + /** + * PEM-encoded CA certificates that BitGo hosts are allowed to chain to. Replaces (does not extend) + * the system trust store for connections to `hosts`. When omitted, the system trust store is used. + */ + ca?: string | Buffer | Array; + /** + * Hostnames the policy applies to. `example.com` matches only that host; `.example.com` matches any + * subdomain of example.com (but not example.com itself). + */ + hosts: string[]; + /** + * Optional base64 SHA-256 digests of SubjectPublicKeyInfo structures. When set, at least one certificate + * in the presented chain must match one of the pins. Include a backup pin before rotating keys. + */ + spkiPins?: string[]; +} + +export interface BitGoTlsPolicy { + /** True when `url` is an https URL whose hostname is covered by the policy. */ + appliesTo(url: string): boolean; + /** Agent that enforces the CA bundle and pins; only to be used for URLs where `appliesTo` is true. */ + readonly agent: https.Agent; +} + +export function spkiPin(cert: { raw: Buffer }): string { + const spki = new crypto.X509Certificate(cert.raw).publicKey.export({ type: 'spki', format: 'der' }); + return crypto.createHash('sha256').update(spki).digest('base64'); +} + +function chainPins(leaf: tls.DetailedPeerCertificate): string[] { + const pins: string[] = []; + const seen = new Set(); + let cert: tls.DetailedPeerCertificate | undefined = leaf; + while (cert && cert.raw && !seen.has(cert.raw)) { + seen.add(cert.raw); + pins.push(spkiPin(cert)); + cert = cert.issuerCertificate; + } + return pins; +} + +function matchesHost(hostname: string, pattern: string): boolean { + const host = hostname.toLowerCase(); + const p = pattern.toLowerCase(); + return p.startsWith('.') ? host.endsWith(p) : host === p; +} + +export function createBitGoTlsPolicy(options: BitGoTlsPolicyOptions): BitGoTlsPolicy { + const { ca, hosts, spkiPins } = options; + if (hosts.length === 0) { + throw new Error('BitGo TLS policy requires at least one host'); + } + if (spkiPins !== undefined && spkiPins.length === 0) { + throw new Error('BitGo TLS policy spkiPins must be omitted or contain at least one pin'); + } + + const agent = new https.Agent({ + ca, + checkServerIdentity: (hostname, cert) => { + const err = tls.checkServerIdentity(hostname, cert); + if (err || !spkiPins) { + return err; + } + const presented = chainPins(cert as tls.DetailedPeerCertificate); + if (!presented.some((pin) => spkiPins.includes(pin))) { + return new Error(`No certificate in the chain for ${hostname} matches a configured SPKI pin`); + } + return undefined; + }, + }); + + return { + agent, + appliesTo(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return parsed.protocol === 'https:' && hosts.some((h) => matchesHost(parsed.hostname, h)); + }, + }; +} diff --git a/modules/sdk-api/src/types.ts b/modules/sdk-api/src/types.ts index 29d7a1af772..26487621dbe 100644 --- a/modules/sdk-api/src/types.ts +++ b/modules/sdk-api/src/types.ts @@ -1,4 +1,5 @@ import { EncryptionVersion, EnvironmentName, IRequestTracer, V1Network } from '@bitgo/sdk-core'; +import { type BitGoTlsPolicy } from './tlsPolicy'; import { ECPairInterface } from '@bitgo/utxo-lib'; import { type Agent } from 'http'; @@ -61,6 +62,11 @@ export interface BitGoAPIOptions { soneiumExplorerApiToken?: string; hmacVerification?: boolean; customProxyAgent?: Agent; + /** + * Restricts the CAs (and optionally public keys) accepted for BitGo hostnames. Cannot be combined with + * `customProxyAgent`, because a proxy agent owns the TLS connection and would bypass the policy. + */ + tlsPolicy?: BitGoTlsPolicy; refreshToken?: string; serverXpub?: string; stellarFederationServerUrl?: string; diff --git a/modules/sdk-api/test/unit/tlsPolicy.ts b/modules/sdk-api/test/unit/tlsPolicy.ts new file mode 100644 index 00000000000..02372a07075 --- /dev/null +++ b/modules/sdk-api/test/unit/tlsPolicy.ts @@ -0,0 +1,206 @@ +import * as assert from 'assert'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { BitGoAPI } from '../../src/bitgoAPI'; +import { createBitGoTlsPolicy, spkiPin } from '../../src/tlsPolicy'; + +class TestableBitGoAPI extends BitGoAPI { + req(url: string) { + return this.getAgentRequest('get', url); + } +} + +function openssl(cwd: string, ...args: string[]): void { + execFileSync('openssl', args, { cwd, stdio: 'pipe' }); +} + +interface Pki { + caPem: string; + leafKey: string; + leafPem: string; + leafPin: string; +} + +function makePki(dir: string, name: string): Pki { + openssl( + dir, + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + `${name}-ca.key`, + '-out', + `${name}-ca.pem`, + '-subj', + `/CN=${name} CA`, + '-days', + '2', + '-addext', + 'basicConstraints=critical,CA:TRUE' + ); + openssl( + dir, + 'req', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + `${name}-leaf.key`, + '-out', + `${name}-leaf.csr`, + '-subj', + '/CN=localhost' + ); + fs.writeFileSync(path.join(dir, `${name}-ext.cnf`), 'subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n'); + openssl( + dir, + 'x509', + '-req', + '-in', + `${name}-leaf.csr`, + '-CA', + `${name}-ca.pem`, + '-CAkey', + `${name}-ca.key`, + '-CAcreateserial', + '-out', + `${name}-leaf.pem`, + '-days', + '2', + '-extfile', + `${name}-ext.cnf` + ); + const read = (f: string) => fs.readFileSync(path.join(dir, f), 'utf8'); + const leafPem = read(`${name}-leaf.pem`); + return { + caPem: read(`${name}-ca.pem`), + leafKey: read(`${name}-leaf.key`), + leafPem, + leafPin: spkiPin(new crypto.X509Certificate(leafPem)), + }; +} + +function listen(pki: Pki, handler: http.RequestListener): Promise<{ server: https.Server; url: string }> { + return new Promise((resolve) => { + const server = https.createServer({ key: pki.leafKey, cert: pki.leafPem }, handler); + server.listen(0, '127.0.0.1', () => { + resolve({ server, url: `https://localhost:${(server.address() as { port: number }).port}` }); + }); + }); +} + +describe('BitGo TLS policy', function () { + let dir: string; + let trusted: Pki; + let rogue: Pki; + const servers: https.Server[] = []; + + before(function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tls-policy-')); + trusted = makePki(dir, 'trusted'); + rogue = makePki(dir, 'rogue'); + }); + + afterEach(function () { + servers.splice(0).forEach((s) => s.close()); + }); + + after(function () { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function serve(pki: Pki, handler: http.RequestListener = (_req, res) => res.end('ok')) { + const s = await listen(pki, handler); + servers.push(s.server); + return s.url; + } + + function api(options: { spkiPins?: string[]; hosts?: string[] } = {}) { + const tlsPolicy = createBitGoTlsPolicy({ + ca: trusted.caPem, + hosts: options.hosts ?? ['localhost'], + spkiPins: options.spkiPins, + }); + return new TestableBitGoAPI({ env: 'test', tlsPolicy }); + } + + it('accepts a certificate issued by the approved CA', async function () { + const url = await serve(trusted); + const res = await api().req(url); + assert.strictEqual(res.text, 'ok'); + }); + + it('rejects a certificate issued by an unapproved CA', async function () { + const url = await serve(rogue); + await assert.rejects(api().req(url), /unable to verify|self.signed|UNABLE_TO_GET_ISSUER/i); + }); + + it('accepts a matching SPKI pin and ignores extra backup pins', async function () { + const url = await serve(trusted); + const res = await api({ spkiPins: [trusted.leafPin, 'backup+pin+not+yet+deployed='] }).req(url); + assert.strictEqual(res.text, 'ok'); + }); + + it('accepts a pin on the issuing CA key, which survives leaf rotation', async function () { + const url = await serve(trusted); + const caPin = spkiPin(new crypto.X509Certificate(trusted.caPem)); + const res = await api({ spkiPins: [caPin] }).req(url); + assert.strictEqual(res.text, 'ok'); + }); + + it('rejects a trusted-CA certificate when no pin matches', async function () { + const url = await serve(trusted); + await assert.rejects(api({ spkiPins: [rogue.leafPin] }).req(url), /SPKI pin/); + }); + + it('does not apply to hosts outside the allowlist', function () { + const policy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['bitgo.com', '.bitgo.com'] }); + assert.ok(policy.appliesTo('https://app.bitgo.com/api/v2')); + assert.ok(policy.appliesTo('https://bitgo.com')); + assert.ok(!policy.appliesTo('https://evilbitgo.com')); + assert.ok(!policy.appliesTo('https://bitgo.com.evil.example')); + assert.ok(!policy.appliesTo('http://app.bitgo.com')); + assert.ok(!policy.appliesTo('not a url')); + }); + + it('leaves non-BitGo hosts on the default trust store', async function () { + const url = await serve(trusted); + const req = api({ hosts: ['.bitgo.com'] }).req(url); + // Default store does not trust our test CA, so the connection must fail rather than use the policy agent. + await assert.rejects(req, /unable to verify|self.signed|UNABLE_TO_GET_ISSUER/i); + }); + + it('does not follow a redirect off the allowlisted host', async function () { + let targetHit = false; + const target = await serve(trusted, (_req, res) => { + targetHit = true; + res.end('escaped'); + }); + const origin = await serve(trusted, (_req, res) => { + res.writeHead(302, { Location: `${target.replace('localhost', '127.0.0.1')}/` }); + res.end(); + }); + await assert.rejects(api().req(origin)); + assert.strictEqual(targetHit, false); + }); + + it('refuses to combine with a custom proxy agent', function () { + const tlsPolicy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['localhost'] }); + assert.throws( + () => new BitGoAPI({ env: 'test', tlsPolicy, customProxyAgent: new https.Agent() }), + /cannot be combined/ + ); + }); + + it('requires at least one host and a non-empty pin list', function () { + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: [] })); + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['a.com'], spkiPins: [] })); + }); +}); From cd7df1c5d7bac9ab40b974ce749b4369a7abbc40 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Thu, 22 Oct 2026 12:50:48 -0400 Subject: [PATCH 2/2] test(sdk-api): allow local net connections in tls policy tests Other sdk-api suites call nock.disableNetConnect() globally, which blocked the local HTTPS servers when the full suite ran. TICKET: WCN-3731 Co-Authored-By: Claude Sonnet 5.5 --- modules/sdk-api/test/unit/tlsPolicy.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/sdk-api/test/unit/tlsPolicy.ts b/modules/sdk-api/test/unit/tlsPolicy.ts index 02372a07075..fea3da21c78 100644 --- a/modules/sdk-api/test/unit/tlsPolicy.ts +++ b/modules/sdk-api/test/unit/tlsPolicy.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import * as nock from 'nock'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as http from 'http'; @@ -103,6 +104,7 @@ describe('BitGo TLS policy', function () { const servers: https.Server[] = []; before(function () { + nock.enableNetConnect(); dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tls-policy-')); trusted = makePki(dir, 'trusted'); rogue = makePki(dir, 'rogue'); @@ -113,6 +115,7 @@ describe('BitGo TLS policy', function () { }); after(function () { + nock.disableNetConnect(); fs.rmSync(dir, { recursive: true, force: true }); });