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>
211 lines
9.9 KiB
C++
211 lines
9.9 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();
|
|
|
|
// ── Module-internal, not exported ────────────────────────────────────
|
|
// The certificate work :ListenerQUIC's self-signed credential needs.
|
|
// It lives here so that libssl stays behind this one partition: the
|
|
// listener keeps the pair on disk with plain POSIX calls and leaves
|
|
// every OpenSSL call to these.
|
|
|
|
// A fresh pair shaped for Chromium's WebTransport
|
|
// serverCertificateHashes, which pins a hash instead of trusting a CA
|
|
// and in exchange demands: ECDSA P-256 signed with SHA-256, a validity
|
|
// of under 14 days (10, backdated an hour), BasicConstraints CA:FALSE,
|
|
// KeyUsage digitalSignature, EKU serverAuth, and SANs covering the name
|
|
// dialled (localhost, 127.0.0.1, ::1). A random serial, since the pair
|
|
// is replaced every ten days under the same issuer name. Throws
|
|
// TLSException.
|
|
TLSCertificatePem MakeWebTransportCertificatePem();
|
|
|
|
// When the certificate in `pem` stops being valid, provided it parses,
|
|
// has begun being valid, and `pem.privateKey` is its key; nothing for
|
|
// anything else. Never throws: a bad pair on disk is one to replace.
|
|
std::optional<std::chrono::system_clock::time_point>
|
|
CertificatePairExpiry(const TLSCertificatePem& pem);
|
|
|
|
// The pair as a DER-encoded PKCS#12 under `password`: the in-memory
|
|
// credential msquic takes as QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12.
|
|
// Throws TLSException.
|
|
std::vector<std::uint8_t> EncodePkcs12(const TLSCertificatePem& pem,
|
|
const std::string& password);
|
|
|
|
// SHA-256 of the DER encoding of the first certificate in `pem`, the
|
|
// value serverCertificateHashes pins. Throws TLSException.
|
|
std::array<std::uint8_t, 32> CertificateSha256(const std::string& pem);
|
|
}
|
|
#endif
|