//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include #include #include #include #include #include #include #include #include #include #include module Crafter.Network:TLS_impl; import :TLS; import :Stream; import std; using namespace Crafter; namespace { // ── OpenSSL plumbing ───────────────────────────────────────────────── template struct Releaser { void operator()(T* pointer) const noexcept { if (pointer) Release(pointer); } }; template using Owned = std::unique_ptr>; using OwnedBio = Owned; using OwnedKey = Owned; using OwnedCertificate = Owned; // 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(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 EncodeAlpn(const std::vector& protocols) { std::vector 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(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*>(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(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("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(INT_MAX)) { throw TLSException("PEM blob is implausibly large"); } OwnedBio bio(BIO_new_mem_buf(contents.data(), static_cast(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 alpn; ~Impl() { if (context) SSL_CTX_free(context); } }; TLSContext::TLSContext() : impl(std::make_unique()) {} TLSContext::~TLSContext() = default; std::shared_ptr TLSContext::Server(const TLSServerCredentials& credentials) { std::shared_ptr 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::Client(const TLSClientCredentials& credentials) { std::shared_ptr 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 wire = EncodeAlpn(credentials.alpnProtocols); if (SSL_CTX_set_alpn_protos(state.context, wire.data(), static_cast(wire.size())) != 0) { throw TLSException(Describe("could not set the ALPN protocol list")); } } return wrapper; } // ── TLSStream ──────────────────────────────────────────────────────────── struct TLSStream::Impl { std::shared_ptr 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(selected), length); } } }; TLSStream::TLSStream() : impl(std::make_unique()) {} TLSStream::~TLSStream() { Shutdown(); } std::unique_ptr TLSStream::Connect(int descriptor, std::shared_ptr context, const std::string& hostName, std::chrono::milliseconds timeout) { if (!context) throw TLSException("no TLS context"); SetNonBlocking(descriptor); std::unique_ptr 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::Accept(int descriptor, std::shared_ptr context, std::chrono::milliseconds timeout) { if (!context) throw TLSException("no TLS context"); SetNonBlocking(descriptor); std::unique_ptr 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(std::min(size, INT_MAX)); for (;;) { ERR_clear_error(); const int got = SSL_read(impl->ssl, buffer, wanted); if (got > 0) { read = static_cast(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(buffer); std::size_t sent = 0; while (sent < size) { const int wanted = static_cast(std::min(size - sent, INT_MAX)); ERR_clear_error(); const int wrote = SSL_write(impl->ssl, data + sent, wanted); if (wrote > 0) { sent += static_cast(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 cached; std::lock_guard lock(mutex); if (!cached) cached = MakeSelfSignedCertificate(); return *cached; }