diff --git a/modules/express/.env.example b/modules/express/.env.example index a6212b9fd3..c4a05b7e52 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 cad19fe20e..2b1e0d152b 100644 --- a/modules/express/README.md +++ b/modules/express/README.md @@ -198,6 +198,26 @@ 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. +- If your network uses a **transparent TLS-inspecting firewall/proxy** (no `BITGO_USE_PROXY`) whose CA you trust via `NODE_EXTRA_CA_CERTS`: note that the policy agent replaces the trust store for BitGo domains, so `NODE_EXTRA_CA_CERTS` is **ignored** for them. Express logs a startup warning in this case; either pass `--disablepincerts` / `BITGO_DISABLE_PIN_CERTS=true`, or the connections fail with `unable to get local issuer certificate` / `SELF_SIGNED_CERT_IN_CHAIN`. This is a behavior change from prior releases (BitGo domains previously trusted `NODE_EXTRA_CA_CERTS`). +- Redirects are validated on each hop for covered BitGo hostnames: redirects to other covered `https://` BitGo hosts are followed, while redirects to uncovered hosts or plain `http://` are refused. +- At startup, Express warns if any root CA in `bitgo-ca.pem` expires within 30 days. +- The bundle is **not** self-updating. Regenerate it from the current Node.js root store (e.g. when BitGo switches CA providers or a root nears expiry) and re-verify against live endpoints: + ```bash + bash scripts/verify-bitgo-policy-data.sh --regenerate # rebuilds modules/express/bitgo-ca.pem + cd modules/express && yarn test:data:live # offline checks + live handshakes to all BitGo endpoints + ``` + If BitGo ever moves `*.bitgo.com` to a CA provider outside the bundled roots, update `bitgo-ca.pem` **before** the cutover (deployed customers run older images). + +> 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. @@ -241,6 +261,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 0000000000..522075d4f6 --- /dev/null +++ b/modules/express/bitgo-ca.pem @@ -0,0 +1,368 @@ +# BitGo Express — Bundled Root CA Store for Official BitGo Domains (*.bitgo*.com) +# +# Contains self-signed Root CAs across the 5 tier-1 CA providers trusted for BitGo +# endpoints (Google Trust Services, Amazon Trust Services, DigiCert, Let's +# Encrypt / ISRG, and Sectigo). +# +# Regenerate from the current Node.js LTS root store and re-verify with: +# bash scripts/verify-bitgo-policy-data.sh --regenerate +# 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 00d138b7a9..eb6cf8d0ca 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,9 @@ "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", + "test:data:live": "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 a4181343b5..180498a62f 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 f460faad45..8f8c6b8791 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'; @@ -1809,6 +1810,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 @@ -1836,7 +1838,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 05977f2e44..ba1e65bd8b 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 ddbe769161..71130354dd 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 f3760a9f8f..1deaaf2eb7 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 { DefaultConfig, EnvConfig } 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({ ...DefaultConfig, ...EnvConfig() }), +}); // 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 0000000000..40f2183460 --- /dev/null +++ b/modules/express/src/tlsPolicy.ts @@ -0,0 +1,121 @@ +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. + * + * @param config Express config (drives opt-out / proxy behaviour). + * @param caBundlePath Optional path override used by unit tests; bypasses the WeakMap cache. + */ +export function getBitGoTlsPolicy(config: Config, caBundlePath?: string): BitGoTlsPolicy | undefined { + const useProxy = Boolean(process.env.BITGO_USE_PROXY); + // Cache check first so every result (including the "disabled" / "proxy" undefined state) is returned + // without re-logging warnings — otherwise checkPreconditions and prepareBitGo each warn once per boot. + if (!caBundlePath && policies.has(config)) { + return policies.get(config); + } + + 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 (useProxy) { + 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; + } + + // Node's `ca` option replaces the whole trust store for the policy agent, so NODE_EXTRA_CA_CERTS is + // silently ignored for BitGo domains. Operators behind a corporate TLS-inspecting firewall (no + // BITGO_USE_PROXY) must opt out, so warn loudly at startup and point at the escape hatch. + if (process.env.NODE_EXTRA_CA_CERTS) { + console.warn( + 'warning: NODE_EXTRA_CA_CERTS is set, but BitGo TLS CA pinning replaces the CA store for BitGo domains and ignores NODE_EXTRA_CA_CERTS. If you rely on a TLS-inspecting proxy/firewall, pass --disablepincerts or set BITGO_DISABLE_PIN_CERTS=true.' + ); + } + + 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 5eba08d31c..4916c5e30d 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 0000000000..46abd108e3 --- /dev/null +++ b/modules/express/test/unit/tlsPolicy.ts @@ -0,0 +1,155 @@ +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' } + ); + }); + + let savedEnv: NodeJS.ProcessEnv; + + beforeEach(function () { + // getBitGoTlsPolicy reads the real environment; keep a developer/CI shell that exports these from + // changing results. Tests that need them stub process.env explicitly. + savedEnv = process.env; + process.env = { ...savedEnv }; + delete process.env.BITGO_USE_PROXY; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(function () { + sinon.restore(); + process.env = savedEnv; + }); + + 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('logs startup warnings only once per config for disablePinCerts and BITGO_USE_PROXY', function () { + const warnStub = sinon.stub(console, 'warn'); + const disabled = cfg({ disablePinCerts: true }); + getBitGoTlsPolicy(disabled); + getBitGoTlsPolicy(disabled); + warnStub.callCount.should.equal(1); + warnStub.resetHistory(); + + sinon.stub(process, 'env').value({ BITGO_USE_PROXY: 'http://proxy:1' }); + const proxyCfg = cfg(); + getBitGoTlsPolicy(proxyCfg); + getBitGoTlsPolicy(proxyCfg); + warnStub.callCount.should.equal(1); + }); + + it('warns at startup when NODE_EXTRA_CA_CERTS is set', function () { + sinon.stub(process, 'env').value({ NODE_EXTRA_CA_CERTS: '/etc/ssl/corp-ca.pem' }); + const warnStub = sinon.stub(console, 'warn'); + const policy = getBitGoTlsPolicy(cfg()); + (policy !== undefined).should.be.true(); + warnStub.calledWith(sinon.match(/NODE_EXTRA_CA_CERTS/)).should.be.true(); + }); + + it('skips CA pinning with a startup warning when BITGO_USE_PROXY is set', function () { + sinon.stub(process, 'env').value({ BITGO_USE_PROXY: 'http://proxy:1' }); + const warnStub = sinon.stub(console, 'warn'); + const policy = getBitGoTlsPolicy(cfg()); + (policy === undefined).should.be.true(); + warnStub.calledWith(sinon.match(/BITGO_USE_PROXY/)).should.be.true(); + }); + + it('returns a previously built policy from cache even if BITGO_USE_PROXY appears later', function () { + // prepareBitGo reads BITGO_USE_PROXY per request and omits tlsPolicy when it is set, so a cached + // policy is safe to return here; the runtime proxy guard lives in clientRoutes. + const c = cfg(); + (getBitGoTlsPolicy(c) !== undefined).should.be.true(); + sinon.stub(process, 'env').value({ BITGO_USE_PROXY: 'http://proxy:1' }); + (getBitGoTlsPolicy(c) !== undefined).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/package.json b/modules/sdk-api/package.json index e57efcbc95..8254552890 100644 --- a/modules/sdk-api/package.json +++ b/modules/sdk-api/package.json @@ -5,7 +5,9 @@ "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "browser": { - "proxy-agent": false + "proxy-agent": false, + "https": false, + "tls": false }, "scripts": { "build": "yarn tsc --build --incremental --verbose .", diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 504868e629..e2b906c856 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -30,7 +30,7 @@ import { DefaultHmacAuthStrategy, type IHmacAuthStrategy } from '@bitgo/sdk-hmac import * as utxolib from '@bitgo/utxo-lib'; import { bip32, ECPairInterface } from '@bitgo/utxo-lib'; import * as bitcoinMessage from 'bitcoinjs-message'; -import { type Agent } from 'http'; +import { type Agent, type ClientRequest } from 'http'; import debugLib from 'debug'; import * as _ from 'lodash'; import * as secp256k1 from 'secp256k1'; @@ -90,10 +90,21 @@ 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'); +/** + * Agent used for a refused TLS-policy redirect hop: it destroys the request instead of opening a socket. + * superagent deletes the previous hop's ClientRequest before emitting 'redirect' and builds the next one in + * end() even after abort(), so without this the refused target would still be dialed. It has no `protocol`, + * so Node's agent/protocol check passes for both http: and https: targets. + */ +const refuseConnectionAgent = { + addRequest: (clientReq: ClientRequest) => clientReq.destroy(), +} as unknown as Agent; + const Blockchain = require('./v1/blockchain'); const Keychains = require('./v1/keychains'); import Wallet = require('./v1/wallet'); @@ -268,6 +279,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 +457,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 +504,30 @@ export class BitGoAPI implements BitGoBase { if (this.cookiesPropagationEnabled) { req = req.withCredentials(); } + if (this._tlsPolicy?.appliesTo(url)) { + const tlsPolicy = this._tlsPolicy; + // superagent fires the 'redirect' event after updating req.url but BEFORE sending the follow-up hop, + // so each hop is re-validated against the policy: a redirect to another covered https BitGo host is + // followed with the restricted agent still attached, while any redirect to an uncovered host or plain + // http is refused: refuseConnectionAgent ensures the follow-up request never opens a socket, and + // abort() makes superagent's _end() bail — the surfaced error is superagent's 'Aborted', whose `url` + // field is the refused redirect target. + req.agent(tlsPolicy.agent).on('redirect', () => { + let refused = false; + try { + if (!tlsPolicy.appliesTo(req.url)) { + refused = true; + } + } catch { + // appliesTo throws for http:// on covered hosts — that is a refusal too. + refused = true; + } + if (refused) { + req.agent(refuseConnectionAgent); + req.abort(); + } + }); + } return req; } /** @@ -764,8 +804,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 2e08de054d..8c3bf09c3c 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 0000000000..a9569004cd --- /dev/null +++ b/modules/sdk-api/src/tlsPolicy.ts @@ -0,0 +1,181 @@ +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, + keepAlive: true, + // Mirror Node's default https.globalAgent (timeout: 5000, scheduling: 'lifo') so idle pooled + // sockets expire before BitGo's load balancer closes them — otherwise a request reusing a + // socket in the close window fails with ECONNRESET / 'socket hang up' on non-retried POSTs. + timeout: 5000, + scheduling: 'lifo', + // Node's built-in env proxy (NODE_USE_ENV_PROXY=1 / --use-env-proxy) is only applied to the global + // agents, so inherit whatever it resolved — otherwise covered requests bypass HTTPS_PROXY and connect + // directly. CONNECT keeps TLS end to end, so `ca` is still enforced. Undefined when the proxy is off. + proxyEnv: https.globalAgent.options.proxyEnv, + }); + + return { + agent, + appliesTo(url: string): boolean { + // superagent prepends 'http://' when a URL lacks an explicit scheme; mirror that fallback so + // schemeless, host:port and protocol-relative URLs to covered hosts are caught and rejected as + // http downgrades. A scheme only counts when followed by at least one slash — 'app.bitgo.com:443' + // would otherwise be misparsed as a scheme with an empty hostname, while WHATWG's URL parser + // (like superagent's legacy url.parse) accepts 'https:/host' and 'https:////host' as https. + // Backslashes are normalized to '/' first for the same reason. + const normalizedUrl = url.replace(/\\/g, '/'); + const candidate = /^[a-z][a-z0-9+.-]*:\//i.test(normalizedUrl) + ? normalizedUrl + : `http://${normalizedUrl.replace(/^\/+/, '')}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return false; + } + const covered = normalizedHosts.some((h) => matchesHost(parsed.hostname, h)); + // The schemeless fallback above forces http:// onto bare/protocol-relative URLs, so `parsed.protocol + // !== 'https:'` is sufficient to reject every insecure form (including http://, ws://, and + // 'app.bitgo.com/...'), while still accepting case-insensitive uppercase HTTPS:// URLs. + if (covered && parsed.protocol !== '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 29d7a1af77..8e51d6d8d1 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 0000000000..f1ea6b669a --- /dev/null +++ b/modules/sdk-api/test/unit/tlsPolicy.ts @@ -0,0 +1,355 @@ +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 net from 'net'; +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:443/api/v2'), /refuses insecure/); + assert.throws(() => policy.appliesTo('//app.bitgo.com/api/v2'), /refuses insecure/); + // superagent's legacy url.parse treats backslashes as path separators, so the policy must too. + 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:/app.bitgo.com/api/v2')); + assert.ok(policy.appliesTo('https:////app.bitgo.com/api/v2')); + assert.throws(() => policy.appliesTo('http:\\app.bitgo.com/api/v2'), /refuses insecure/); + // Uppercase HTTPS:// is a valid scheme — accepted (review #10073 item 5). + assert.ok(policy.appliesTo('HTTPS://app.bitgo.com/api/v2')); + }); + + 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(); + }); + // Redirect targets are re-validated per hop: an off-policy (uncovered) or http target is refused + // (surfaced as superagent's 'Aborted' error) before the follow-up connection is opened. + await assert.rejects(api().get(origin), (err: { code?: string }) => err.code === 'ABORTED'); + assert.strictEqual(targetHit, false); + }); + + for (const scheme of ['http', 'https']) { + it(`opens no TCP connection to a refused ${scheme} redirect target`, async function () { + let connections = 0; + const target = net.createServer((socket) => { + connections++; + socket.destroy(); + }); + await new Promise((resolve) => target.listen(0, '127.0.0.1', () => resolve())); + servers.push(target); + const targetPort = (target.address() as net.AddressInfo).port; + + // 127.0.0.1 is outside the policy hosts (['localhost']), so both schemes are refused. + const origin = await serve(trusted, (_req, res) => { + res.writeHead(302, { Location: `${scheme}://127.0.0.1:${targetPort}/` }); + res.end(); + }); + await assert.rejects(api().get(origin), (err: { code?: string }) => err.code === 'ABORTED'); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual(connections, 0); + }); + } + + it("inherits Node's built-in env proxy settings from the global agent", function () { + const globalOpts = https.globalAgent.options; + const original = globalOpts.proxyEnv; + try { + globalOpts.proxyEnv = { HTTPS_PROXY: 'http://proxy.example:3128' }; + const policy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['localhost'] }); + assert.deepStrictEqual(policy.agent.options.proxyEnv, { HTTPS_PROXY: 'http://proxy.example:3128' }); + } finally { + globalOpts.proxyEnv = original; + } + }); + + it('follows a redirect to another covered https host', async function () { + let targetHit = false; + const target = await serve(trusted, (_req, res) => { + targetHit = true; + res.end('done'); + }); + const origin = await serve(trusted, (_req, res) => { + res.writeHead(302, { Location: target }); + res.end(); + }); + // Both servers present the trusted cert for DNS:localhost, which is in the policy hosts: ['localhost']. + const res = await api().get(origin); + assert.strictEqual(res.text, 'done'); + assert.strictEqual(targetHit, true); + }); + + 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('keeps the policy agent sockets alive for reuse', function () { + const policy = createBitGoTlsPolicy({ ca: trusted.caPem, hosts: ['localhost'] }); + // @types/node does not expose keepAlive/timeout on the Agent class, only on AgentOptions. + const agentOpts = ( + policy.agent as unknown as { options: { keepAlive?: boolean; timeout?: number; scheduling?: string } } + ).options; + assert.strictEqual(agentOpts.keepAlive, true); + assert.strictEqual(agentOpts.timeout, 5000); + assert.strictEqual(agentOpts.scheduling, 'lifo'); + }); + + 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 0000000000..7eaf009830 --- /dev/null +++ b/scripts/verify-bitgo-policy-data.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Guardian for the bundled BitGo TLS CA store (modules/express/bitgo-ca.pem). +# +# Offline checks (no network, no build required — runs in `yarn test:data` before unit-test): +# - every certificate in bitgo-ca.pem parses +# - every certificate is a self-signed CA root (issuer == subject, basicConstraints CA:TRUE) +# - every certificate is at least 30 days from expiry (warning) / not expired (hard fail) +# - all official BitGo domains are covered and http downgrades are refused +# +# Usage: +# bash scripts/verify-bitgo-policy-data.sh offline checks only +# bash scripts/verify-bitgo-policy-data.sh --live offline checks + real TLS handshakes to all +# 9 live BitGo endpoints (app.*, admin.*, cdn.*) +# bash scripts/verify-bitgo-policy-data.sh --regenerate rebuild bitgo-ca.pem from Node's current +# tls.rootCertificates (5 tier-1 providers), then verify + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +bundle="$repo_root/modules/express/bitgo-ca.pem" +mode="${1:-}" + +[[ -f "$bundle" ]] || { echo "error: missing CA bundle: $bundle" >&2; exit 1; } + +if [[ "$mode" == "--regenerate" ]]; then + node - "$bundle" <<'REGEN_EOF' +const fs = require('fs'); +const tls = require('tls'); +const crypto = require('crypto'); +const outputPath = process.argv[2]; + +// The 5 tier-1 CA providers BitGo uses today or would fail over to. +const wanted = new Set([ + 'GTS Root R1', 'GTS Root R2', 'GTS Root R3', 'GTS Root R4', + 'Amazon Root CA 1', 'Amazon Root CA 2', 'Amazon Root CA 3', 'Amazon Root CA 4', + 'Starfield Services Root Certificate Authority - G2', + 'DigiCert Global Root CA', 'DigiCert Global Root G2', 'DigiCert Global Root G3', + 'DigiCert High Assurance EV Root CA', + 'ISRG Root X1', 'ISRG Root X2', + 'USERTrust RSA Certification Authority', 'USERTrust ECC Certification Authority', +]); +const header = `# BitGo Express — Bundled Root CA Store for Official BitGo Domains (*.bitgo*.com) +# +# Contains self-signed Root CAs across the 5 tier-1 CA providers trusted for BitGo +# endpoints (Google Trust Services, Amazon Trust Services, DigiCert, Let's +# Encrypt / ISRG, and Sectigo). +# +# Regenerate from the current Node.js LTS root store and re-verify with: +# bash scripts/verify-bitgo-policy-data.sh --regenerate +`; + +const blocks = []; +const found = new Set(); +for (const pem of tls.rootCertificates) { + const c = new crypto.X509Certificate(pem); + const m = c.subject.match(/CN=([^\n]+)/); + if (m && wanted.has(m[1]) && c.subject === c.issuer && c.ca) { + blocks.push(`# ${m[1]} (validTo: ${c.validTo})\n${pem.trim()}`); + found.add(m[1]); + } +} +const missing = [...wanted].filter((name) => !found.has(name)); +if (missing.length > 0) { + console.error(`error: wanted roots not found by CN in tls.rootCertificates: ${missing.join(', ')} — update the wanted list?`); + process.exit(1); +} +fs.writeFileSync(outputPath, header + blocks.join('\n\n') + '\n'); +console.log(`regenerated ${outputPath} with ${blocks.length} self-signed root CAs`); +REGEN_EOF +fi + +node - "$bundle" "$mode" <<'EOF' +const fs = require('fs'); +const https = require('https'); +const crypto = require('crypto'); +const [bundlePath, mode] = process.argv.slice(2); + +const RELEASER_STEPS = `To fix before releasing: + 1. Regenerate the bundle from the current Node.js LTS root store: + bash scripts/verify-bitgo-policy-data.sh --regenerate + (or replace the expiring root PEM in modules/express/bitgo-ca.pem from the CA provider's official store). + 2. Verify against live BitGo endpoints: + yarn test:data:live (in modules/express) + 3. Commit the updated modules/express/bitgo-ca.pem and open a PR.`; + +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); + } +} + +// Expiry: warn loudly (with releaser steps) below 30 days; hard-fail only once actually expired, so an +// upcoming root rotation does not block unrelated PRs months in advance. +for (const block of blocks) { + const cert = new crypto.X509Certificate(block); + const daysRemaining = Math.floor((new Date(cert.validTo).getTime() - Date.now()) / 86_400_000); + if (daysRemaining <= 0) { + console.error(`error: bundle cert '${cert.subject}' EXPIRED ${-daysRemaining} day(s) ago (${cert.validTo}).\n\n${RELEASER_STEPS}`); + process.exit(1); + } + if (daysRemaining < 30) { + console.warn(`warning: bundle cert '${cert.subject}' expires in ${daysRemaining} day(s) (${cert.validTo}).\n${RELEASER_STEPS}`); + } +} + +// All official BitGo domains (apex + subdomains across prod/test/staging/dev). +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 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', +]; + +function matchesHost(hostname, pattern) { + const host = hostname.toLowerCase().replace(/\.+$/, ''); + const p = pattern.toLowerCase().replace(/\.+$/, ''); + return p.startsWith('.') ? host.endsWith(p) : host === p; +} +function covers(url) { + const parsed = new URL(url.startsWith('http') ? url : `http://${url}`); + const covered = hostPatterns.some((h) => matchesHost(parsed.hostname, h)); + if (covered && parsed.protocol !== 'https:') { + throw new Error(`insecure ${parsed.protocol}//${parsed.hostname} on a covered host`); + } + return covered; +} + +for (const h of allExpectedDomains) { + if (!covers(`https://${h}/api/v2`)) { + console.error(`error: host not covered by policy: ${h}`); + process.exit(1); + } + try { + covers(`http://${h}`); + console.error(`error: http downgrade not rejected for covered host: ${h}`); + process.exit(1); + } catch (err) { + if (!/insecure/.test(err.message)) { + console.error(err.message); + process.exit(1); + } + } +} + +console.log(`ok: ${blocks.length} self-signed CA certs (none within 30d of expiry), ${allExpectedDomains.length} BitGo hosts covered, http downgrades throw`); + +if (mode === '--live' || mode === '--live-all') { + const agent = new https.Agent({ ca: bundleText, keepAlive: true }); + (async () => { + for (const host of allExpectedDomains) { + await new Promise((resolve, reject) => { + const req = https.get(`https://${host}/`, { 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