From 21198bd6dcb29a11c2751e206c04c1cac5bc6beb Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Tue, 27 Oct 2026 23:49:11 -0400 Subject: [PATCH] feat(express): add default domain-scoped CA trust for BitGo connections Restrict outbound HTTPS connections to official BitGo domains (*.bitgo.com, *.bitgo-test.com, *.bitgo-staging.com, *.bitgo-dev.com, and their apex domains) to a bundled multi-provider root CA store (Google Trust Services, Amazon Trust Services, DigiCert, Let's Encrypt/ISRG, and Sectigo) while keeping the system trust store for all non-BitGo destinations: - sdk-api: add createBitGoTlsPolicy({ ca, hosts }) and enforce it in BitGoAPI.getAgentRequest (refusing redirects and plain http on covered hosts) - express: enable the bundled 18-root CA policy by default at startup, with a single opt-out flag (--disablepincerts / BITGO_DISABLE_PIN_CERTS) and automatic proxy fallback when BITGO_USE_PROXY is set - scripts/verify-bitgo-policy-data.sh: CI check verifying all bundled roots are self-signed, valid (>30d), and cover all BitGo domains (plus --live handshake verification) TICKET: WCN-3731 --- modules/express/.env.example | 1 + modules/express/README.md | 14 + modules/express/bitgo-ca.pem | 381 ++++++++++++++++++ modules/express/package.json | 5 +- modules/express/src/args.ts | 6 + modules/express/src/clientRoutes.ts | 4 +- modules/express/src/config.ts | 13 + modules/express/src/expressApp.ts | 4 + modules/express/src/fetchEncryptedPrivKeys.ts | 7 +- modules/express/src/tlsPolicy.ts | 109 +++++ modules/express/test/unit/config.ts | 29 ++ modules/express/test/unit/tlsPolicy.ts | 113 ++++++ modules/sdk-api/src/bitgoAPI.ts | 15 +- modules/sdk-api/src/index.ts | 1 + modules/sdk-api/src/tlsPolicy.ts | 161 ++++++++ modules/sdk-api/src/types.ts | 6 + modules/sdk-api/test/unit/tlsPolicy.ts | 283 +++++++++++++ scripts/verify-bitgo-policy-data.sh | 142 +++++++ 18 files changed, 1288 insertions(+), 6 deletions(-) create mode 100644 modules/express/bitgo-ca.pem 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 create mode 100755 scripts/verify-bitgo-policy-data.sh diff --git a/modules/express/.env.example b/modules/express/.env.example index a6212b9fd34..c4a05b7e52e 100644 --- a/modules/express/.env.example +++ b/modules/express/.env.example @@ -37,3 +37,4 @@ BITGO_EXTERNAL_SIGNER_WALLET_IDS= # Examples # BITGO_EXTERNAL_SIGNER_WALLET_IDS="{"tbtc":[{"walletId":"xxx","walletPassword":"xxx","secret":"xxx"}],"hteth":["walletId":"xxx","walletPassword":"xxx"]}" +BITGO_DISABLE_PIN_CERTS= diff --git a/modules/express/README.md b/modules/express/README.md index 12273b7af05..aa1004c35b7 100644 --- a/modules/express/README.md +++ b/modules/express/README.md @@ -196,6 +196,19 @@ BitGo Express currently supports the following proxy protocols: - SOCKSv5 - PAC +### Domain-scoped CA trust for BitGo connections + +By default, BitGo Express restricts outbound HTTPS connections to official BitGo domains (`*.bitgo.com`, `*.bitgo-test.com`, `*.bitgo-staging.com`, `*.bitgo-dev.com`, and their apex domains) to a bundled set of root certificate authorities (`modules/express/bitgo-ca.pem`: Google Trust Services, Amazon Trust Services, DigiCert, Let's Encrypt/ISRG, and Sectigo). Every other outbound destination (external signer, block explorers, webhooks, KMS) continues to use the Node.js / system trust store. + +This is **enabled by default** to shrink the blast radius of a compromise of any unrelated CA in the global trust store without requiring manual certificate-pin maintenance across routine BitGo certificate renewals or CA migrations. + +- To opt out, pass `--disablepincerts` or set `BITGO_DISABLE_PIN_CERTS=true`. +- When `BITGO_USE_PROXY` is set, Express logs a startup warning and skips the bundled CA policy because the proxy agent owns the TLS connection. +- Redirects are not followed on covered BitGo hostnames so a redirect cannot move a request outside the domain policy; plain `http://` to a covered BitGo hostname is refused. +- At startup, Express warns if any root CA in `bitgo-ca.pem` expires within 30 days. + +> The underlying policy helper (`createBitGoTlsPolicy` in `@bitgo/sdk-api`) is Node-only: it relies on the Node `crypto` and `tls` modules and refuses to build in a browser 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 +252,7 @@ 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 | --disablepincerts | `BITGO_DISABLE_PIN_CERTS` | false | Disable the default domain-scoped CA bundle restriction for BitGo hostnames. See [Domain-scoped CA trust for BitGo connections](#domain-scoped-ca-trust-for-bitgo-connections). | \[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/bitgo-ca.pem b/modules/express/bitgo-ca.pem new file mode 100644 index 00000000000..3efa8101af4 --- /dev/null +++ b/modules/express/bitgo-ca.pem @@ -0,0 +1,381 @@ +# GlobalSign Root CA (validTo: Jan 28 12:00:00 2028 GMT) +-----BEGIN CERTIFICATE----- +MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMC +QkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNV +BAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBa +MFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdS +b290IENBMRswGQYDVQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQDaDuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtI +K+6NiY6arymAZavpxy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCO +XkNz8kHp1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG +snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3 +dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DP +AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRg +e2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUFAAOCAQEA1nPnfE920I2/7LqivjTF +KDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY7 +76BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9 +LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr ++WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME +HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== +-----END CERTIFICATE----- + +# DigiCert Global Root CA (validTo: Nov 10 00:00:00 2031 GMT) +-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYD +VQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBa +Fw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx +GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBS +b290IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKP +C3eQyaKl7hLOllsBCSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscF +s3YnFo97nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6g +SzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSii +cNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm8KPiGxvDl7I90VUwHwYDVR0jBBgw +FoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1E +nE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDi +qw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBA +I+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQkCAUw7C29 +C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE----- + +# DigiCert High Assurance EV Root CA (validTo: Nov 10 00:00:00 2031 GMT) +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYD +VQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2 +MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERp +Z2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNl +cnQgSGlnaCBBc3N1cmFuY2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAMbM5XPm+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlB +WTrT3JTWPNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM +xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeB +QVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5 +OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsgEsxBu24LUTi4S8sCAwEAAaNj +MGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9H +AdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3 +DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1 +ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VH +MWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 +Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCevEsXCS+0 +yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K +-----END CERTIFICATE----- + +# Starfield Services Root Certificate Authority - G2 (validTo: Dec 31 23:59:59 2037 GMT) +-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMxEDAOBgNV +BAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBU +ZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENl +cnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1 +OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNk +YWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJT +dGFyZmllbGQgU2VydmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p +OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm28xpWriu2 +dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1KTs9DkTvnVtYAcMtS +7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufehRhJfGZOozptqbXuNC66DQO4 +M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk6mFBrMnUVN+HL8cisibMn1lUaJ/8viov +xFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBL +NqaEd2ndOxmfZyMIbw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynV +v/heyNXBve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z +qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkdiEDPfUYd +/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jzaYyWf +/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6 +-----END CERTIFICATE----- + +# DigiCert Global Root G2 (validTo: Jan 15 12:00:00 2038 GMT) +-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYD +VQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu +Y29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0xMzA4MDExMjAwMDBa +Fw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx +GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBS +b290IEcyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/ +RrohCgiN9RlUyfuI2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxps +MNzaHxmx1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ +q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F +8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQvIOlCsRnKPZzFBQ9 +RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcN +AQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVn +NeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2Z +L7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUT +Fy8bJZ918rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe +pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTflMrY= +-----END CERTIFICATE----- + +# DigiCert Global Root G3 (validTo: Jan 15 12:00:00 2038 GMT) +-----BEGIN CERTIFICATE----- +MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t +MSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAeFw0xMzA4MDExMjAwMDBaFw0z +ODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAX +BgNVBAsTEHd3dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290 +IEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu8 +0JX28MzQC7phW1FGfp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6I +p6FrtUPOZ9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd +BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIxAK288mw/ +EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/oAIwOWZbwmSNuJ5Q +3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8sycX +-----END CERTIFICATE----- + +# USERTrust RSA Certification Authority (validTo: Jan 18 23:59:59 2038 GMT) +-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkG +A1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4w +HAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0Eg +Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5 +WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl +eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJU +cnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B +3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTb +f6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/Fp0YvVGONaanZshy +Z9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2VN3I5xI6Ta5MirdcmrS3ID3K +fyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq/nROacdrjGCT3sTHDN/hMq7MkztR +eJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+td +Omw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugT +ncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE +9JnnV4eeUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE +Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAdBgNVHQ4E +FgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeW +FPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1V +eCkZ7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jR +Ra8YFWSQEg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9s +pnFixdjQg3IM8WcRiQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwX +tuhxkYzeSf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ +XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL3 +9ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnu +Wih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1 +cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfGjjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE----- + +# USERTrust ECC Certification Authority (validTo: Jan 18 23:59:59 2038 GMT) +-----BEGIN CERTIFICATE----- +MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYD +VQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCB +iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBD +aXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVz +dCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQa +rFRaqfloI+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng +o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQW +BBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB +/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpk +ue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbR +qZtNyWHa0V1Xahg= +-----END CERTIFICATE----- + +# ISRG Root X1 (validTo: Jun 4 11:04:38 2035 GMT) +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkG +A1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUw +EwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBP +MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3Jv +dXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj +/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7i +S4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3Hs +LuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02 +dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFle +aJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAv +BbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymC +zLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC +1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB +BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnT +IZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxv +wuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt +hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztX +OoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIu +vtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1N +bdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4k +qKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcY +xn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- + +# Amazon Root CA 1 (validTo: Jan 17 00:00:00 2038 GMT) +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQsw +CQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAx +MB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNV +BAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOg +Q3pOsqTQNroBvo3bSMgHFzZM9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9 +tBb6dNqcmzU5L/qwIFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAw +hmahRWa6VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrI +qg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYE +FIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUAA4IBAQCY8jdaQZChGsV2USgg +NiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PMCCjjmCXPI6T53iHTfIUJrU6adTrC +C2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V +8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJc +JmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeX +eGADbkpyrqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE----- + +# Amazon Root CA 2 (validTo: May 26 00:00:00 2040 GMT) +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwFADA5MQsw +CQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAy +MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNV +BAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEB +BQADggIPADCCAgoCggIBAK2Wny2cSkxKgXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBv +IITplLGbhQPDW9tK6Mj4kHbZW0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZM +UnbqQ523BNFQ9lXg1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6 +PBJTYv9K8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r +2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+mez/CiVX18 +JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR8O70zoan4G7ptGmh +32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6ZjmUyl+17vIWR6IF9sZIUVyzfp +YgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vn +SUeVC06JIglJ4PVhHvG/LopyboBZ/1c6+XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3y +VAhU/bQtCSwXVEqY0VThUWcI0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8G +A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPl +Uq9LhONmUjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 +LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY+gn0oJMs +XdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kSk5Nrp+gvU5LEYFiw +zAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl7uxMMne0nxrpS10gxdr9HIcW +xkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygmbtmlyTrIQRNg91CMFa6ybRoVGld45pIq +2WWQgj9sAq+uEjonljYE1x2igGOpm/HlurR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JO +cQ3AWEbWaQbLU8uz/mtBzUF+fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn +9Kr5v2c69BoVYh63n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG +6lzWE7OE76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H +9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT4PsJYGw= +-----END CERTIFICATE----- + +# Amazon Root CA 3 (validTo: May 26 00:00:00 2040 GMT) +-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAzMB4X +DTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoT +BkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49 +AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKlui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6A +F2hiRVd9RFgdszflZwjrZt6jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG +MB0GA1UdDgQWBBSrttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWS +oxe3jfkrBqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM +YyRIHN8wfdVoOw== +-----END CERTIFICATE----- + +# Amazon Root CA 4 (validTo: May 26 00:00:00 2040 GMT) +-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSA0MB4X +DTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoT +BkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgNDB2MBAGByqGSM49AgEGBSuBBAAi +A2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhF +XRbb/egQbeOc4OO9X4Ri83BkM6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYD +VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc +84ZtV+WBMAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw +CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW1KyLa2tJ +ElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE----- + +# ISRG Root X2 (validTo: Sep 17 16:00:00 2040 GMT) +-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQswCQYDVQQG +EwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNV +BAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00MDA5MTcxNjAwMDBaME8xCzAJ +BgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEV +MBMGA1UEAxMMSVNSRyBSb290IFgyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJ +SvMWSj5cz3es3mcFDR0HttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvX +RdgKam7mAHf7AlF9ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI +zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdWtL4ndQav +Ei51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1/q4AaOeMSQ+2b1tb +FfLn +-----END CERTIFICATE----- + +# GTS Root R1 (validTo: Jun 22 00:00:00 2036 GMT) +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQG +EwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RT +IFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv +b3QgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreV +p0A8of2C+X0yBoJx9vaMf/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4 +IyfLpLGcY9vXmX7wCl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/S +CYe7zUjwTcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl +qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQ +f4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8Y/kB+Xj9e1x3+naH ++uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmkMiVOKvFlRNACzqrOSbTqn3yD +sEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97 +PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmx +lbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACY +PTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb +C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEeQkEzCzc9 +zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRiClmp +IgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34ar +J45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlH +xYciNuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/b +al8xa5meLMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAb +d03OIozUhfJFfbdT6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLs +QBqvFAnZ0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm +2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJw +NQzcmRk13NfIRmPVNnGuV/u3gm3c +-----END CERTIFICATE----- + +# GTS Root R2 (validTo: Jun 22 00:00:00 2036 GMT) +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQG +EwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RT +IFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv +b3QgUjIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3 +GTXd98GdVarTzTukk3LvCvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfg +LFuv5AS/T3KgGjSY6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/B +W9BuXvAuMC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k +RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWgf9RhD1FL +PD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV+3PBV2HdKFZ1E66H +jucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8YzodDqs5xoic4DSMPclQsciOzsS +rZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RWIr9qS34BIbIjMt/kmkRtWVtd9QCgHJvG +eJeNkP+byKq0rxFROV7Z+2et1VsRnTKaG73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9Om +TN6SpdTi3/UGVN4unUu0kzCqgc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGF +PP0UJAOyh9OktwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H +vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM80mJhwQTt +zuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyCB19m3H0Q/gxhswWV +7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2uNmSRXbBoGOqKYcl3qJfEycel +/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMgyALOWr7Z6v2yTcQvG99fevX4i8buMTol +UVVnjWQye+mew4K6Ki3pHrTgSAai/GevHyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFe +nTgCR2y59PYjJbigapordwj6xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGo +o7z7GJa7Um8M7YNRTOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCM +Elv924SgJPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV +7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl6WLAYv7Y +TVWW4tAR+kg0Eeye7QUd5MjWHYbL +-----END CERTIFICATE----- + +# GTS Root R3 (validTo: Jun 22 00:00:00 2036 GMT) +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv +b3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEi +MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/ +JlFyb+Kf1qPKzEUURout736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyR +RK2EE46ajA2ADDL24CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7 +VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/JdmZuVDFhOD +3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X +-----END CERTIFICATE----- + +# GTS Root R4 (validTo: Jun 22 00:00:00 2036 GMT) +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv +b3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEi +MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvV +YRg1rkDdc/eJkTBa6zzuhXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHc +i8nHc8iMai/lxKvRHYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8p/SgguMh +1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE----- diff --git a/modules/express/package.json b/modules/express/package.json index e45f25abc80..c9791eb4e2e 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -21,7 +21,7 @@ }, "scripts": { "test": "yarn unit-test", - "unit-test": "yarn nyc -- mocha", + "unit-test": "yarn test:data && yarn nyc -- mocha", "integration-test": "yarn nyc -- mocha \"test/integration/**/*.ts\"", "clean": "rm -rf dist/*", "prepare": "yarn build", @@ -34,7 +34,8 @@ "build-docker": "cd ../../ && yarn update-dockerfile && ./scripts/build-docker-express.sh", "check-fmt": "yarn prettier --check '{src,test}/**/*.{ts,js,json}'", "unprettied": "grep -R -L --include '*.ts' --include '*.js' --include '*.json' '@prettier' src test", - "fmt": "yarn prettier --write '{src,test}/**/*.{ts,js,json}'" + "fmt": "yarn prettier --write '{src,test}/**/*.{ts,js,json}'", + "test:data": "bash ../../scripts/verify-bitgo-policy-data.sh --live" }, "dependencies": { "@api-ts/io-ts-http": "^3.2.1", diff --git a/modules/express/src/args.ts b/modules/express/src/args.ts index a4181343b58..180498a62f6 100644 --- a/modules/express/src/args.ts +++ b/modules/express/src/args.ts @@ -84,6 +84,12 @@ parser.addArgument(['--disableenvcheck'], { help: 'disable checking for proper NODE_ENV when running in prod environment', }); +parser.addArgument(['--disablepincerts'], { + action: 'storeConst', + constant: true, + help: 'disable the default domain-scoped CA bundle restriction for BitGo hostnames', +}); + parser.addArgument(['-t', '--timeout'], { help: 'Proxy server timeout in milliseconds', }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 0d8b0780622..a8c58355114 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'; @@ -1807,6 +1808,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 @@ -1834,7 +1836,7 @@ function prepareBitGo(config: Config) { getProxyForUrl: () => useProxyUrl, }), } - : {}), + : { tlsPolicy }), }; req.bitgo = new BitGo(bitgoConstructorParams); diff --git a/modules/express/src/config.ts b/modules/express/src/config.ts index 05977f2e447..ba1e65bd8b8 100644 --- a/modules/express/src/config.ts +++ b/modules/express/src/config.ts @@ -19,6 +19,14 @@ function readEnvVar(name, ...deprecatedAliases): string | undefined { } } +function readBooleanEnvVar(name: string, ...deprecatedAliases: string[]): boolean | undefined { + const val = readEnvVar(name, ...deprecatedAliases); + if (val === undefined) { + return undefined; + } + return !['false', '0', 'no', 'off'].includes(val.trim().toLowerCase()); +} + export interface Config { port: number; bind: string; @@ -33,6 +41,7 @@ export interface Config { disableSSL: boolean; disableProxy: boolean; disableEnvCheck: boolean; + disablePinCerts: boolean; timeout: number; customRootUri?: string; customBitcoinNetwork?: V1Network; @@ -59,6 +68,7 @@ export const ArgConfig = (args): Partial => ({ disableSSL: args.disablessl, disableProxy: args.disableproxy, disableEnvCheck: args.disableenvcheck, + disablePinCerts: args.disablepincerts, timeout: args.timeout, customRootUri: args.customrooturi, customBitcoinNetwork: args.custombitcoinnetwork, @@ -85,6 +95,7 @@ export const EnvConfig = (): Partial => ({ disableSSL: readEnvVar('BITGO_DISABLE_SSL', 'BITGO_DISABLESSL', 'DISABLESSL', 'DISABLE_SSL') ? true : undefined, disableProxy: readEnvVar('BITGO_DISABLE_PROXY', 'DISABLE_PROXY') ? true : undefined, disableEnvCheck: readEnvVar('BITGO_DISABLE_ENV_CHECK', 'DISABLE_ENV_CHECK') ? true : undefined, + disablePinCerts: readBooleanEnvVar('BITGO_DISABLE_PIN_CERTS'), timeout: Number(readEnvVar('BITGO_TIMEOUT')), customRootUri: readEnvVar('BITGO_CUSTOM_ROOT_URI'), customBitcoinNetwork: readEnvVar('BITGO_CUSTOM_BITCOIN_NETWORK') as V1Network, @@ -108,6 +119,7 @@ export const DefaultConfig: Config = { // BG-9584: temporarily disable env check while we give users time to react to change in runtime behavior // This will require a major version bump, since this is a breaking change to default behavior. disableEnvCheck: true, + disablePinCerts: false, timeout: 305 * 1000, authVersion: 2, }; @@ -171,6 +183,7 @@ function mergeConfigs(...configs: Partial[]): Config { disableSSL, disableProxy: get('disableProxy'), disableEnvCheck: get('disableEnvCheck'), + disablePinCerts: get('disablePinCerts'), timeout: get('timeout'), customRootUri: customRootUri || undefined, customBitcoinNetwork: get('customBitcoinNetwork'), diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index ddbe7691618..71130354dd5 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -28,6 +28,7 @@ import { import { Environments } from 'bitgo'; import * as clientRoutes from './clientRoutes'; +import { getBitGoTlsPolicy } from './tlsPolicy'; /** * Set up the logging middleware provided by morgan @@ -267,6 +268,9 @@ function checkPreconditions(config: Config) { if (lightningSignerFileSystemPath !== undefined) { checkJsonFilePath(lightningSignerFileSystemPath); } + + // Fail fast on TLS policy misconfiguration before the server listens. + getBitGoTlsPolicy(config); } export function setupRoutes(app: express.Application, config: Config): void { diff --git a/modules/express/src/fetchEncryptedPrivKeys.ts b/modules/express/src/fetchEncryptedPrivKeys.ts index f3760a9f8f0..bfffea3e20a 100644 --- a/modules/express/src/fetchEncryptedPrivKeys.ts +++ b/modules/express/src/fetchEncryptedPrivKeys.ts @@ -11,6 +11,8 @@ import { writeFile } from 'fs'; import { logger } from '@bitgo/logger'; import { BitGo, EnvironmentName } from 'bitgo'; import 'dotenv/config'; +import { config } from './config'; +import { getBitGoTlsPolicy } from './tlsPolicy'; type Output = { [key: string]: string; @@ -37,7 +39,10 @@ const esWalletIDs = process.env.BITGO_EXTERNAL_SIGNER_WALLET_IDS; // example: "{ const esWalletWithPrv = process.env.BITGO_EXTERNAL_SIGNER_WALLET_IDS_WITH_PRV; // example: "[{"walletId":"xxx","encryptedPrv":"xxx"}]" // TODO: set env to 'test' or 'prod' -const bg = new BitGo({ env: (process.env.BITGO_EXTERNAL_SIGNER_ENV as EnvironmentName) ?? 'test' }); +const bg = new BitGo({ + env: (process.env.BITGO_EXTERNAL_SIGNER_ENV as EnvironmentName) ?? 'test', + tlsPolicy: getBitGoTlsPolicy(config()), +}); // TODO: set your access token here const accessToken = esAccessToken || ''; diff --git a/modules/express/src/tlsPolicy.ts b/modules/express/src/tlsPolicy.ts new file mode 100644 index 00000000000..825be4a1827 --- /dev/null +++ b/modules/express/src/tlsPolicy.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import debugLib from 'debug'; +import { BitGoTlsPolicy, createBitGoTlsPolicy, getCaExpiries } from 'bitgo'; +import { Config } from './config'; +import { TlsConfigurationError } from './errors'; + +const debug = debugLib('bitgo:express'); + +/** + * Official BitGo domain patterns covered by the default CA bundle restriction. + * Covers apex and all subdomains (`app.*`, `admin.*`, `cdn.*`, etc.) across prod, test, staging, and dev. + */ +export const BITGO_DEFAULT_TLS_HOSTS: string[] = [ + 'bitgo.com', + '.bitgo.com', + 'bitgo-test.com', + '.bitgo-test.com', + 'bitgo-staging.com', + '.bitgo-staging.com', + 'bitgo-dev.com', + '.bitgo-dev.com', +]; + +function resolveBundledCaPath(): string { + const candidates = [path.resolve(__dirname, '../bitgo-ca.pem'), path.resolve(__dirname, '../../bitgo-ca.pem')]; + const found = candidates.find((p) => fs.existsSync(p)); + if (!found) { + throw new TlsConfigurationError(`Bundled BitGo CA file not found (searched: ${candidates.join(', ')})`); + } + return found; +} + +let cachedBundledCaPem: string | undefined; + +function getBundledCaPem(): string { + if (cachedBundledCaPem === undefined) { + cachedBundledCaPem = fs.readFileSync(resolveBundledCaPath(), 'utf8'); + } + return cachedBundledCaPem; +} + +const policies = new WeakMap(); + +/** + * Logs the policy summary to debug, plus a console.warn per bundle CA expiring within 30 days, so an operator + * can update Express before a bundled root CA expiry breaks connections. + */ +function logPolicyStatus(hosts: string[], ca: string | Buffer | Array): void { + const expiries = getCaExpiries(ca); + debug('BitGo TLS policy enabled for %d host pattern(s), %d CA(s)', hosts.length, expiries.length); + for (const expiry of expiries) { + if (expiry.daysRemaining < 30) { + const when = + expiry.daysRemaining < 0 + ? `expired ${-expiry.daysRemaining} day(s) ago` + : `expires in ${expiry.daysRemaining} day(s)`; + console.warn( + `BitGo TLS policy CA '${ + expiry.subject + }' ${when} (${expiry.validTo.toISOString()}); update @bitgo/express to refresh the bundled CA roots` + ); + } + } +} + +/** + * Builds (once per config) the default outbound TLS policy for BitGo hostnames using the bundled multi-provider + * CA store (`bitgo-ca.pem`), or returns undefined when disabled via `disablePinCerts` (`--disablepincerts` / + * `BITGO_DISABLE_PIN_CERTS`) or when `BITGO_USE_PROXY` is set. + */ +export function getBitGoTlsPolicy(config: Config, caBundlePath?: string): BitGoTlsPolicy | undefined { + if (config.disablePinCerts) { + console.warn( + 'warning: BitGo TLS CA pinning is disabled via disablePinCerts (--disablepincerts / BITGO_DISABLE_PIN_CERTS).' + ); + policies.set(config, undefined); + return undefined; + } + + if (process.env.BITGO_USE_PROXY) { + console.warn( + 'warning: BitGo TLS CA pinning is skipped because BITGO_USE_PROXY is set; the proxy agent owns the TLS connection.' + ); + policies.set(config, undefined); + return undefined; + } + + if (!caBundlePath && policies.has(config)) { + const cached = policies.get(config); + if (cached !== undefined) { + return cached; + } + } + + let policy: BitGoTlsPolicy; + try { + const ca = caBundlePath ? fs.readFileSync(caBundlePath) : getBundledCaPem(); + policy = createBitGoTlsPolicy({ ca, hosts: BITGO_DEFAULT_TLS_HOSTS }); + logPolicyStatus(BITGO_DEFAULT_TLS_HOSTS, ca); + } catch (err) { + throw err instanceof TlsConfigurationError ? err : new TlsConfigurationError((err as Error).message); + } + + if (!caBundlePath) { + policies.set(config, policy); + } + return policy; +} diff --git a/modules/express/test/unit/config.ts b/modules/express/test/unit/config.ts index 5eba08d31c5..4916c5e30d3 100644 --- a/modules/express/test/unit/config.ts +++ b/modules/express/test/unit/config.ts @@ -119,6 +119,7 @@ describe('Config:', () => { BITGO_DISABLE_SSL: 'envdisableSSL', BITGO_DISABLE_PROXY: 'envdisableProxy', BITGO_DISABLE_ENV_CHECK: 'envdisableEnvCheck', + BITGO_DISABLE_PIN_CERTS: 'true', BITGO_TIMEOUT: 'envtimeout', BITGO_CUSTOM_ROOT_URI: 'envcustomRootUri', BITGO_CUSTOM_BITCOIN_NETWORK: 'envcustomBitcoinNetwork', @@ -145,6 +146,7 @@ describe('Config:', () => { disablessl: 'argdisableSSL', disableproxy: 'argdisableProxy', disableenvcheck: 'argdisableEnvCheck', + disablepincerts: 'argdisablePinCerts', timeout: 'argtimeout', customrooturi: 'argcustomRootUri', custombitcoinnetwork: 'argcustomBitcoinNetwork', @@ -174,6 +176,7 @@ describe('Config:', () => { disableSSL: 'argdisableSSL', disableProxy: 'argdisableProxy', disableEnvCheck: 'argdisableEnvCheck', + disablePinCerts: 'argdisablePinCerts', timeout: 'argtimeout', customRootUri: 'https://argcustomRootUri', customBitcoinNetwork: 'argcustomBitcoinNetwork', @@ -278,6 +281,32 @@ describe('Config:', () => { should.not.exist(parsed.disablessl); should.not.exist(parsed.disableenvcheck); should.not.exist(parsed.disableproxy); + should.not.exist(parsed.disablepincerts); argvStub.restore(); }); + + it('should handle BITGO_DISABLE_PIN_CERTS boolean values and --disablepincerts CLI flag', () => { + for (const [raw, expected] of [ + ['true', true], + ['1', true], + ['false', false], + ['0', false], + ['no', false], + ] as const) { + const envStub = sinon.stub(process, 'env').value({ BITGO_DISABLE_PIN_CERTS: raw }); + const { config: proxyConfig } = proxyquire('../../src/config', { + './args': { args: () => ({}) }, + }); + proxyConfig().disablePinCerts.should.equal(expected); + envStub.restore(); + } + + const argvStub = sinon.stub(process, 'argv').value([process.argv[0], process.argv[1], '--disablepincerts']); + try { + const parsed = args.args(); + parsed.disablepincerts.should.equal(true); + } finally { + argvStub.restore(); + } + }); }); diff --git a/modules/express/test/unit/tlsPolicy.ts b/modules/express/test/unit/tlsPolicy.ts new file mode 100644 index 00000000000..2b4c76506ce --- /dev/null +++ b/modules/express/test/unit/tlsPolicy.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { execFileSync } from 'child_process'; +import 'should'; +import { Config, DefaultConfig } from '../../src/config'; +import { TlsConfigurationError } from '../../src/errors'; +import { BITGO_DEFAULT_TLS_HOSTS, getBitGoTlsPolicy } from '../../src/tlsPolicy'; + +describe('getBitGoTlsPolicy', function () { + let dir: string; + let shortLivedBundle: string; + + before(function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'express-tls-')); + shortLivedBundle = path.join(dir, 'ca.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + path.join(dir, 'ca.key'), + '-out', + shortLivedBundle, + '-subj', + '/CN=test CA', + '-days', + '2', + '-addext', + 'basicConstraints=critical,CA:TRUE', + ], + { stdio: 'pipe' } + ); + }); + + afterEach(function () { + sinon.restore(); + }); + + after(function () { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const cfg = (overrides: Partial = {}): Config => ({ ...DefaultConfig, ...overrides }); + + it('is enabled by default, covers all official BitGo domains (apex + subdomains), and does not warn on healthy CAs', function () { + const warnStub = sinon.stub(console, 'warn'); + const policy = getBitGoTlsPolicy(cfg()); + (policy !== undefined).should.be.true(); + warnStub.notCalled.should.be.true(); + for (const host of [ + 'bitgo.com', + 'app.bitgo.com', + 'admin.bitgo.com', + 'cdn.bitgo.com', + 'bitgo-test.com', + 'app.bitgo-test.com', + 'admin.bitgo-test.com', + 'bitgo-staging.com', + 'app.bitgo-staging.com', + 'admin.bitgo-staging.com', + 'bitgo-dev.com', + 'app.bitgo-dev.com', + 'admin.bitgo-dev.com', + ]) { + policy!.appliesTo(`https://${host}/api/v2/ping`).should.be.true(); + } + policy!.appliesTo('https://api.etherscan.io/v2').should.be.false(); + policy!.appliesTo('https://localhost:3000/api/v2').should.be.false(); + BITGO_DEFAULT_TLS_HOSTS.length.should.equal(8); + }); + + it('can be opted out with disablePinCerts (--disablepincerts / BITGO_DISABLE_PIN_CERTS) and logs a warning', function () { + const warnStub = sinon.stub(console, 'warn'); + const policy = getBitGoTlsPolicy(cfg({ disablePinCerts: true })); + (policy === undefined).should.be.true(); + warnStub.calledWith(sinon.match(/disablePinCerts/)).should.be.true(); + }); + + it('returns the same policy for the same config', function () { + const c = cfg(); + getBitGoTlsPolicy(c)!.should.equal(getBitGoTlsPolicy(c)); + }); + + it('skips CA pinning with a startup warning when BITGO_USE_PROXY is set (including after initial cache)', function () { + const c = cfg(); + (getBitGoTlsPolicy(c) !== undefined).should.be.true(); + sinon.stub(process, 'env').value({ BITGO_USE_PROXY: 'http://proxy:1' }); + const warnStub = sinon.stub(console, 'warn'); + const policy = getBitGoTlsPolicy(c); + (policy === undefined).should.be.true(); + warnStub.calledWith(sinon.match(/BITGO_USE_PROXY/)).should.be.true(); + }); + + it('warns on bundle CAs expiring within 30 days', function () { + const warnStub = sinon.stub(console, 'warn'); + const policy = getBitGoTlsPolicy(cfg(), shortLivedBundle); + (policy !== undefined).should.be.true(); + warnStub.calledWith(sinon.match(/expires in/)).should.be.true(); + }); + + it('raises TlsConfigurationError when the bundle is missing or invalid', function () { + (() => getBitGoTlsPolicy(cfg(), path.join(dir, 'missing.pem'))).should.throw(TlsConfigurationError); + const empty = path.join(dir, 'empty.pem'); + fs.writeFileSync(empty, 'garbage'); + (() => getBitGoTlsPolicy(cfg(), empty)).should.throw(TlsConfigurationError); + }); +}); diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 504868e629a..87c114ca18d 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; } /** @@ -764,8 +775,8 @@ export class BitGoAPI implements BitGoBase { } // client constants call cannot be authenticated using the normal HMAC validation - // scheme, so we need to use a raw superagent instance to do this request. - // Proxy settings must still be respected however + // scheme. Proxy settings (and the TLS policy) must still be respected, so the + // request goes through the same getAgentRequest funnel as every other call. const resultPromise = this.getAgentRequest('get', this.url('/client/constants')); resultPromise.set('BitGo-SDK-Version', this._version); if (this._customProxyAgent) { 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..aa8a16d723d --- /dev/null +++ b/modules/sdk-api/src/tlsPolicy.ts @@ -0,0 +1,161 @@ +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`. + */ + 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[]; +} + +export interface BitGoTlsPolicy { + /** + * True for a covered host over https. THROWS for a covered host contacted over http, because a silent + * downgrade would bypass the policy's trust constraints. False for non-covered hosts (any protocol). + */ + appliesTo(url: string): boolean; + /** Agent that enforces the CA bundle; only to be used for URLs where `appliesTo` is true. */ + readonly agent: https.Agent; +} + +export interface CaExpiry { + subject: string; + validTo: Date; + daysRemaining: number; +} + +function parseCaCertificates(ca: string | Buffer | Array): { + blocks: string[]; + certs: crypto.X509Certificate[]; +} { + const blocks: string[] = []; + const certs: crypto.X509Certificate[] = []; + const entries = Array.isArray(ca) ? ca : [ca]; + for (const entry of entries) { + // typeof narrowing is used because sdk-api's browser typings define a Buffer that does not relate to + // the @types/node Buffer in the union, which would defeat instanceof narrowing. + const text = typeof entry === 'string' ? entry : entry.toString('utf8'); + const matches = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!matches || matches.length === 0) { + throw new Error('BitGo TLS policy ca must contain at least one PEM certificate'); + } + for (const block of matches) { + try { + certs.push(new crypto.X509Certificate(block)); + } catch (err) { + throw new Error(`BitGo TLS policy ca contains an invalid certificate: ${(err as Error).message}`); + } + blocks.push(block); + } + } + if (blocks.length === 0) { + throw new Error('BitGo TLS policy ca must contain at least one PEM certificate'); + } + return { blocks, certs }; +} + +/** + * Splits a PEM bundle into its individual certificate blocks and parses each one, so a malformed or empty + * bundle is rejected at policy build time instead of at the first handshake. Node's crypto.X509Certificate + * only reads the FIRST certificate of a concatenated PEM string, so multi-cert bundles must be split first. + */ +export function normalizeCaBundle(ca: string | Buffer | Array): string[] { + return parseCaCertificates(ca).blocks; +} + +/** Expiry information for every certificate in a bundle, for expiry monitoring and alerting. */ +export function getCaExpiries(ca: string | Buffer | Array): CaExpiry[] { + const now = Date.now(); + return parseCaCertificates(ca).certs.map((cert) => { + const validTo = new Date(cert.validTo); + return { + subject: cert.subject, + validTo, + daysRemaining: Math.floor((validTo.getTime() - now) / 86_400_000), + }; + }); +} + +function matchesHost(hostname: string, pattern: string): boolean { + const host = hostname.toLowerCase().replace(/\.+$/, ''); + const p = pattern.toLowerCase().replace(/\.+$/, ''); + return p.startsWith('.') ? host.endsWith(p) : host === p; +} + +/** + * Normalizes an operator-supplied host pattern to the canonical form `matchesHost` understands: lower-case, + * no scheme/path, no trailing dot. Rejects glob patterns, because translating `*.bitgo.com` into a covering + * pattern requires guessing intent (subdomains only vs. apex too) — a silent guess would be a false sense of + * safety. Runs once at policy build time, so a malformed host list fails at startup instead of silently + * protecting nothing at connection time. + */ +export function parsePolicyHost(host: string): string { + let h = host.trim().toLowerCase(); + h = h + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/\.+$/, ''); + if (h.includes('*')) { + throw new Error( + `BitGo TLS policy host pattern '${host}' must not use wildcards; use an exact host or '.domain.com' for subdomains` + ); + } + const body = h.startsWith('.') ? h.slice(1) : h; + if (!body || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(body)) { + throw new Error(`BitGo TLS policy host pattern '${host}' is not a valid hostname`); + } + return h; +} + +export function createBitGoTlsPolicy(options: BitGoTlsPolicyOptions): BitGoTlsPolicy { + // Node-only: browser bundles stub `tls` and lack crypto.X509Certificate, which this policy is built on. + // Fail with a clear message instead of a cryptic throw at the first X509Certificate use. + if (typeof tls.checkServerIdentity !== 'function' || typeof crypto.X509Certificate !== 'function') { + throw new Error('BitGo TLS policy is only supported in Node.js (it requires the Node crypto and tls APIs)'); + } + const { ca, hosts } = options; + if (hosts.length === 0) { + throw new Error('BitGo TLS policy requires at least one host'); + } + // Normalize once at build time so every later appliesTo comparison is exact (parsePolicyHost). + const normalizedHosts = hosts.map((h) => parsePolicyHost(h)); + if (ca === undefined) { + throw new Error('BitGo TLS policy requires a CA bundle'); + } + + // Validate the CA bundle at build time so a malformed bundle fails here, not at the first handshake. + const normalizedCa = normalizeCaBundle(ca); + + const agent = new https.Agent({ + ca: normalizedCa, + }); + + return { + agent, + appliesTo(url: string): boolean { + // superagent prepends 'http://' when a URL lacks an explicit scheme; mirror that fallback so + // schemeless or protocol-relative URLs to covered hosts are caught and rejected as http downgrades. + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) ? url : `http://${url.replace(/^\/+/, '')}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return false; + } + const covered = normalizedHosts.some((h) => matchesHost(parsed.hostname, h)); + if (covered && (parsed.protocol !== 'https:' || !url.startsWith('https://'))) { + throw new Error( + `BitGo TLS policy refuses insecure ${parsed.protocol}//${parsed.hostname} connection to a covered host; only https is allowed` + ); + } + return covered; + }, + }; +} diff --git a/modules/sdk-api/src/types.ts b/modules/sdk-api/src/types.ts index 29d7a1af772..8e51d6d8d1f 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 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..2099da56312 --- /dev/null +++ b/modules/sdk-api/test/unit/tlsPolicy.ts @@ -0,0 +1,283 @@ +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'; +import * as https from 'https'; +import * as os from 'os'; +import * as path from 'path'; +import crypto from 'crypto'; +import { BitGoAPI } from '../../src/bitgoAPI'; +import { type BitGoTlsPolicyOptions, createBitGoTlsPolicy, getCaExpiries, parsePolicyHost } from '../../src/tlsPolicy'; + +function openssl(cwd: string, ...args: string[]): void { + execFileSync('openssl', args, { cwd, stdio: 'pipe' }); +} + +interface Pki { + caPem: string; + leafKey: string; + leafPem: 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'); + return { + caPem: read(`${name}-ca.pem`), + leafKey: read(`${name}-leaf.key`), + leafPem: read(`${name}-leaf.pem`), + }; +} + +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: Array = []; + + before(function () { + nock.enableNetConnect(/^(127\.0\.0\.1|localhost)(:\d+)?$/); + 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 () { + nock.disableNetConnect(); + 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: { ca?: string | Buffer | Array; hosts?: string[] } = {}) { + const tlsPolicy = createBitGoTlsPolicy({ + ca: options.ca ?? trusted.caPem, + hosts: options.hosts ?? ['localhost'], + }); + return new BitGoAPI({ env: 'test', tlsPolicy, clientConstants: {} as any }); + } + + it('accepts a certificate issued by the approved CA', async function () { + const url = await serve(trusted); + const res = await api().get(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().get(url), /unable to verify|self.signed|UNABLE_TO_GET_ISSUER/i); + }); + + it('enforces the policy on fetchConstants()', async function () { + const url = await serve(rogue, (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ maxFee: 100 })); + }); + const tlsPolicy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['localhost'] }); + const client = new BitGoAPI({ env: 'custom', customRootURI: url, tlsPolicy, clientConstants: {} as any }); + (BitGoAPI as unknown as { _constants: Record })._constants = {}; + await assert.rejects(client.fetchConstants(), /unable to verify|self.signed|UNABLE_TO_GET_ISSUER/i); + }); + + it('does not apply to hosts outside the allowlist and handles trailing-dot FQDNs', 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://app.bitgo.com./api/v2')); + assert.ok(policy.appliesTo('https://bitgo.com')); + 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://evilbitgo.com')); + assert.ok(!policy.appliesTo('not a url')); + }); + + it('refuses plain http and schemeless URLs to a covered host instead of silently skipping the policy', function () { + const policy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['bitgo.com', '.bitgo.com'] }); + assert.throws(() => policy.appliesTo('http://app.bitgo.com/api/v2'), /refuses insecure/); + assert.throws(() => policy.appliesTo('http://app.bitgo.com./api/v2'), /refuses insecure/); + assert.throws(() => policy.appliesTo('http://bitgo.com'), /refuses insecure/); + assert.throws(() => policy.appliesTo('app.bitgo.com/api/v2'), /refuses insecure/); + assert.throws(() => policy.appliesTo('//app.bitgo.com/api/v2'), /refuses insecure/); + assert.throws(() => policy.appliesTo('HTTPS://app.bitgo.com/api/v2'), /refuses insecure/); + }); + + it('leaves non-BitGo hosts on the default trust store', async function () { + const url = await serve(trusted); + const req = api({ hosts: ['.bitgo.com'] }).get(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 targetServer = http.createServer((_req, res) => { + targetHit = true; + res.end('escaped'); + }); + await new Promise((resolve) => targetServer.listen(0, '127.0.0.1', () => resolve())); + servers.push(targetServer); + const targetPort = (targetServer.address() as { port: number }).port; + + const origin = await serve(trusted, (_req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${targetPort}/` }); + res.end(); + }); + await assert.rejects(api().get(origin), (err: { status?: number }) => err.status === 302); + 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 CA bundle', function () { + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: [] })); + assert.throws(() => createBitGoTlsPolicy({ hosts: ['a.com'] } as unknown as BitGoTlsPolicyOptions)); + assert.throws(() => createBitGoTlsPolicy({ ca: [], hosts: ['a.com'] }), /at least one PEM certificate/); + }); + + it('rejects an empty policy at compile time', function () { + // Never invoked: exists only to prove that `{ hosts }` alone is a compile error. + function compileCheck(): void { + // @ts-expect-error a policy must provide a CA bundle + createBitGoTlsPolicy({ hosts: ['a.com'] }); + } + void compileCheck; + }); + + it('parses every certificate in a multi-cert bundle and trusts non-first CAs', async function () { + const bundle = `${trusted.caPem}\n${rogue.caPem}`; + const url = await serve(rogue); + const res = await api({ ca: bundle }).get(url); + assert.strictEqual(res.text, 'ok'); + }); + + it('rejects a bundle containing a malformed certificate', function () { + const garbage = `${trusted.caPem}\n-----BEGIN CERTIFICATE-----\nnot base64 at all\n-----END CERTIFICATE-----`; + assert.throws(() => createBitGoTlsPolicy({ ca: garbage, hosts: ['localhost'] }), /invalid certificate/); + }); + + it('rejects a bundle with no certificates', function () { + assert.throws( + () => createBitGoTlsPolicy({ ca: 'no certificates here', hosts: ['localhost'] }), + /at least one PEM certificate/ + ); + }); + + it('reports expiry information for every bundle certificate', function () { + const expiries = getCaExpiries(trusted.caPem); + assert.strictEqual(expiries.length, 1); + assert.ok(expiries[0].subject.includes('trusted CA')); + assert.ok(expiries[0].validTo.getTime() > Date.now()); + assert.ok(expiries[0].daysRemaining > 0 && expiries[0].daysRemaining <= 2); + }); + + it('normalizes host patterns to the canonical form the matcher understands', function () { + assert.strictEqual(parsePolicyHost('https://app.bitgo.com'), 'app.bitgo.com'); + assert.strictEqual(parsePolicyHost('https://app.bitgo.com/api/v2'), 'app.bitgo.com'); + assert.strictEqual(parsePolicyHost('APP.BITGO.COM...'), 'app.bitgo.com'); + assert.strictEqual(parsePolicyHost('.bitgo.com'), '.bitgo.com'); + assert.strictEqual(parsePolicyHost('app.bitgo.com'), 'app.bitgo.com'); + }); + + it('rejects malformed host patterns at policy build time', function () { + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['*.bitgo.com'] }), /must not use wildcards/); + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['not a host!'] }), /not a valid hostname/); + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['..'] }), /not a valid hostname/); + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['..bitgo.com'] }), /not a valid hostname/); + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['bitgo..com'] }), /not a valid hostname/); + assert.throws(() => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['-bitgo.com'] }), /not a valid hostname/); + }); + + it('applies a pasted full URL host pattern to the intended host', function () { + const policy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['https://app.bitgo.com'] }); + assert.ok(policy.appliesTo('https://app.bitgo.com/api/v2')); + assert.ok(!policy.appliesTo('https://evilbitgo.com')); + }); + + it('refuses to build a policy without the Node crypto/tls APIs', function () { + // Simulates a browser bundle, where crypto.X509Certificate does not exist. + const apiObj = crypto as unknown as { X509Certificate: unknown }; + const original = apiObj.X509Certificate; + try { + apiObj.X509Certificate = undefined; + assert.throws( + () => createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['localhost'] }), + /only supported in Node.js/ + ); + } finally { + apiObj.X509Certificate = original; + } + }); +}); diff --git a/scripts/verify-bitgo-policy-data.sh b/scripts/verify-bitgo-policy-data.sh new file mode 100755 index 00000000000..953362c2e70 --- /dev/null +++ b/scripts/verify-bitgo-policy-data.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Guardian for the bundled BitGo TLS CA store (modules/express/bitgo-ca.pem). +# Fails CI when the bundle is missing, malformed, contains non-CA, cross-signed +# or expiring (<30d) anchors, or fails to cover all official BitGo domains. +# Pass `--live` to additionally perform a real TLS handshake against the +# production BitGo endpoint (app.bitgo.com) using ONLY the bundled CAs — +# runs automatically in `yarn test:data` (before unit-test). Pass `--live-all` +# to check all 9 endpoints (dev/staging/test/prod + admin + cdn) manually. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle="$repo_root/modules/express/bitgo-ca.pem" +policy_js="$repo_root/modules/sdk-api/dist/src/tlsPolicy.js" +live="${1:-}" + +[[ -f "$bundle" ]] || { echo "error: missing CA bundle: $bundle" >&2; exit 1; } +[[ -f "$policy_js" ]] || { + echo "error: sdk-api not built ($policy_js missing); run 'yarn build' in modules/sdk-api first" >&2 + exit 1 +} + +node - "$bundle" "$policy_js" "$live" <<'EOF' +const fs = require('fs'); +const https = require('https'); +const crypto = require('crypto'); +const [bundlePath, policyJs, liveFlag] = process.argv.slice(2); +const { createBitGoTlsPolicy, getCaExpiries } = require(policyJs); + +const bundleText = fs.readFileSync(bundlePath, 'utf8'); +const blocks = bundleText.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); +if (!blocks || blocks.length === 0) { + console.error('error: bundle contains no certificates'); + process.exit(1); +} + +for (const block of blocks) { + const cert = new crypto.X509Certificate(block); + if (cert.issuer !== cert.subject) { + console.error(`error: bundle cert '${cert.subject}' is cross-signed (issuer '${cert.issuer}'); trust anchors must be self-signed`); + process.exit(1); + } + if (!cert.ca) { + console.error(`error: bundle cert '${cert.subject}' is not a CA certificate (basicConstraints CA:TRUE missing)`); + process.exit(1); + } +} + +for (const expiry of getCaExpiries(bundleText)) { + if (expiry.daysRemaining < 30) { + console.error(`error: bundle cert '${expiry.subject}' expires in ${expiry.daysRemaining} day(s) (${expiry.validTo.toISOString()})`); + process.exit(1); + } +} + +const hostPatterns = [ + 'bitgo.com', + '.bitgo.com', + 'bitgo-test.com', + '.bitgo-test.com', + 'bitgo-staging.com', + '.bitgo-staging.com', + 'bitgo-dev.com', + '.bitgo-dev.com', +]; + +const policy = createBitGoTlsPolicy({ + ca: bundleText, + hosts: hostPatterns, +}); + +const allExpectedDomains = [ + 'bitgo.com', + 'bitgo-test.com', + 'bitgo-staging.com', + 'bitgo-dev.com', + 'app.bitgo.com', + 'app.bitgo-test.com', + 'app.bitgo-staging.com', + 'app.bitgo-dev.com', + 'admin.bitgo.com', + 'admin.bitgo-test.com', + 'admin.bitgo-staging.com', + 'admin.bitgo-dev.com', + 'cdn.bitgo.com', +]; + +for (const h of allExpectedDomains) { + if (!policy.appliesTo(`https://${h}/api/v2`)) { + console.error(`error: host not covered by policy: ${h}`); + process.exit(1); + } + try { + policy.appliesTo(`http://${h}`); + console.error(`error: http downgrade not rejected for covered host: ${h}`); + process.exit(1); + } catch (err) { + if (!/refuses insecure/.test(err.message)) { + console.error(err.message); + process.exit(1); + } + } +} + +const liveHosts = [ + 'app.bitgo.com', + 'app.bitgo-test.com', + 'app.bitgo-staging.com', + 'app.bitgo-dev.com', + 'admin.bitgo.com', + 'admin.bitgo-test.com', + 'admin.bitgo-staging.com', + 'admin.bitgo-dev.com', + 'cdn.bitgo.com', +]; + +console.log(`ok: ${blocks.length} self-signed CA certs valid (>30d), ${allExpectedDomains.length} BitGo hosts covered, http downgrades throw`); + +if (liveFlag === '--live' || liveFlag === '--live-all') { + const targets = liveFlag === '--live-all' ? liveHosts : ['app.bitgo.com']; + (async () => { + for (const host of targets) { + await new Promise((resolve, reject) => { + const req = https.get( + `https://${host}/`, + { agent: policy.agent, timeout: 10000 }, + (res) => { + res.resume(); + console.log(`live ok: https://${host}/ (HTTP ${res.statusCode})`); + resolve(); + } + ); + req.on('error', (err) => reject(new Error(`live TLS check failed for ${host}: ${err.message}`))); + req.on('timeout', () => req.destroy(new Error(`live TLS check timed out for ${host}`))); + }); + } + })().catch((err) => { + console.error(err.message); + process.exit(1); + }); +} +EOF