Crafter.Network/interfaces/Crafter.Network-TLS.cppm

179 lines
8.1 KiB
Text
Raw Normal View History

feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack 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>
2026-07-28 20:13:30 +00:00
//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