QUICServerCredentials{selfSigned} shelled out to the openssl CLI and kept
the pair in the fixed /tmp/crafter-network-quic-cert/, guarded by an
in-process mutex only. Three things went wrong with that:
- Processes started at once (crafter-build runs tests in parallel, and
inside the Flatpak they share one /tmp) each found no pair and each
ran `openssl req` over the same key.pem and cert.pem, which it writes
in place one after the other. A process could load one run's key with
another's certificate and fail in ConfigurationLoadCredential.
- /tmp is shared by every user of the machine: whoever made the
directory first could keep everyone else from writing there, or plant
a pair in it.
- Without the openssl CLI there was no certificate at all, and
ComputeCertificateHashSHA256 piped the file through it as well.
The pair now lives in $XDG_CACHE_HOME/crafter-network/quic-cert/ (else
~/.cache/..., else $XDG_RUNTIME_DIR/...). Missing directories are made
0700, and the last is opened O_NOFOLLOW, refused unless the user owns
it, and made 0700 if it was not. A POSIX record lock on a file in it,
given up on after 10 s, covers both the check of the pair on disk and
its replacement, so one process makes the pair and the rest read it;
each file is written under a temporary name, fsynced and renamed into
place; and the listener hands msquic the pair from memory as PKCS#12
(QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12) instead of the two paths, so
a replacement cannot land between its reads of the key and the
certificate. A pair that does not parse, whose key does not match, or
that has less than a day left is replaced, which also repairs one a
crash tore between the two renames. A record lock rather than flock: an
flock is shared with a child forked while another thread holds it, and
the child holds it for its whole life. Threads of one process are kept
apart by the existing mutex, and the process keeps the pair it loaded,
so all its listeners serve one certificate until it nears its end.
The certificate is made in-process by :TLS in the shape the CLI
produced (ECDSA P-256, ecdsa-with-SHA256, 10 days, BasicConstraints
CA:FALSE, KeyUsage digitalSignature, EKU serverAuth, SANs localhost,
127.0.0.1 and ::1), with a random serial where the CLI also drew one;
ComputeCertificateHashSHA256 is X509_digest over the file's
certificate. The OpenSSL helpers are module-internal declarations in
:TLS, so libssl stays behind that one partition, and the TLS
partition's own development certificate is unchanged.
As before, the pair is reused across restarts while valid, so a
browser's pinned hash survives one; it is now replaced a day before it
expires rather than after, so a server is not started on a certificate
about to lapse. In the Flatpak, where /tmp went away with the last
instance, it now survives between runs too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
810 lines
37 KiB
C++
810 lines
37 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#include <poll.h>
|
|
#include <arpa/inet.h>
|
|
#include <signal.h>
|
|
#include <pthread.h>
|
|
#include <cerrno>
|
|
#include <climits>
|
|
#include <time.h>
|
|
|
|
#include <openssl/bio.h>
|
|
#include <openssl/bn.h>
|
|
#include <openssl/err.h>
|
|
#include <openssl/evp.h>
|
|
#include <openssl/pem.h>
|
|
#include <openssl/pkcs12.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 {
|
|
// ── SIGPIPE ──────────────────────────────────────────────────────────
|
|
// OpenSSL's socket BIO writes with write(2), not send(2), so it cannot
|
|
// pass MSG_NOSIGNAL the way PlainStream does. Writing to a peer that has
|
|
// gone therefore raises SIGPIPE and — with the default disposition — kills
|
|
// the process. That is not an edge case: it happens on every teardown
|
|
// where the far side closed first, because SSL_shutdown still tries to put
|
|
// a close_notify on the wire.
|
|
//
|
|
// A library must not install a process-wide SIG_IGN on its caller's
|
|
// behalf; that would silently change how the caller's own writes behave.
|
|
// SIGPIPE from write(2) is delivered to the thread that wrote, so block it
|
|
// for this thread across the call instead, and drain any instance that
|
|
// went pending while it was blocked so it cannot fire on unblock.
|
|
class SigPipeGuard {
|
|
public:
|
|
SigPipeGuard() {
|
|
sigset_t pipeOnly;
|
|
sigemptyset(&pipeOnly);
|
|
sigaddset(&pipeOnly, SIGPIPE);
|
|
sigset_t previous;
|
|
// If the caller already blocks SIGPIPE, leave everything alone —
|
|
// any pending instance may be theirs to consume, not ours.
|
|
blocked = pthread_sigmask(SIG_BLOCK, &pipeOnly, &previous) == 0
|
|
&& sigismember(&previous, SIGPIPE) == 0;
|
|
restore = previous;
|
|
}
|
|
~SigPipeGuard() {
|
|
if (!blocked) return;
|
|
sigset_t pipeOnly;
|
|
sigemptyset(&pipeOnly);
|
|
sigaddset(&pipeOnly, SIGPIPE);
|
|
const timespec immediately{ .tv_sec = 0, .tv_nsec = 0 };
|
|
while (sigtimedwait(&pipeOnly, nullptr, &immediately) >= 0) {}
|
|
pthread_sigmask(SIG_SETMASK, &restore, nullptr);
|
|
}
|
|
SigPipeGuard(const SigPipeGuard&) = delete;
|
|
|
|
private:
|
|
sigset_t restore{};
|
|
bool blocked = false;
|
|
};
|
|
|
|
// ── 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>;
|
|
using OwnedBignum = Owned<BIGNUM, BN_free>;
|
|
using OwnedPkcs12 = Owned<PKCS12, PKCS12_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"));
|
|
}
|
|
|
|
// `keyUsage` is the KeyUsage extension's value; `randomSerial` draws the
|
|
// serial number instead of using 1.
|
|
TLSCertificatePem MakeSelfSignedCertificate(const char* keyUsage, bool randomSerial) {
|
|
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);
|
|
if (randomSerial) {
|
|
// 127 bits: positive, and within the 20 octets RFC 5280 allows.
|
|
OwnedBignum serial(BN_new());
|
|
if (!serial
|
|
|| BN_rand(serial.get(), 127, BN_RAND_TOP_ANY, BN_RAND_BOTTOM_ANY) != 1
|
|
|| BN_to_ASN1_INTEGER(serial.get(), X509_get_serialNumber(certificate.get())) == nullptr) {
|
|
throw TLSException(Describe("could not draw a serial number"));
|
|
}
|
|
} else {
|
|
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, keyUsage);
|
|
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 SigPipeGuard noSigPipe;
|
|
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;
|
|
// A read can put bytes on the wire too — a TLS 1.3 key update, or an alert
|
|
// in response to something we refuse.
|
|
const SigPipeGuard noSigPipe;
|
|
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 SigPipeGuard noSigPipe;
|
|
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;
|
|
// The likeliest SIGPIPE of the lot: by the time a connection is being torn
|
|
// down the peer has often gone already.
|
|
const SigPipeGuard noSigPipe;
|
|
// 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("critical,digitalSignature,keyEncipherment", false);
|
|
return *cached;
|
|
}
|
|
|
|
// ── Module-internal: :ListenerQUIC's self-signed credential ──────────────
|
|
|
|
TLSCertificatePem Crafter::MakeWebTransportCertificatePem() {
|
|
// No keyEncipherment, unlike the TLS certificate above: the openssl
|
|
// invocation this replaces asked for digitalSignature alone, which is
|
|
// all an ECDSA key can do anyway.
|
|
return MakeSelfSignedCertificate("critical,digitalSignature", true);
|
|
}
|
|
|
|
std::optional<std::chrono::system_clock::time_point>
|
|
Crafter::CertificatePairExpiry(const TLSCertificatePem& pem) {
|
|
constexpr std::size_t limit = 1 << 20;
|
|
if (pem.certificate.empty() || pem.privateKey.empty()
|
|
|| pem.certificate.size() > limit || pem.privateKey.size() > limit) {
|
|
return std::nullopt;
|
|
}
|
|
OwnedBio certificateBio(BIO_new_mem_buf(pem.certificate.data(), static_cast<int>(pem.certificate.size())));
|
|
OwnedBio keyBio(BIO_new_mem_buf(pem.privateKey.data(), static_cast<int>(pem.privateKey.size())));
|
|
if (!certificateBio || !keyBio) {
|
|
ERR_clear_error();
|
|
return std::nullopt;
|
|
}
|
|
OwnedCertificate certificate(PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr));
|
|
OwnedKey key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr));
|
|
std::optional<std::chrono::system_clock::time_point> expiry;
|
|
std::tm notAfter{};
|
|
// X509_cmp_current_time is -1 for a time already past, so notBefore has
|
|
// to compare below 0; 0 is an unreadable time.
|
|
if (certificate && key
|
|
&& X509_check_private_key(certificate.get(), key.get()) == 1
|
|
&& X509_cmp_current_time(X509_get0_notBefore(certificate.get())) < 0
|
|
&& ASN1_TIME_to_tm(X509_get0_notAfter(certificate.get()), ¬After) == 1) {
|
|
expiry = std::chrono::system_clock::from_time_t(timegm(¬After));
|
|
}
|
|
// What a bad pair left on the queue is not anyone else's error.
|
|
ERR_clear_error();
|
|
return expiry;
|
|
}
|
|
|
|
std::vector<std::uint8_t> Crafter::EncodePkcs12(const TLSCertificatePem& pem, const std::string& password) {
|
|
OwnedBio certificateBio = MemoryBio(pem.certificate);
|
|
OwnedCertificate certificate(PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr));
|
|
if (!certificate) throw TLSException(Describe("could not read the certificate PEM"));
|
|
OwnedBio keyBio = MemoryBio(pem.privateKey);
|
|
OwnedKey key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr));
|
|
if (!key) throw TLSException(Describe("could not read the private key PEM"));
|
|
// Zeros pick OpenSSL 3's defaults: AES-256-CBC under PBKDF2 for both
|
|
// bags and an HMAC-SHA-256 MAC, all of which its default provider has.
|
|
OwnedPkcs12 pkcs12(PKCS12_create(password.c_str(), nullptr, key.get(), certificate.get(),
|
|
nullptr, 0, 0, 0, 0, 0));
|
|
if (!pkcs12) throw TLSException(Describe("could not encode the pair as PKCS#12"));
|
|
unsigned char* der = nullptr;
|
|
const int length = i2d_PKCS12(pkcs12.get(), &der);
|
|
if (length <= 0 || der == nullptr) throw TLSException(Describe("could not encode the PKCS#12 as DER"));
|
|
std::vector<std::uint8_t> encoded(der, der + length);
|
|
OPENSSL_free(der);
|
|
return encoded;
|
|
}
|
|
|
|
std::array<std::uint8_t, 32> Crafter::CertificateSha256(const std::string& pem) {
|
|
OwnedBio bio = MemoryBio(pem);
|
|
OwnedCertificate certificate(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
|
if (!certificate) throw TLSException(Describe("could not read the certificate PEM"));
|
|
// X509_digest hashes the DER encoding, which is what a browser pins.
|
|
std::array<std::uint8_t, 32> digest{};
|
|
unsigned int length = 0;
|
|
if (X509_digest(certificate.get(), EVP_sha256(), digest.data(), &length) != 1
|
|
|| length != digest.size()) {
|
|
throw TLSException(Describe("could not hash the certificate"));
|
|
}
|
|
return digest;
|
|
}
|