669 lines
30 KiB
C++
669 lines
30 KiB
C++
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
|
|
||
|
|
module;
|
||
|
|
#include <poll.h>
|
||
|
|
#include <arpa/inet.h>
|
||
|
|
#include <cerrno>
|
||
|
|
#include <climits>
|
||
|
|
|
||
|
|
#include <openssl/bio.h>
|
||
|
|
#include <openssl/err.h>
|
||
|
|
#include <openssl/evp.h>
|
||
|
|
#include <openssl/pem.h>
|
||
|
|
#include <openssl/ssl.h>
|
||
|
|
#include <openssl/sslerr.h>
|
||
|
|
#include <openssl/x509v3.h>
|
||
|
|
|
||
|
|
module Crafter.Network:TLS_impl;
|
||
|
|
import :TLS;
|
||
|
|
import :Stream;
|
||
|
|
import std;
|
||
|
|
|
||
|
|
using namespace Crafter;
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
// ── OpenSSL plumbing ─────────────────────────────────────────────────
|
||
|
|
template <typename T, void (*Release)(T*)>
|
||
|
|
struct Releaser {
|
||
|
|
void operator()(T* pointer) const noexcept { if (pointer) Release(pointer); }
|
||
|
|
};
|
||
|
|
template <typename T, void (*Release)(T*)>
|
||
|
|
using Owned = std::unique_ptr<T, Releaser<T, Release>>;
|
||
|
|
|
||
|
|
using OwnedBio = Owned<BIO, BIO_free_all>;
|
||
|
|
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
|
||
|
|
using OwnedCertificate = Owned<X509, X509_free>;
|
||
|
|
|
||
|
|
// Drain OpenSSL's per-thread error queue into a readable message. Without
|
||
|
|
// this every TLS failure reads as "handshake failed" with no hint as to
|
||
|
|
// whether it was the certificate, the version, or the peer hanging up.
|
||
|
|
std::string Describe(std::string_view what) {
|
||
|
|
std::string message(what);
|
||
|
|
bool first = true;
|
||
|
|
while (const unsigned long code = ERR_get_error()) {
|
||
|
|
char buffer[256];
|
||
|
|
ERR_error_string_n(code, buffer, sizeof(buffer));
|
||
|
|
message += first ? ": " : "; ";
|
||
|
|
message += buffer;
|
||
|
|
first = false;
|
||
|
|
}
|
||
|
|
return message;
|
||
|
|
}
|
||
|
|
|
||
|
|
// True once OpenSSL has told us the peer vanished without a close_notify.
|
||
|
|
// For HTTP/1.1 that is an end of connection like any other — the message
|
||
|
|
// parser is the thing that decides whether it arrived too early.
|
||
|
|
bool UnexpectedEof() {
|
||
|
|
const unsigned long code = ERR_peek_error();
|
||
|
|
return ERR_GET_LIB(code) == ERR_LIB_SSL
|
||
|
|
&& ERR_GET_REASON(code) == SSL_R_UNEXPECTED_EOF_WHILE_READING;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string BioToString(BIO* bio) {
|
||
|
|
char* data = nullptr;
|
||
|
|
const long length = BIO_get_mem_data(bio, &data);
|
||
|
|
if (length <= 0 || data == nullptr) return {};
|
||
|
|
return std::string(data, static_cast<std::size_t>(length));
|
||
|
|
}
|
||
|
|
|
||
|
|
bool IsIpLiteral(const std::string& name) {
|
||
|
|
in_addr v4{};
|
||
|
|
in6_addr v6{};
|
||
|
|
return inet_pton(AF_INET, name.c_str(), &v4) == 1
|
||
|
|
|| inet_pton(AF_INET6, name.c_str(), &v6) == 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── ALPN ─────────────────────────────────────────────────────────────
|
||
|
|
// Wire form is a sequence of length-prefixed protocol names.
|
||
|
|
std::vector<unsigned char> EncodeAlpn(const std::vector<std::string>& protocols) {
|
||
|
|
std::vector<unsigned char> wire;
|
||
|
|
for (const std::string& protocol : protocols) {
|
||
|
|
if (protocol.empty() || protocol.size() > 255) {
|
||
|
|
throw TLSException("ALPN protocol names must be 1..255 bytes: '" + protocol + "'");
|
||
|
|
}
|
||
|
|
wire.push_back(static_cast<unsigned char>(protocol.size()));
|
||
|
|
wire.insert(wire.end(), protocol.begin(), protocol.end());
|
||
|
|
}
|
||
|
|
return wire;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Server-side selection, in *our* preference order rather than the
|
||
|
|
// client's: the server is the side that knows what it can actually parse.
|
||
|
|
// No overlap is a fatal no_application_protocol alert (RFC 7301 §3.2) —
|
||
|
|
// letting the connection through would mean answering HTTP/2 with an
|
||
|
|
// HTTP/1.1 response and confusing both ends.
|
||
|
|
int SelectAlpn(SSL*, const unsigned char** out, unsigned char* outLength,
|
||
|
|
const unsigned char* in, unsigned int inLength, void* argument) {
|
||
|
|
const auto& preferred = *static_cast<const std::vector<std::string>*>(argument);
|
||
|
|
for (const std::string& candidate : preferred) {
|
||
|
|
for (unsigned int offset = 0; offset < inLength;) {
|
||
|
|
const unsigned int length = in[offset];
|
||
|
|
if (offset + 1 + length > inLength) break; // malformed list
|
||
|
|
if (length == candidate.size()
|
||
|
|
&& std::memcmp(in + offset + 1, candidate.data(), length) == 0) {
|
||
|
|
*out = in + offset + 1;
|
||
|
|
*outLength = static_cast<unsigned char>(length);
|
||
|
|
return SSL_TLSEXT_ERR_OK;
|
||
|
|
}
|
||
|
|
offset += 1 + length;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return SSL_TLSEXT_ERR_ALERT_FATAL;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Self-signed certificate ──────────────────────────────────────────
|
||
|
|
void AddExtension(X509* certificate, X509V3_CTX* context, int nid, const char* value) {
|
||
|
|
X509_EXTENSION* extension = X509V3_EXT_conf_nid(nullptr, context, nid, value);
|
||
|
|
if (extension == nullptr) {
|
||
|
|
throw TLSException(Describe("could not build certificate extension"));
|
||
|
|
}
|
||
|
|
const int added = X509_add_ext(certificate, extension, -1);
|
||
|
|
X509_EXTENSION_free(extension);
|
||
|
|
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
|
||
|
|
}
|
||
|
|
|
||
|
|
TLSCertificatePem MakeSelfSignedCertificate() {
|
||
|
|
OwnedKey key(EVP_EC_gen("P-256"));
|
||
|
|
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
|
||
|
|
|
||
|
|
OwnedCertificate certificate(X509_new());
|
||
|
|
if (!certificate) throw TLSException(Describe("could not allocate a certificate"));
|
||
|
|
|
||
|
|
// X509_set_version takes the zero-based version, so 2 is v3 — which is
|
||
|
|
// what the extensions below require.
|
||
|
|
X509_set_version(certificate.get(), 2);
|
||
|
|
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
|
||
|
|
// Backdated an hour so a peer whose clock runs slightly behind ours
|
||
|
|
// does not reject a certificate we just minted.
|
||
|
|
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
|
||
|
|
X509_gmtime_adj(X509_getm_notAfter(certificate.get()), 10 * 24 * 60 * 60);
|
||
|
|
if (X509_set_pubkey(certificate.get(), key.get()) != 1) {
|
||
|
|
throw TLSException(Describe("could not set the certificate public key"));
|
||
|
|
}
|
||
|
|
|
||
|
|
X509_NAME* subject = X509_get_subject_name(certificate.get());
|
||
|
|
X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC,
|
||
|
|
reinterpret_cast<const unsigned char*>("localhost"), -1, -1, 0);
|
||
|
|
// Self-signed: issuer is the subject.
|
||
|
|
X509_set_issuer_name(certificate.get(), subject);
|
||
|
|
|
||
|
|
X509V3_CTX extensionContext;
|
||
|
|
X509V3_set_ctx_nodb(&extensionContext);
|
||
|
|
X509V3_set_ctx(&extensionContext, certificate.get(), certificate.get(),
|
||
|
|
nullptr, nullptr, 0);
|
||
|
|
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
|
||
|
|
"critical,CA:FALSE");
|
||
|
|
AddExtension(certificate.get(), &extensionContext, NID_key_usage,
|
||
|
|
"critical,digitalSignature,keyEncipherment");
|
||
|
|
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
|
||
|
|
// The SANs are what a verifying client actually matches on; a bare CN
|
||
|
|
// has not been accepted by anything for years.
|
||
|
|
AddExtension(certificate.get(), &extensionContext, NID_subject_alt_name,
|
||
|
|
"DNS:localhost,IP:127.0.0.1,IP:::1");
|
||
|
|
|
||
|
|
if (X509_sign(certificate.get(), key.get(), EVP_sha256()) == 0) {
|
||
|
|
throw TLSException(Describe("could not sign the certificate"));
|
||
|
|
}
|
||
|
|
|
||
|
|
TLSCertificatePem pem;
|
||
|
|
{
|
||
|
|
OwnedBio bio(BIO_new(BIO_s_mem()));
|
||
|
|
if (!bio || PEM_write_bio_X509(bio.get(), certificate.get()) != 1) {
|
||
|
|
throw TLSException(Describe("could not encode the certificate as PEM"));
|
||
|
|
}
|
||
|
|
pem.certificate = BioToString(bio.get());
|
||
|
|
}
|
||
|
|
{
|
||
|
|
OwnedBio bio(BIO_new(BIO_s_mem()));
|
||
|
|
if (!bio || PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
|
||
|
|
nullptr, nullptr) != 1) {
|
||
|
|
throw TLSException(Describe("could not encode the private key as PEM"));
|
||
|
|
}
|
||
|
|
pem.privateKey = BioToString(bio.get());
|
||
|
|
}
|
||
|
|
return pem;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Credential loading ───────────────────────────────────────────────
|
||
|
|
OwnedBio MemoryBio(const std::string& contents) {
|
||
|
|
if (contents.size() > static_cast<std::size_t>(INT_MAX)) {
|
||
|
|
throw TLSException("PEM blob is implausibly large");
|
||
|
|
}
|
||
|
|
OwnedBio bio(BIO_new_mem_buf(contents.data(), static_cast<int>(contents.size())));
|
||
|
|
if (!bio) throw TLSException(Describe("could not wrap the PEM blob"));
|
||
|
|
return bio;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Leaf first, then any intermediates, exactly as OpenSSL's own
|
||
|
|
// *_chain_file loader treats a PEM bundle.
|
||
|
|
void UseCertificateChainPem(SSL_CTX* context, const std::string& pem) {
|
||
|
|
OwnedBio bio = MemoryBio(pem);
|
||
|
|
OwnedCertificate leaf(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||
|
|
if (!leaf) throw TLSException(Describe("could not read the certificate PEM"));
|
||
|
|
if (SSL_CTX_use_certificate(context, leaf.get()) != 1) {
|
||
|
|
throw TLSException(Describe("could not install the certificate"));
|
||
|
|
}
|
||
|
|
SSL_CTX_clear_chain_certs(context);
|
||
|
|
for (;;) {
|
||
|
|
OwnedCertificate extra(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||
|
|
if (!extra) break;
|
||
|
|
// add0 takes ownership on success, so the pointer is released.
|
||
|
|
if (SSL_CTX_add0_chain_cert(context, extra.get()) != 1) {
|
||
|
|
throw TLSException(Describe("could not install a chain certificate"));
|
||
|
|
}
|
||
|
|
(void)extra.release();
|
||
|
|
}
|
||
|
|
// PEM_read_bio_X509 leaves a "no start line" error behind when it runs
|
||
|
|
// out of certificates; that is the loop's exit condition, not a fault.
|
||
|
|
ERR_clear_error();
|
||
|
|
}
|
||
|
|
|
||
|
|
void UsePrivateKeyPem(SSL_CTX* context, const std::string& pem) {
|
||
|
|
OwnedBio bio = MemoryBio(pem);
|
||
|
|
OwnedKey key(PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr));
|
||
|
|
if (!key) throw TLSException(Describe("could not read the private key PEM"));
|
||
|
|
if (SSL_CTX_use_PrivateKey(context, key.get()) != 1) {
|
||
|
|
throw TLSException(Describe("could not install the private key"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A trust anchor path is either a PEM bundle or a hashed directory of
|
||
|
|
// them; OpenSSL wants to be told which, so look.
|
||
|
|
void LoadTrustAnchorPath(SSL_CTX* context, const std::string& path) {
|
||
|
|
std::error_code error;
|
||
|
|
const bool directory = std::filesystem::is_directory(path, error);
|
||
|
|
const int loaded = directory
|
||
|
|
? SSL_CTX_load_verify_locations(context, nullptr, path.c_str())
|
||
|
|
: SSL_CTX_load_verify_locations(context, path.c_str(), nullptr);
|
||
|
|
if (loaded != 1) {
|
||
|
|
throw TLSException(Describe("could not load trust anchors from '" + path + "'"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void LoadTrustAnchorPem(SSL_CTX* context, const std::string& pem) {
|
||
|
|
X509_STORE* store = SSL_CTX_get_cert_store(context);
|
||
|
|
OwnedBio bio = MemoryBio(pem);
|
||
|
|
std::size_t added = 0;
|
||
|
|
for (;;) {
|
||
|
|
OwnedCertificate anchor(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||
|
|
if (!anchor) break;
|
||
|
|
if (X509_STORE_add_cert(store, anchor.get()) != 1) {
|
||
|
|
throw TLSException(Describe("could not add a trust anchor"));
|
||
|
|
}
|
||
|
|
++added;
|
||
|
|
}
|
||
|
|
ERR_clear_error();
|
||
|
|
if (added == 0) throw TLSException("caPem contained no certificate");
|
||
|
|
}
|
||
|
|
|
||
|
|
void ApplyCommonOptions(SSL_CTX* context) {
|
||
|
|
// TLS 1.2 floor: 1.0/1.1 are deprecated (RFC 8996) and nothing we want
|
||
|
|
// to talk to needs them.
|
||
|
|
if (SSL_CTX_set_min_proto_version(context, TLS1_2_VERSION) != 1) {
|
||
|
|
throw TLSException(Describe("could not require TLS 1.2 or newer"));
|
||
|
|
}
|
||
|
|
// Partial writes plus a moving write buffer: our Write() loops over
|
||
|
|
// its own offset, so it must be allowed to make progress a record at a
|
||
|
|
// time instead of being forced to re-present a byte-identical buffer.
|
||
|
|
SSL_CTX_set_mode(context, SSL_MODE_ENABLE_PARTIAL_WRITE
|
||
|
|
| SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER);
|
||
|
|
// Renegotiation buys nothing here and is a cheap way for a peer to
|
||
|
|
// make us do asymmetric crypto on demand.
|
||
|
|
SSL_CTX_set_options(context, SSL_OP_NO_RENEGOTIATION);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── TLSContext ───────────────────────────────────────────────────────────
|
||
|
|
struct TLSContext::Impl {
|
||
|
|
SSL_CTX* context = nullptr;
|
||
|
|
bool verifyPeer = true;
|
||
|
|
std::string serverName;
|
||
|
|
// Server preference list; SelectAlpn holds a pointer to it, so it must
|
||
|
|
// outlive every SSL made from this context — which it does, being owned
|
||
|
|
// by the shared_ptr'd TLSContext.
|
||
|
|
std::vector<std::string> alpn;
|
||
|
|
|
||
|
|
~Impl() { if (context) SSL_CTX_free(context); }
|
||
|
|
};
|
||
|
|
|
||
|
|
TLSContext::TLSContext() : impl(std::make_unique<Impl>()) {}
|
||
|
|
TLSContext::~TLSContext() = default;
|
||
|
|
|
||
|
|
std::shared_ptr<TLSContext> TLSContext::Server(const TLSServerCredentials& credentials) {
|
||
|
|
std::shared_ptr<TLSContext> wrapper(new TLSContext());
|
||
|
|
Impl& state = *wrapper->impl;
|
||
|
|
|
||
|
|
state.context = SSL_CTX_new(TLS_server_method());
|
||
|
|
if (state.context == nullptr) throw TLSException(Describe("could not create a TLS context"));
|
||
|
|
ApplyCommonOptions(state.context);
|
||
|
|
|
||
|
|
if (!credentials.certPath.empty()) {
|
||
|
|
if (credentials.keyPath.empty()) {
|
||
|
|
throw TLSException("certPath was given without a matching keyPath");
|
||
|
|
}
|
||
|
|
if (SSL_CTX_use_certificate_chain_file(state.context, credentials.certPath.c_str()) != 1) {
|
||
|
|
throw TLSException(Describe("could not load the certificate '"
|
||
|
|
+ credentials.certPath + "'"));
|
||
|
|
}
|
||
|
|
if (SSL_CTX_use_PrivateKey_file(state.context, credentials.keyPath.c_str(),
|
||
|
|
SSL_FILETYPE_PEM) != 1) {
|
||
|
|
throw TLSException(Describe("could not load the private key '"
|
||
|
|
+ credentials.keyPath + "'"));
|
||
|
|
}
|
||
|
|
} else if (!credentials.certPem.empty()) {
|
||
|
|
if (credentials.keyPem.empty()) {
|
||
|
|
throw TLSException("certPem was given without a matching keyPem");
|
||
|
|
}
|
||
|
|
UseCertificateChainPem(state.context, credentials.certPem);
|
||
|
|
UsePrivateKeyPem(state.context, credentials.keyPem);
|
||
|
|
} else if (credentials.selfSigned) {
|
||
|
|
const TLSCertificatePem& pem = GetSelfSignedCertificatePem();
|
||
|
|
UseCertificateChainPem(state.context, pem.certificate);
|
||
|
|
UsePrivateKeyPem(state.context, pem.privateKey);
|
||
|
|
} else {
|
||
|
|
throw TLSException("no server certificate: set certPath/keyPath, certPem/keyPem, "
|
||
|
|
"or selfSigned for a development certificate");
|
||
|
|
}
|
||
|
|
if (SSL_CTX_check_private_key(state.context) != 1) {
|
||
|
|
throw TLSException(Describe("the private key does not match the certificate"));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (credentials.requireClientCertificate) {
|
||
|
|
if (!credentials.clientCaPath.empty()) {
|
||
|
|
LoadTrustAnchorPath(state.context, credentials.clientCaPath);
|
||
|
|
// Advertise the acceptable issuers so the client can choose a
|
||
|
|
// certificate instead of guessing.
|
||
|
|
std::error_code error;
|
||
|
|
if (!std::filesystem::is_directory(credentials.clientCaPath, error)) {
|
||
|
|
if (STACK_OF(X509_NAME)* names =
|
||
|
|
SSL_load_client_CA_file(credentials.clientCaPath.c_str())) {
|
||
|
|
SSL_CTX_set_client_CA_list(state.context, names);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (SSL_CTX_set_default_verify_paths(state.context) != 1) {
|
||
|
|
throw TLSException(Describe("could not load the system trust store"));
|
||
|
|
}
|
||
|
|
SSL_CTX_set_verify(state.context,
|
||
|
|
SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr);
|
||
|
|
}
|
||
|
|
|
||
|
|
state.alpn = credentials.alpnProtocols;
|
||
|
|
if (!state.alpn.empty()) {
|
||
|
|
// Validate the names now rather than inside the handshake callback,
|
||
|
|
// where there is nowhere useful to report a bad configuration.
|
||
|
|
(void)EncodeAlpn(state.alpn);
|
||
|
|
SSL_CTX_set_alpn_select_cb(state.context, SelectAlpn, &state.alpn);
|
||
|
|
}
|
||
|
|
return wrapper;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::shared_ptr<TLSContext> TLSContext::Client(const TLSClientCredentials& credentials) {
|
||
|
|
std::shared_ptr<TLSContext> wrapper(new TLSContext());
|
||
|
|
Impl& state = *wrapper->impl;
|
||
|
|
|
||
|
|
state.context = SSL_CTX_new(TLS_client_method());
|
||
|
|
if (state.context == nullptr) throw TLSException(Describe("could not create a TLS context"));
|
||
|
|
ApplyCommonOptions(state.context);
|
||
|
|
|
||
|
|
state.verifyPeer = !credentials.insecureNoServerValidation;
|
||
|
|
state.serverName = credentials.serverName;
|
||
|
|
if (state.verifyPeer) {
|
||
|
|
if (SSL_CTX_set_default_verify_paths(state.context) != 1) {
|
||
|
|
throw TLSException(Describe("could not load the system trust store"));
|
||
|
|
}
|
||
|
|
if (!credentials.caPath.empty()) LoadTrustAnchorPath(state.context, credentials.caPath);
|
||
|
|
if (!credentials.caPem.empty()) LoadTrustAnchorPem(state.context, credentials.caPem);
|
||
|
|
SSL_CTX_set_verify(state.context, SSL_VERIFY_PEER, nullptr);
|
||
|
|
} else {
|
||
|
|
// The handshake still completes and SSL_get_verify_result still
|
||
|
|
// reports what it found; nothing acts on it.
|
||
|
|
SSL_CTX_set_verify(state.context, SSL_VERIFY_NONE, nullptr);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!credentials.certPath.empty()) {
|
||
|
|
if (credentials.keyPath.empty()) {
|
||
|
|
throw TLSException("certPath was given without a matching keyPath");
|
||
|
|
}
|
||
|
|
if (SSL_CTX_use_certificate_chain_file(state.context, credentials.certPath.c_str()) != 1) {
|
||
|
|
throw TLSException(Describe("could not load the client certificate '"
|
||
|
|
+ credentials.certPath + "'"));
|
||
|
|
}
|
||
|
|
if (SSL_CTX_use_PrivateKey_file(state.context, credentials.keyPath.c_str(),
|
||
|
|
SSL_FILETYPE_PEM) != 1) {
|
||
|
|
throw TLSException(Describe("could not load the client private key '"
|
||
|
|
+ credentials.keyPath + "'"));
|
||
|
|
}
|
||
|
|
if (SSL_CTX_check_private_key(state.context) != 1) {
|
||
|
|
throw TLSException(Describe("the client key does not match the client certificate"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!credentials.alpnProtocols.empty()) {
|
||
|
|
const std::vector<unsigned char> wire = EncodeAlpn(credentials.alpnProtocols);
|
||
|
|
if (SSL_CTX_set_alpn_protos(state.context, wire.data(),
|
||
|
|
static_cast<unsigned int>(wire.size())) != 0) {
|
||
|
|
throw TLSException(Describe("could not set the ALPN protocol list"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return wrapper;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── TLSStream ────────────────────────────────────────────────────────────
|
||
|
|
struct TLSStream::Impl {
|
||
|
|
std::shared_ptr<TLSContext> context;
|
||
|
|
SSL* ssl = nullptr;
|
||
|
|
int descriptor = -1;
|
||
|
|
std::string protocol;
|
||
|
|
bool shutdownSent = false;
|
||
|
|
|
||
|
|
~Impl() { if (ssl) SSL_free(ssl); }
|
||
|
|
|
||
|
|
// Drive SSL_connect/SSL_accept to completion, polling for whichever
|
||
|
|
// direction OpenSSL is waiting on. The deadline covers the whole
|
||
|
|
// handshake, not each poll, so a peer that dribbles records cannot
|
||
|
|
// stretch it indefinitely.
|
||
|
|
void Handshake(bool client, std::chrono::milliseconds timeout) {
|
||
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||
|
|
for (;;) {
|
||
|
|
ERR_clear_error();
|
||
|
|
const int result = client ? SSL_connect(ssl) : SSL_accept(ssl);
|
||
|
|
if (result == 1) break;
|
||
|
|
|
||
|
|
const int error = SSL_get_error(ssl, result);
|
||
|
|
if (error == SSL_ERROR_WANT_READ || error == SSL_ERROR_WANT_WRITE) {
|
||
|
|
const short events = error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT;
|
||
|
|
if (!PollDescriptor(descriptor, events, deadline)) {
|
||
|
|
throw TLSException("TLS handshake timed out");
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
// Certificate problems are the failure people actually hit, and
|
||
|
|
// OpenSSL's generic queue message for them ("certificate verify
|
||
|
|
// failed") does not say which check tripped.
|
||
|
|
const long verified = SSL_get_verify_result(ssl);
|
||
|
|
if (verified != X509_V_OK) {
|
||
|
|
throw TLSException(std::string("TLS certificate rejected: ")
|
||
|
|
+ X509_verify_cert_error_string(verified));
|
||
|
|
}
|
||
|
|
if (error == SSL_ERROR_ZERO_RETURN
|
||
|
|
|| (error == SSL_ERROR_SYSCALL && result == 0)
|
||
|
|
|| (error == SSL_ERROR_SSL && UnexpectedEof())) {
|
||
|
|
throw TLSException("the peer closed the connection during the TLS handshake");
|
||
|
|
}
|
||
|
|
if (error == SSL_ERROR_SYSCALL) {
|
||
|
|
throw TLSException(std::string("TLS handshake failed: ") + std::strerror(errno));
|
||
|
|
}
|
||
|
|
throw TLSException(Describe("TLS handshake failed"));
|
||
|
|
}
|
||
|
|
|
||
|
|
const unsigned char* selected = nullptr;
|
||
|
|
unsigned int length = 0;
|
||
|
|
SSL_get0_alpn_selected(ssl, &selected, &length);
|
||
|
|
if (selected != nullptr && length != 0) {
|
||
|
|
protocol.assign(reinterpret_cast<const char*>(selected), length);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
TLSStream::TLSStream() : impl(std::make_unique<Impl>()) {}
|
||
|
|
|
||
|
|
TLSStream::~TLSStream() {
|
||
|
|
Shutdown();
|
||
|
|
}
|
||
|
|
|
||
|
|
std::unique_ptr<TLSStream> TLSStream::Connect(int descriptor,
|
||
|
|
std::shared_ptr<TLSContext> context,
|
||
|
|
const std::string& hostName,
|
||
|
|
std::chrono::milliseconds timeout) {
|
||
|
|
if (!context) throw TLSException("no TLS context");
|
||
|
|
SetNonBlocking(descriptor);
|
||
|
|
|
||
|
|
std::unique_ptr<TLSStream> stream(new TLSStream());
|
||
|
|
TLSContext::Impl& configuration = *context->impl;
|
||
|
|
stream->impl->context = std::move(context);
|
||
|
|
stream->impl->descriptor = descriptor;
|
||
|
|
|
||
|
|
SSL* ssl = SSL_new(configuration.context);
|
||
|
|
if (ssl == nullptr) throw TLSException(Describe("could not create a TLS session"));
|
||
|
|
stream->impl->ssl = ssl;
|
||
|
|
if (SSL_set_fd(ssl, descriptor) != 1) {
|
||
|
|
throw TLSException(Describe("could not attach the socket to the TLS session"));
|
||
|
|
}
|
||
|
|
|
||
|
|
const std::string& name = configuration.serverName.empty()
|
||
|
|
? hostName : configuration.serverName;
|
||
|
|
const bool literal = !name.empty() && IsIpLiteral(name);
|
||
|
|
// SNI carries host names only — an IP literal there is a protocol
|
||
|
|
// violation and some servers reject the handshake outright (RFC 6066 §3).
|
||
|
|
if (!name.empty() && !literal && SSL_set_tlsext_host_name(ssl, name.c_str()) != 1) {
|
||
|
|
throw TLSException(Describe("could not set the SNI host name"));
|
||
|
|
}
|
||
|
|
if (configuration.verifyPeer) {
|
||
|
|
if (name.empty()) {
|
||
|
|
throw TLSException("certificate verification needs a name to check against; "
|
||
|
|
"set serverName or use insecureNoServerValidation");
|
||
|
|
}
|
||
|
|
SSL_set_hostflags(ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
|
||
|
|
const int named = literal
|
||
|
|
? X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), name.c_str())
|
||
|
|
: SSL_set1_host(ssl, name.c_str());
|
||
|
|
if (named != 1) {
|
||
|
|
throw TLSException("could not use '" + name + "' as the name to verify");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
SSL_set_connect_state(ssl);
|
||
|
|
stream->impl->Handshake(true, timeout);
|
||
|
|
return stream;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::unique_ptr<TLSStream> TLSStream::Accept(int descriptor,
|
||
|
|
std::shared_ptr<TLSContext> context,
|
||
|
|
std::chrono::milliseconds timeout) {
|
||
|
|
if (!context) throw TLSException("no TLS context");
|
||
|
|
SetNonBlocking(descriptor);
|
||
|
|
|
||
|
|
std::unique_ptr<TLSStream> stream(new TLSStream());
|
||
|
|
TLSContext::Impl& configuration = *context->impl;
|
||
|
|
stream->impl->context = std::move(context);
|
||
|
|
stream->impl->descriptor = descriptor;
|
||
|
|
|
||
|
|
SSL* ssl = SSL_new(configuration.context);
|
||
|
|
if (ssl == nullptr) throw TLSException(Describe("could not create a TLS session"));
|
||
|
|
stream->impl->ssl = ssl;
|
||
|
|
if (SSL_set_fd(ssl, descriptor) != 1) {
|
||
|
|
throw TLSException(Describe("could not attach the socket to the TLS session"));
|
||
|
|
}
|
||
|
|
|
||
|
|
SSL_set_accept_state(ssl);
|
||
|
|
stream->impl->Handshake(false, timeout);
|
||
|
|
return stream;
|
||
|
|
}
|
||
|
|
|
||
|
|
StreamStatus TLSStream::ReadSome(char* buffer, std::size_t size,
|
||
|
|
std::chrono::milliseconds timeout,
|
||
|
|
std::size_t& read) {
|
||
|
|
read = 0;
|
||
|
|
if (size == 0) return StreamStatus::Data;
|
||
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||
|
|
const int wanted = static_cast<int>(std::min<std::size_t>(size, INT_MAX));
|
||
|
|
for (;;) {
|
||
|
|
ERR_clear_error();
|
||
|
|
const int got = SSL_read(impl->ssl, buffer, wanted);
|
||
|
|
if (got > 0) {
|
||
|
|
read = static_cast<std::size_t>(got);
|
||
|
|
return StreamStatus::Data;
|
||
|
|
}
|
||
|
|
const int error = SSL_get_error(impl->ssl, got);
|
||
|
|
switch (error) {
|
||
|
|
case SSL_ERROR_WANT_READ:
|
||
|
|
if (!PollDescriptor(impl->descriptor, POLLIN, deadline)) {
|
||
|
|
return StreamStatus::TimedOut;
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
// A read can need the socket writable: TLS 1.3 key updates and
|
||
|
|
// (where allowed) renegotiation both send records mid-read.
|
||
|
|
case SSL_ERROR_WANT_WRITE:
|
||
|
|
if (!PollDescriptor(impl->descriptor, POLLOUT, deadline)) {
|
||
|
|
return StreamStatus::TimedOut;
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
case SSL_ERROR_ZERO_RETURN:
|
||
|
|
return StreamStatus::Closed; // close_notify: an orderly end
|
||
|
|
case SSL_ERROR_SYSCALL:
|
||
|
|
if (errno == EINTR) continue;
|
||
|
|
if (got == 0 || errno == 0 || errno == ECONNRESET) return StreamStatus::Closed;
|
||
|
|
throw TLSException(std::string("TLS read failed: ") + std::strerror(errno));
|
||
|
|
case SSL_ERROR_SSL:
|
||
|
|
if (UnexpectedEof()) return StreamStatus::Closed;
|
||
|
|
throw TLSException(Describe("TLS read failed"));
|
||
|
|
default:
|
||
|
|
throw TLSException(Describe("TLS read failed"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void TLSStream::Write(const void* buffer, std::size_t size,
|
||
|
|
std::chrono::milliseconds timeout) {
|
||
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||
|
|
const char* data = reinterpret_cast<const char*>(buffer);
|
||
|
|
std::size_t sent = 0;
|
||
|
|
while (sent < size) {
|
||
|
|
const int wanted = static_cast<int>(std::min<std::size_t>(size - sent, INT_MAX));
|
||
|
|
ERR_clear_error();
|
||
|
|
const int wrote = SSL_write(impl->ssl, data + sent, wanted);
|
||
|
|
if (wrote > 0) {
|
||
|
|
sent += static_cast<std::size_t>(wrote);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const int error = SSL_get_error(impl->ssl, wrote);
|
||
|
|
switch (error) {
|
||
|
|
case SSL_ERROR_WANT_READ:
|
||
|
|
if (!PollDescriptor(impl->descriptor, POLLIN, deadline)) {
|
||
|
|
throw TLSException("timed out writing to the TLS peer");
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
case SSL_ERROR_WANT_WRITE:
|
||
|
|
if (!PollDescriptor(impl->descriptor, POLLOUT, deadline)) {
|
||
|
|
throw TLSException("timed out writing to the TLS peer");
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
case SSL_ERROR_ZERO_RETURN:
|
||
|
|
throw TLSException("the TLS peer closed the connection");
|
||
|
|
case SSL_ERROR_SYSCALL:
|
||
|
|
if (errno == EINTR) continue;
|
||
|
|
throw TLSException(std::string("TLS write failed: ")
|
||
|
|
+ (errno == 0 ? "the peer closed the connection"
|
||
|
|
: std::strerror(errno)));
|
||
|
|
default:
|
||
|
|
throw TLSException(Describe("TLS write failed"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void TLSStream::Shutdown() noexcept {
|
||
|
|
if (!impl || impl->ssl == nullptr || impl->shutdownSent) return;
|
||
|
|
impl->shutdownSent = true;
|
||
|
|
// One attempt only: close_notify goes out, and we deliberately do not
|
||
|
|
// wait for the peer's. Waiting means blocking a teardown path on a peer
|
||
|
|
// that may never answer, and every framing decision has already been made
|
||
|
|
// by the time we get here.
|
||
|
|
ERR_clear_error();
|
||
|
|
SSL_shutdown(impl->ssl);
|
||
|
|
ERR_clear_error();
|
||
|
|
}
|
||
|
|
|
||
|
|
int TLSStream::Descriptor() const noexcept {
|
||
|
|
return impl ? impl->descriptor : -1;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string_view TLSStream::Protocol() const noexcept {
|
||
|
|
return impl ? std::string_view(impl->protocol) : std::string_view();
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string TLSStream::Version() const {
|
||
|
|
if (!impl || impl->ssl == nullptr) return {};
|
||
|
|
const char* version = SSL_get_version(impl->ssl);
|
||
|
|
return version == nullptr ? std::string() : std::string(version);
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string TLSStream::PeerCertificateSubject() const {
|
||
|
|
if (!impl || impl->ssl == nullptr) return {};
|
||
|
|
OwnedCertificate peer(SSL_get1_peer_certificate(impl->ssl));
|
||
|
|
if (!peer) return {};
|
||
|
|
char buffer[512] = {};
|
||
|
|
X509_NAME_oneline(X509_get_subject_name(peer.get()), buffer, sizeof(buffer));
|
||
|
|
return std::string(buffer);
|
||
|
|
}
|
||
|
|
|
||
|
|
const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() {
|
||
|
|
// Generated once per process so every listener presents the same
|
||
|
|
// certificate: a client that was handed it as a trust anchor keeps
|
||
|
|
// working across reconnects.
|
||
|
|
static std::mutex mutex;
|
||
|
|
static std::optional<TLSCertificatePem> cached;
|
||
|
|
std::lock_guard lock(mutex);
|
||
|
|
if (!cached) cached = MakeSelfSignedCertificate();
|
||
|
|
return *cached;
|
||
|
|
}
|