346 lines
12 KiB
C++
346 lines
12 KiB
C++
|
|
/// @file dpf/net/tls.hpp
|
||
|
|
/// @brief TLS 1.3 for party links and client links (OpenSSL through asio).
|
||
|
|
/// @details Only TLS 1.3 is offered, without session tickets or resumption.
|
||
|
|
/// The TLS layer accepts any certificate; the decision is made right
|
||
|
|
/// after the handshake, before any application byte is read:
|
||
|
|
/// `check_peer` compares a party's key with the one this side holds
|
||
|
|
/// for that party (none held means unauthenticated in that
|
||
|
|
/// direction), and `check_server` applies a client's pins, CA chain
|
||
|
|
/// and host name, or the development-certificate default. Every
|
||
|
|
/// blocking step here pumps the socket's `io_context` until it
|
||
|
|
/// completes or its budget runs out, then closes the socket.
|
||
|
|
#ifndef LIBDPF_INCLUDE_DPF_NET_TLS_HPP__
|
||
|
|
#define LIBDPF_INCLUDE_DPF_NET_TLS_HPP__
|
||
|
|
|
||
|
|
#include <algorithm>
|
||
|
|
#include <chrono>
|
||
|
|
#include <cstddef>
|
||
|
|
#include <cstdint>
|
||
|
|
#include <memory>
|
||
|
|
#include <optional>
|
||
|
|
#include <stdexcept>
|
||
|
|
#include <string>
|
||
|
|
#include <system_error>
|
||
|
|
|
||
|
|
#include "dpf/net/asio_ns.hpp"
|
||
|
|
#include "dpf/net/connect.hpp"
|
||
|
|
#include "dpf/net/identity.hpp"
|
||
|
|
#include "dpf/net/security.hpp"
|
||
|
|
|
||
|
|
#if DPF_HAS_OPENSSL
|
||
|
|
#include <asio/ssl.hpp>
|
||
|
|
#include <openssl/ssl.h>
|
||
|
|
#include <openssl/x509v3.h>
|
||
|
|
#endif
|
||
|
|
|
||
|
|
namespace dpf
|
||
|
|
{
|
||
|
|
namespace net
|
||
|
|
{
|
||
|
|
|
||
|
|
#if DPF_HAS_OPENSSL
|
||
|
|
|
||
|
|
using tls_stream = asio::ssl::stream<asio::ip::tcp::socket>;
|
||
|
|
using tls_local_stream = asio::ssl::stream<asio::local::stream_protocol::socket>;
|
||
|
|
using tls_context = asio::ssl::context;
|
||
|
|
|
||
|
|
namespace detail
|
||
|
|
{
|
||
|
|
|
||
|
|
inline int tls_accept_any(int, X509_STORE_CTX *) { return 1; }
|
||
|
|
|
||
|
|
inline void tls_common(SSL_CTX * c)
|
||
|
|
{
|
||
|
|
if (SSL_CTX_set_min_proto_version(c, TLS1_3_VERSION) != 1
|
||
|
|
|| SSL_CTX_set_max_proto_version(c, TLS1_3_VERSION) != 1
|
||
|
|
|| SSL_CTX_set_ciphersuites(c,
|
||
|
|
"TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:"
|
||
|
|
"TLS_AES_256_GCM_SHA384")
|
||
|
|
!= 1)
|
||
|
|
throw std::runtime_error(openssl_error("tls: context"));
|
||
|
|
SSL_CTX_set_num_tickets(c, 0);
|
||
|
|
SSL_CTX_set_session_cache_mode(c, SSL_SESS_CACHE_OFF);
|
||
|
|
SSL_CTX_set_options(c, SSL_OP_NO_TICKET);
|
||
|
|
}
|
||
|
|
|
||
|
|
inline void use_identity(SSL_CTX * c, const identity & id)
|
||
|
|
{
|
||
|
|
if (SSL_CTX_use_certificate(c, id.cert()) != 1
|
||
|
|
|| SSL_CTX_use_PrivateKey(c, id.pkey()) != 1
|
||
|
|
|| SSL_CTX_check_private_key(c) != 1)
|
||
|
|
throw std::runtime_error(openssl_error("tls: identity"));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Run one async step on `s`, pumping `io`, for at most `budget`.
|
||
|
|
template <typename Stream, typename Start>
|
||
|
|
inline std::error_code tls_step(asio::io_context & io, Stream & s,
|
||
|
|
std::chrono::milliseconds budget, Start start)
|
||
|
|
{
|
||
|
|
bool done = false;
|
||
|
|
std::error_code result;
|
||
|
|
start([&done, &result](const std::error_code & ec) {
|
||
|
|
result = ec;
|
||
|
|
done = true;
|
||
|
|
});
|
||
|
|
const auto deadline = setup_clock::now() + budget;
|
||
|
|
bool expired = false;
|
||
|
|
while (!done)
|
||
|
|
{
|
||
|
|
if (!expired && setup_clock::now() >= deadline)
|
||
|
|
{
|
||
|
|
expired = true;
|
||
|
|
std::error_code e;
|
||
|
|
s.lowest_layer().cancel(e);
|
||
|
|
s.lowest_layer().close(e);
|
||
|
|
}
|
||
|
|
if (io.stopped())
|
||
|
|
io.restart();
|
||
|
|
io.run_one_for(std::chrono::milliseconds(10));
|
||
|
|
}
|
||
|
|
// Running out of work stops `io`; leave it ready for the caller's run().
|
||
|
|
if (io.stopped())
|
||
|
|
io.restart();
|
||
|
|
if (expired && result)
|
||
|
|
return std::make_error_code(std::errc::timed_out);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
inline std::string tls_failure(const std::string & what, const std::error_code & ec,
|
||
|
|
std::chrono::milliseconds budget)
|
||
|
|
{
|
||
|
|
if (ec == std::errc::timed_out)
|
||
|
|
return what + ": no answer within " + std::to_string(budget.count()) + " ms";
|
||
|
|
return what + ": " + ec.message();
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace detail
|
||
|
|
|
||
|
|
/// @brief Party links: both ends present `self` and request the peer's key.
|
||
|
|
inline std::shared_ptr<tls_context> make_peer_tls_context(const identity & self)
|
||
|
|
{
|
||
|
|
auto ctx = std::make_shared<tls_context>(tls_context::tls);
|
||
|
|
SSL_CTX * c = ctx->native_handle();
|
||
|
|
detail::tls_common(c);
|
||
|
|
detail::use_identity(c, self);
|
||
|
|
SSL_CTX_set_verify(c, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT,
|
||
|
|
detail::tls_accept_any);
|
||
|
|
return ctx;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Server side of client links. `development` reports whether the
|
||
|
|
/// development certificate is presented.
|
||
|
|
inline std::shared_ptr<tls_context> make_server_tls_context(const server_security & sec,
|
||
|
|
bool & development)
|
||
|
|
{
|
||
|
|
auto ctx = std::make_shared<tls_context>(tls_context::tls);
|
||
|
|
SSL_CTX * c = ctx->native_handle();
|
||
|
|
detail::tls_common(c);
|
||
|
|
development = false;
|
||
|
|
if (!sec.cert_file.empty())
|
||
|
|
{
|
||
|
|
const std::string key = sec.key_file.empty() ? sec.cert_file : sec.key_file;
|
||
|
|
if (SSL_CTX_use_certificate_chain_file(c, sec.cert_file.c_str()) != 1
|
||
|
|
|| SSL_CTX_use_PrivateKey_file(c, key.c_str(), SSL_FILETYPE_PEM) != 1
|
||
|
|
|| SSL_CTX_check_private_key(c) != 1)
|
||
|
|
throw std::runtime_error(detail::openssl_error(
|
||
|
|
("tls: server certificate '" + sec.cert_file + "'").c_str()));
|
||
|
|
}
|
||
|
|
else if (sec.self)
|
||
|
|
detail::use_identity(c, *sec.self);
|
||
|
|
else
|
||
|
|
{
|
||
|
|
detail::use_identity(c, identity::development());
|
||
|
|
development = true;
|
||
|
|
}
|
||
|
|
// Client keys are optional; `check_client` reads whichever arrived.
|
||
|
|
SSL_CTX_set_verify(c, SSL_VERIFY_PEER, detail::tls_accept_any);
|
||
|
|
return ctx;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Client side of client links. Verification happens in `check_server`.
|
||
|
|
inline std::shared_ptr<tls_context> make_client_tls_context(const client_security & sec)
|
||
|
|
{
|
||
|
|
auto ctx = std::make_shared<tls_context>(tls_context::tls);
|
||
|
|
SSL_CTX * c = ctx->native_handle();
|
||
|
|
detail::tls_common(c);
|
||
|
|
if (sec.self)
|
||
|
|
detail::use_identity(c, *sec.self);
|
||
|
|
if (sec.ca_file == "system")
|
||
|
|
{
|
||
|
|
if (SSL_CTX_set_default_verify_paths(c) != 1)
|
||
|
|
throw std::runtime_error(detail::openssl_error("tls: system trust store"));
|
||
|
|
}
|
||
|
|
else if (!sec.ca_file.empty()
|
||
|
|
&& SSL_CTX_load_verify_locations(c, sec.ca_file.c_str(), nullptr) != 1)
|
||
|
|
throw std::runtime_error(detail::openssl_error(
|
||
|
|
("tls: CA file '" + sec.ca_file + "'").c_str()));
|
||
|
|
SSL_CTX_set_verify(c, SSL_VERIFY_PEER, detail::tls_accept_any);
|
||
|
|
return ctx;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Set the name a CA-issued server certificate must carry (and SNI).
|
||
|
|
template <typename Stream>
|
||
|
|
inline void tls_expect_host(Stream & s, const std::string & name)
|
||
|
|
{
|
||
|
|
if (name.empty())
|
||
|
|
return;
|
||
|
|
SSL * ssl = s.native_handle();
|
||
|
|
SSL_set_tlsext_host_name(ssl, name.c_str());
|
||
|
|
if (SSL_set1_host(ssl, name.c_str()) != 1)
|
||
|
|
throw std::runtime_error(detail::openssl_error("tls: server name"));
|
||
|
|
}
|
||
|
|
|
||
|
|
template <typename Stream>
|
||
|
|
inline void tls_handshake(asio::io_context & io, Stream & s, bool server,
|
||
|
|
std::chrono::milliseconds budget, const std::string & what)
|
||
|
|
{
|
||
|
|
const auto ec = detail::tls_step(io, s, budget, [&](auto h) {
|
||
|
|
s.async_handshake(server ? asio::ssl::stream_base::server
|
||
|
|
: asio::ssl::stream_base::client,
|
||
|
|
h);
|
||
|
|
});
|
||
|
|
if (ec)
|
||
|
|
throw std::system_error(ec, detail::tls_failure(what + ": TLS handshake", ec,
|
||
|
|
budget));
|
||
|
|
}
|
||
|
|
|
||
|
|
template <typename Stream>
|
||
|
|
inline void tls_write(asio::io_context & io, Stream & s, const void * p,
|
||
|
|
std::size_t n, std::chrono::milliseconds budget, const std::string & what)
|
||
|
|
{
|
||
|
|
const auto ec = detail::tls_step(io, s, budget, [&](auto h) {
|
||
|
|
asio::async_write(s, asio::buffer(p, n),
|
||
|
|
[h](const std::error_code & e, std::size_t) { h(e); });
|
||
|
|
});
|
||
|
|
if (ec)
|
||
|
|
throw std::system_error(ec, detail::tls_failure(what, ec, budget));
|
||
|
|
}
|
||
|
|
|
||
|
|
template <typename Stream>
|
||
|
|
inline void tls_read(asio::io_context & io, Stream & s, void * p, std::size_t n,
|
||
|
|
std::chrono::milliseconds budget, const std::string & what)
|
||
|
|
{
|
||
|
|
const auto ec = detail::tls_step(io, s, budget, [&](auto h) {
|
||
|
|
asio::async_read(s, asio::buffer(p, n),
|
||
|
|
[h](const std::error_code & e, std::size_t) { h(e); });
|
||
|
|
});
|
||
|
|
if (ec)
|
||
|
|
throw std::system_error(ec, detail::tls_failure(what, ec, budget));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief The Ed25519 key in the peer's certificate, if it sent one.
|
||
|
|
template <typename Stream>
|
||
|
|
inline std::optional<public_key> tls_peer_key(Stream & s)
|
||
|
|
{
|
||
|
|
#if OPENSSL_VERSION_NUMBER >= 0x30000000L
|
||
|
|
X509 * x = SSL_get1_peer_certificate(s.native_handle());
|
||
|
|
#else
|
||
|
|
X509 * x = SSL_get_peer_certificate(s.native_handle());
|
||
|
|
#endif
|
||
|
|
if (x == nullptr)
|
||
|
|
return std::nullopt;
|
||
|
|
std::optional<public_key> out;
|
||
|
|
EVP_PKEY * k = X509_get0_pubkey(x);
|
||
|
|
public_key pk;
|
||
|
|
std::size_t n = public_key::size;
|
||
|
|
if (k != nullptr && EVP_PKEY_id(k) == EVP_PKEY_ED25519
|
||
|
|
&& EVP_PKEY_get_raw_public_key(k, pk.bytes.data(), &n) == 1
|
||
|
|
&& n == public_key::size)
|
||
|
|
out = pk;
|
||
|
|
X509_free(x);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Protocol, cipher, and peer key of an established stream.
|
||
|
|
template <typename Stream>
|
||
|
|
inline link_security tls_describe(Stream & s)
|
||
|
|
{
|
||
|
|
link_security out;
|
||
|
|
out.encrypted = true;
|
||
|
|
SSL * ssl = s.native_handle();
|
||
|
|
out.protocol = SSL_get_version(ssl);
|
||
|
|
const char * cipher = SSL_get_cipher_name(ssl);
|
||
|
|
out.cipher = cipher != nullptr ? cipher : "unknown";
|
||
|
|
out.peer_key = tls_peer_key(s);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Party link: authenticate `who` if this side holds its key.
|
||
|
|
/// @details Throws when a held key does not match. Otherwise fills
|
||
|
|
/// `sec.peer_auth` with `key` or `none`.
|
||
|
|
inline void check_peer(link_security & sec, const peer_security & policy,
|
||
|
|
std::uint32_t party, const std::string & who)
|
||
|
|
{
|
||
|
|
const public_key * want = policy.trusted_key(party);
|
||
|
|
if (want == nullptr)
|
||
|
|
{
|
||
|
|
sec.peer_auth = "none";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!sec.peer_key || *sec.peer_key != *want)
|
||
|
|
throw std::runtime_error("security: " + who + " presented key "
|
||
|
|
+ (sec.peer_key ? sec.peer_key->base64() : std::string("(not Ed25519)"))
|
||
|
|
+ " but this side holds " + want->base64() + " for it");
|
||
|
|
sec.peer_auth = "key";
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Client link: apply `policy` to the server behind `s`.
|
||
|
|
/// @details Accepts a pinned key, then a CA chain that matched the expected
|
||
|
|
/// name, then (only when neither is configured) the development
|
||
|
|
/// certificate. `verify` off accepts anything.
|
||
|
|
inline void check_server(link_security & sec, tls_stream & s,
|
||
|
|
const client_security & policy, const std::string & where)
|
||
|
|
{
|
||
|
|
if (!policy.verify)
|
||
|
|
{
|
||
|
|
sec.peer_auth = "none";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (sec.peer_key
|
||
|
|
&& std::find(policy.pins.begin(), policy.pins.end(), *sec.peer_key)
|
||
|
|
!= policy.pins.end())
|
||
|
|
{
|
||
|
|
sec.peer_auth = "key";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!policy.ca_file.empty() && SSL_get_verify_result(s.native_handle()) == X509_V_OK)
|
||
|
|
{
|
||
|
|
sec.peer_auth = "ca";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!policy.configured() && sec.peer_key
|
||
|
|
&& *sec.peer_key == identity::development().key())
|
||
|
|
{
|
||
|
|
sec.peer_auth = "development";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
std::string why;
|
||
|
|
if (!policy.ca_file.empty())
|
||
|
|
why = std::string(" (CA check: ")
|
||
|
|
+ X509_verify_cert_error_string(SSL_get_verify_result(s.native_handle()))
|
||
|
|
+ ")";
|
||
|
|
throw std::runtime_error("security: server " + where + " presented "
|
||
|
|
+ (sec.peer_key ? "key " + sec.peer_key->base64()
|
||
|
|
: std::string("a certificate without an Ed25519 key"))
|
||
|
|
+ ", which this client does not trust" + why
|
||
|
|
+ "; pin it (client_pin=...), configure client_ca, or set client_verify=off");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @brief Server side: authenticate the client if it presented a pinned key.
|
||
|
|
inline void check_client(link_security & sec, const server_security & policy)
|
||
|
|
{
|
||
|
|
sec.peer_auth = sec.peer_key
|
||
|
|
&& std::find(policy.client_pins.begin(), policy.client_pins.end(),
|
||
|
|
*sec.peer_key)
|
||
|
|
!= policy.client_pins.end()
|
||
|
|
? "key"
|
||
|
|
: "none";
|
||
|
|
}
|
||
|
|
|
||
|
|
#endif // DPF_HAS_OPENSSL
|
||
|
|
|
||
|
|
} // namespace net
|
||
|
|
} // namespace dpf
|
||
|
|
|
||
|
|
#endif // LIBDPF_INCLUDE_DPF_NET_TLS_HPP__
|