HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3
listener or a terminating proxy in front. Neither helps the callers this
stack exists for — curl scripts, CI tooling, old proxies — so wrap the
transport in libssl instead.
Two new partitions:
:Stream a ByteStream with per-call deadlines on both directions, plus
the plaintext socket implementation. The HTTP/1.1 client and
listener now hold a ByteStream& and never learn which
transport they have, which is what lets one code path serve
both schemes.
:TLS TLSContext/TLSStream over OpenSSL 3, with credentials for both
roles: chain and hostname verification on by default, private
trust anchors, client certificates, mutual TLS, ALPN, and an
in-process self-signed certificate for development.
Both descriptors go non-blocking and every read and write is driven by
poll() against a deadline. That is required for TLS — a blocking
descriptor cannot express a handshake timeout — and it means a plaintext
write can now time out too, instead of parking forever against a peer
that stopped reading.
ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the
existing ones still speak http://. The listener handshakes on the
connection's own thread, so a peer that stalls mid-handshake costs one
thread rather than the accept loop, and a failed handshake is counted
rather than logged — on a public port it is ordinary traffic.
MessageParser gains SetDefaultScheme so origin-form targets report the
scheme the transport actually used; handlers shared with ListenerHTTP now
see the same "https" they would over HTTP/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
8.1 KiB
C++
179 lines
8.1 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
export module Crafter.Network:TLS;
|
|
import std;
|
|
import :Stream;
|
|
|
|
#ifndef CRAFTER_NETWORK_BROWSER
|
|
namespace Crafter {
|
|
// TLS over a TCP socket, via libssl (OpenSSL 3). This is the transport
|
|
// that turns `ClientHTTP1`/`ListenerHTTP1` into `https://` endpoints; it
|
|
// is deliberately protocol-agnostic, so anything else that owns a
|
|
// connected descriptor can wrap it the same way.
|
|
//
|
|
// No OpenSSL type appears below: the SSL_CTX and SSL live behind the Impl
|
|
// pointers, so importing this partition does not drag <openssl/*.h> into
|
|
// the consumer. TLS 1.2 is the floor, the platform's cipher defaults are
|
|
// used unchanged, and renegotiation is left to OpenSSL's own policy.
|
|
export class TLSException : public std::runtime_error {
|
|
public:
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
|
|
// A certificate and its private key, PEM-encoded.
|
|
export struct TLSCertificatePem {
|
|
std::string certificate;
|
|
std::string privateKey;
|
|
};
|
|
|
|
// The certificate the server presents. Exactly one source is used, in
|
|
// this order: certPath/keyPath, then certPem/keyPem, then selfSigned.
|
|
//
|
|
// selfSigned generates an ephemeral in-memory certificate (see
|
|
// GetSelfSignedCertificatePem) — for development, tests and LAN use. A
|
|
// client talking to it needs either insecureNoServerValidation or the
|
|
// certificate itself as a trust anchor.
|
|
export struct TLSServerCredentials {
|
|
// PEM files on disk. certPath may hold a chain (leaf first).
|
|
std::string certPath;
|
|
std::string keyPath;
|
|
// The same material inline, for callers that hold it in memory
|
|
// already (a secret store, a test) and would rather not touch disk.
|
|
std::string certPem;
|
|
std::string keyPem;
|
|
bool selfSigned = false;
|
|
|
|
// Mutual TLS. With requireClientCertificate set, a peer that presents
|
|
// no certificate — or one that does not chain to clientCaPath — is
|
|
// rejected during the handshake. clientCaPath is a PEM file or a
|
|
// directory of them; when it is empty the system trust store is used.
|
|
std::string clientCaPath;
|
|
bool requireClientCertificate = false;
|
|
|
|
// Protocols we are willing to speak, in server preference order. A
|
|
// client that offers ALPN and none of these is rejected with
|
|
// no_application_protocol (RFC 7301 §3.2) rather than being let
|
|
// through to speak something we cannot parse. A client that offers no
|
|
// ALPN at all is accepted — plenty of tooling still does not send it.
|
|
std::vector<std::string> alpnProtocols = { "http/1.1" };
|
|
};
|
|
|
|
// How the client checks the server, and what it presents itself.
|
|
//
|
|
// The default verifies the chain against the system trust store *and* the
|
|
// hostname, which is the only combination that is actually safe; a chain
|
|
// check without a name check accepts any valid certificate for any name.
|
|
export struct TLSClientCredentials {
|
|
// Skip both checks. Development only — it accepts any certificate,
|
|
// including an attacker's.
|
|
bool insecureNoServerValidation = false;
|
|
|
|
// Extra trust anchor: a PEM file or a directory of them. Added to the
|
|
// system store rather than replacing it. This is how you talk to a
|
|
// self-signed listener without giving up verification — hand the
|
|
// client the server's certificate.
|
|
std::string caPath;
|
|
// The same, inline.
|
|
std::string caPem;
|
|
|
|
// Overrides the name used for SNI and hostname verification. Empty
|
|
// means the host being connected to, which is what you want unless
|
|
// you are dialling an address that differs from the certificate name
|
|
// (a tunnel, a pinned IP).
|
|
std::string serverName;
|
|
|
|
// Client certificate for mutual TLS. Ignored when the server does not
|
|
// ask for one.
|
|
std::string certPath;
|
|
std::string keyPath;
|
|
|
|
// Protocols to offer, in client preference order. Empty sends no ALPN
|
|
// extension at all.
|
|
std::vector<std::string> alpnProtocols = { "http/1.1" };
|
|
};
|
|
|
|
// A configured SSL_CTX. Shared by every connection it produces — one per
|
|
// listener, one per client — because the expensive parts (parsing the
|
|
// certificate, loading the trust store) are per-context, and OpenSSL 3
|
|
// lets an SSL_CTX be used concurrently from many threads.
|
|
//
|
|
// Held by shared_ptr: a TLSStream keeps its context alive, so a listener
|
|
// that goes away mid-connection does not pull the configuration out from
|
|
// under a session still using it.
|
|
export class TLSContext {
|
|
public:
|
|
static std::shared_ptr<TLSContext> Server(const TLSServerCredentials& credentials);
|
|
static std::shared_ptr<TLSContext> Client(const TLSClientCredentials& credentials);
|
|
|
|
~TLSContext();
|
|
TLSContext(const TLSContext&) = delete;
|
|
TLSContext& operator=(const TLSContext&) = delete;
|
|
|
|
private:
|
|
TLSContext();
|
|
struct Impl;
|
|
std::unique_ptr<Impl> impl;
|
|
friend class TLSStream;
|
|
};
|
|
|
|
// A TLS session over an already-connected descriptor. Non-owning, like
|
|
// PlainStream: the descriptor stays owned by its ClientTCP, and this only
|
|
// adds the record layer on top.
|
|
//
|
|
// Both factories complete the handshake before returning, so a stream you
|
|
// hold is a stream you can write to. They throw TLSException on
|
|
// certificate rejection, on a protocol mismatch, and on a peer that stops
|
|
// answering mid-handshake.
|
|
export class TLSStream final : public ByteStream {
|
|
public:
|
|
// Client side. `hostName` drives SNI and hostname verification unless
|
|
// the credentials overrode it with serverName; an IP literal sets no
|
|
// SNI (RFC 6066 forbids it) and is checked against the certificate's
|
|
// iPAddress SANs instead.
|
|
static std::unique_ptr<TLSStream> Connect(int descriptor,
|
|
std::shared_ptr<TLSContext> context,
|
|
const std::string& hostName,
|
|
std::chrono::milliseconds timeout);
|
|
|
|
// Server side, on a descriptor accept(2) just handed us.
|
|
static std::unique_ptr<TLSStream> Accept(int descriptor,
|
|
std::shared_ptr<TLSContext> context,
|
|
std::chrono::milliseconds timeout);
|
|
|
|
~TLSStream() override;
|
|
|
|
StreamStatus ReadSome(char* buffer, std::size_t size,
|
|
std::chrono::milliseconds timeout,
|
|
std::size_t& read) override;
|
|
void Write(const void* buffer, std::size_t size,
|
|
std::chrono::milliseconds timeout) override;
|
|
void Shutdown() noexcept override;
|
|
int Descriptor() const noexcept override;
|
|
std::string_view Protocol() const noexcept override;
|
|
bool Secure() const noexcept override { return true; }
|
|
|
|
// The negotiated protocol version, e.g. "TLSv1.3". For logging.
|
|
std::string Version() const;
|
|
// One-line subject of the peer's certificate, empty when it presented
|
|
// none. With requireClientCertificate a non-empty value is the
|
|
// authenticated client identity.
|
|
std::string PeerCertificateSubject() const;
|
|
|
|
private:
|
|
TLSStream();
|
|
struct Impl;
|
|
std::unique_ptr<Impl> impl;
|
|
};
|
|
|
|
// The process-wide ephemeral self-signed certificate used by
|
|
// TLSServerCredentials{selfSigned=true}, in PEM form. Generated on first
|
|
// call and then cached, so every listener in a process presents the same
|
|
// certificate and a client can be handed it as a trust anchor.
|
|
//
|
|
// ECDSA P-256, CN=localhost, SAN {DNS:localhost, IP:127.0.0.1, IP:::1},
|
|
// valid for 10 days. Development and tests only — it is regenerated on
|
|
// every process start and no peer has any reason to trust it.
|
|
export const TLSCertificatePem& GetSelfSignedCertificatePem();
|
|
}
|
|
#endif
|