diff --git a/examples/helloworld/producer.m.cpp b/examples/helloworld/producer.m.cpp index 0b928ec0..e2d18332 100644 --- a/examples/helloworld/producer.m.cpp +++ b/examples/helloworld/producer.m.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -21,13 +22,21 @@ using namespace BloombergLP; int main(int argc, char** argv) { if (argc < 2) { - std::cerr << "USAGE: " << argv[0] << " \n"; + std::cerr << "USAGE: " << argv[0] << " [ca-cert-path]\n"; return 1; } rmqa::RabbitContext rabbit; + // An amqps:// uri needs a certificate authority to verify the broker's + // certificate against + bsl::shared_ptr securityParameters; + if (argc > 2) { + securityParameters = + bsl::make_shared(argv[2]); + } + bsl::optional vhostInfo = - rmqa::ConnectionString::parse(argv[1]); + rmqa::ConnectionString::parse(argv[1], securityParameters); if (!vhostInfo) { std::cerr << "Failed to parse connection string: " << argv[1] << "\n"; diff --git a/src/rmq/rmqio/rmqio_asioresolver.cpp b/src/rmq/rmqio/rmqio_asioresolver.cpp index e42ea3b0..0836607f 100644 --- a/src/rmq/rmqio/rmqio_asioresolver.cpp +++ b/src/rmq/rmqio/rmqio_asioresolver.cpp @@ -134,6 +134,23 @@ void handleTLSHandshake(boost::system::error_code error, } } +class CertificateVerifier { + public: + explicit CertificateVerifier(const bsl::string& host) + : d_host(host) + { + } + + bool operator()(bool preverified, + boost::asio::ssl::verify_context& ctx) const + { + return AsioResolver::verifyCertificate(d_host, preverified, ctx); + } + + private: + bsl::string d_host; +}; + void startTLSHandshake( const bsl::string& host, boost::system::error_code error, @@ -148,6 +165,33 @@ void startTLSHandshake( << endpoint->endpoint().port() << ", starting TLS Handshake"; + boost::system::error_code verifyError; + socketWrapper->socket().set_verify_callback(CertificateVerifier(host), + verifyError); + if (verifyError) { + BALL_LOG_ERROR << "Error setting host name verification for [" + << host << "]: " << augmentTlsError(verifyError); + onFail(Resolver::ERROR_HANDSHAKE); + return; // RETURN + } + + // The host resolved to reach this point, so anything which doesn't + // parse as an IP address is a DNS name + boost::system::error_code parseError; + boost::asio::ip::make_address(host.c_str(), parseError); + const bool hostIsDnsName = static_cast(parseError); + + // Only DNS names are sent as SNI, so the broker can select the + // matching certificate. RFC 6066 forbids IP literals here + if (hostIsDnsName && + !SSL_set_tlsext_host_name(socketWrapper->socket().native_handle(), + host.c_str())) { + BALL_LOG_ERROR << "Error setting TLS SNI host name for [" << host + << "]"; + onFail(Resolver::ERROR_HANDSHAKE); + return; // RETURN + } + socketWrapper->socket().async_handshake( boost::asio::ssl::stream_base::client, bdlf::BindUtil::bind(&handleTLSHandshake, @@ -505,6 +549,27 @@ bool AsioResolver::logCertVerificationFailure( return preverified; } +bool AsioResolver::verifyCertificate(const bsl::string& host, + bool preverified, + boost::asio::ssl::verify_context& ctx) +{ + if (!logCertVerificationFailure(preverified, ctx)) { + return false; + } + + const boost::asio::ssl::host_name_verification hostNameVerifier( + host.c_str()); + + if (!hostNameVerifier(preverified, ctx)) { + BALL_LOG_ERROR << "Certificate verification failed: certificate " + "identity does not match host name: " + << host; + return false; + } + + return true; +} + void AsioResolver::shuffleResolverResults( AsioResolver::results_type& resolverResults, bool shuffleConnectionEndpoints, diff --git a/src/rmq/rmqio/rmqio_asioresolver.h b/src/rmq/rmqio/rmqio_asioresolver.h index 00ffab5a..e9e4502e 100644 --- a/src/rmq/rmqio/rmqio_asioresolver.h +++ b/src/rmq/rmqio/rmqio_asioresolver.h @@ -111,6 +111,10 @@ class AsioResolver : public Resolver, logCertVerificationFailure(bool preverified, boost::asio::ssl::verify_context& ctx); + static bool verifyCertificate(const bsl::string& host, + bool preverified, + boost::asio::ssl::verify_context& ctx); + private: explicit AsioResolver(AsioEventLoop& eventloop, bool shuffleConnectionEndpoints); diff --git a/src/tests/rmqio/rmqio_asioresolver.t.cpp b/src/tests/rmqio/rmqio_asioresolver.t.cpp index 07e2e7e5..d0295f57 100644 --- a/src/tests/rmqio/rmqio_asioresolver.t.cpp +++ b/src/tests/rmqio/rmqio_asioresolver.t.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -96,6 +97,39 @@ class CertificateGuard { X509* d_cert; }; +bool addSubjectAltName(X509* cert, const char* subjectAltName) +{ + X509_EXTENSION* ext = + X509V3_EXT_conf_nid(0, 0, NID_subject_alt_name, subjectAltName); + if (!ext) { + return false; + } + + const int rc = X509_add_ext(cert, ext, -1); + X509_EXTENSION_free(ext); + + return rc == 1; +} + +/// Verifies `host` against a certificate presenting `subjectAltName`, as if +/// the chain itself had already verified successfully. +bool verifyHost(const char* subjectAltName, const bsl::string& host) +{ + StoreContextGuard storeCtx; + EXPECT_THAT(storeCtx.get(), NotNull()); + EXPECT_THAT(X509_STORE_CTX_init(storeCtx.get(), 0, 0, 0), Eq(1)); + + CertificateGuard cert; + EXPECT_THAT(cert.get(), NotNull()); + EXPECT_TRUE(addSubjectAltName(cert.get(), subjectAltName)); + + X509_STORE_CTX_set_current_cert(storeCtx.get(), cert.get()); + + boost::asio::ssl::verify_context ctx(storeCtx.get()); + + return AsioResolver::verifyCertificate(host, true, ctx); +} + } // namespace TEST_F(ResolverTests, Breathing) @@ -151,6 +185,73 @@ TEST_F(ResolverTests, LogCertVerificationPreverifiedPassesThrough) EXPECT_TRUE(AsioResolver::logCertVerificationFailure(true, ctx)); } +TEST_F(ResolverTests, VerifyCertificateExactHostName) +{ + EXPECT_TRUE(verifyHost("DNS:one.example.com", "one.example.com")); + EXPECT_FALSE(verifyHost("DNS:one.example.com", "two.example.com")); +} + +TEST_F(ResolverTests, VerifyCertificateWildcardMatchesAnySingleLabel) +{ + EXPECT_TRUE(verifyHost("DNS:*.example.com", "one.example.com")); + EXPECT_TRUE(verifyHost("DNS:*.example.com", "two.example.com")); +} + +TEST_F(ResolverTests, VerifyCertificateWildcardIsScopedToItsOwnDomain) +{ + EXPECT_FALSE(verifyHost("DNS:*.example.com", "one.not-example.com")); + + EXPECT_TRUE(verifyHost("DNS:*.not-example.com", "one.not-example.com")); + EXPECT_FALSE(verifyHost("DNS:*.not-example.com", "one.example.com")); +} + +TEST_F(ResolverTests, VerifyCertificateWildcardMatchesOneLabelOnly) +{ + EXPECT_FALSE(verifyHost("DNS:*.example.com", "one.two.example.com")); + EXPECT_FALSE(verifyHost("DNS:*.example.com", "example.com")); +} + +TEST_F(ResolverTests, VerifyCertificateMatchesAnySanEntry) +{ + const char* san = "DNS:one.example.com,DNS:two.example.com"; + + EXPECT_TRUE(verifyHost(san, "one.example.com")); + EXPECT_TRUE(verifyHost(san, "two.example.com")); + EXPECT_FALSE(verifyHost(san, "one.not-example.com")); +} + +TEST_F(ResolverTests, VerifyCertificateIpHostRequiresIpSan) +{ + // Connecting to an IP rather than a name is verified against the + // certificate's iPAddress SAN. A DNS SAN does not cover it, so such + // connections are intentionally rejected + EXPECT_FALSE(verifyHost("DNS:one.example.com", "10.20.30.40")); + + EXPECT_TRUE( + verifyHost("DNS:one.example.com,IP:10.20.30.40", "10.20.30.40")); + EXPECT_FALSE( + verifyHost("DNS:one.example.com,IP:10.20.30.40", "10.20.30.99")); +} + +TEST_F(ResolverTests, VerifyCertificateDoesNotOverrideChainFailure) +{ + StoreContextGuard storeCtx; + ASSERT_THAT(storeCtx.get(), NotNull()); + ASSERT_THAT(X509_STORE_CTX_init(storeCtx.get(), 0, 0, 0), Eq(1)); + + CertificateGuard cert; + ASSERT_THAT(cert.get(), NotNull()); + ASSERT_TRUE(addSubjectAltName(cert.get(), "DNS:one.example.com")); + + X509_STORE_CTX_set_current_cert(storeCtx.get(), cert.get()); + + boost::asio::ssl::verify_context ctx(storeCtx.get()); + + // A matching host name must not rescue a chain which failed to verify + EXPECT_FALSE( + AsioResolver::verifyCertificate("one.example.com", false, ctx)); +} + TEST_F(ResolverTests, badresolve) { using bdlf::PlaceHolders::_1;