libdpf/include/dpf/net/tls.hpp

346 lines
12 KiB
C++
Raw Permalink Normal View History

/// @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__