From 61d3e8c26aa5b2526ac58fe13c3bd4aa013d3580 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 01:56:44 +0100 Subject: [PATCH] fix(listener): make the selfSigned QUIC certificate per user, in-process, without a race 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 --- .../Crafter.Network-ListenerQUIC.cpp | 391 ++++++++++++------ implementations/Crafter.Network-TLS.cpp | 99 ++++- interfaces/Crafter.Network-ClientQUIC.cppm | 4 +- interfaces/Crafter.Network-ListenerQUIC.cppm | 19 +- interfaces/Crafter.Network-TLS.cppm | 32 ++ 5 files changed, 404 insertions(+), 141 deletions(-) diff --git a/implementations/Crafter.Network-ListenerQUIC.cpp b/implementations/Crafter.Network-ListenerQUIC.cpp index a8be0e8..d1ef71e 100644 --- a/implementations/Crafter.Network-ListenerQUIC.cpp +++ b/implementations/Crafter.Network-ListenerQUIC.cpp @@ -3,15 +3,18 @@ module; #include +#include #include -#include #include +#include #include #include +#include #include module Crafter.Network:ListenerQUIC_impl; import :ListenerQUIC; import :ClientQUIC; +import :TLS; import Crafter.Thread; import std; @@ -91,88 +94,231 @@ namespace { return address; } - // Lazily generate (and cache) a self-signed cert + key pair on disk by - // shelling out to the openssl CLI. Used by ListenerQUIC when the caller - // passes QUICServerCredentials{selfSigned=true}. The pair lives in a - // mkdtemp'd directory under /tmp for the lifetime of the process. - // Intended for dev / LAN play / tests — production should pass real - // cert/key paths. + // ── The self-signed certificate ────────────────────────────────────── + // QUICServerCredentials{selfSigned=true} serves one pair per user, kept + // on disk so that it, and with it the SHA-256 a browser pins through + // serverCertificateHashes, survives a server restart while it is valid: + // 3DForts publishes that hash to its pages, and one that changed on + // every restart would break every page still holding the old one. The + // pair is made in-process by :TLS (MakeWebTransportCertificatePem, whose + // comment has the shape Chromium demands), so nothing has to be + // installed; it used to take the openssl CLI. // - // Cert shape: ECDSA P-256, validity 13 days, SAN={DNS:localhost, - // IP:127.0.0.1, IP:::1}. These constraints are mandated by Chromium's - // WebTransport `serverCertificateHashes` (must be ECDSA P-256, validity - // <14 days, SAN with the connect target). msquic accepts the same cert - // unchanged, so existing pure-QUIC callers are unaffected. + // Where: $XDG_CACHE_HOME/crafter-network/quic-cert/, else + // $HOME/.cache/crafter-network/quic-cert/, else + // $XDG_RUNTIME_DIR/crafter-network/quic-cert/ (missing parents made + // 0700, as the XDG spec asks). It used to be the fixed + // /tmp/crafter-network-quic-cert/, which every user of the machine + // shared: whoever made it first could keep everyone else from writing + // there, or plant a pair. The last directory is opened O_NOFOLLOW and + // refused unless it is this user's, and made 0700 if it was not. + // + // Several processes: test binaries the runner starts at once all find no + // pair and all make one. With the CLI writing key.pem and cert.pem in + // place, one after the other, under an in-process mutex only, a process + // could load one run's key with another's certificate, and the listener + // failed in ConfigurationLoadCredential. Now: + // - a POSIX record lock on `lock` in the directory covers the check + // and the replacement, so one process makes the pair and the rest + // read it. A record lock rather than flock: it belongs to the + // process, so a child forked while another thread holds it does not + // inherit it and hold it for its whole life; threads of one process + // are kept apart by the mutex in GetSelfSignedCert instead; + // - each file is written under a temporary name and renamed over the + // old one, so a reader sees an old file or a new one, never half; + // - the listener loads the pair from memory, as PKCS#12, not from the + // files, so a replacement cannot land between its reads of the two; + // - a pair that does not parse, does not match, or has less than a day + // left is replaced; so is one a crash tore between the two renames. struct SelfSignedCert { std::string certPath; std::string keyPath; + TLSCertificatePem pem; + std::chrono::system_clock::time_point notAfter; }; - SelfSignedCert& GetSelfSignedCert() { - static std::mutex mtx; - static std::optional cached; - std::lock_guard lk(mtx); - if (cached) return *cached; - // Stable on-disk location so the cert (and therefore its SHA-256) is - // reused across server restarts. Without this, a browser peer that - // pinned the cert hash on a previous run would see a hash mismatch - // the moment we restart. We only regenerate if the cert file is - // missing or has expired. - std::filesystem::path dir = "/tmp/crafter-network-quic-cert"; - std::error_code ec; - std::filesystem::create_directories(dir, ec); - if (ec) { - throw QUICException(std::format( - "could not create cert cache dir {}: {}", dir.string(), ec.message())); - } - SelfSignedCert s; - s.keyPath = (dir / "key.pem").string(); - s.certPath = (dir / "cert.pem").string(); + // A pair this close to its end is replaced rather than served: a server + // started on it would outlive it. + constexpr std::chrono::hours renewWithin{ 24 }; - bool needRegen = !std::filesystem::exists(s.certPath, ec) - || !std::filesystem::exists(s.keyPath, ec); - if (!needRegen) { - // Use openssl to ask whether the cert is still valid. -checkend 0 - // returns 0 if the cert is still good, non-zero if expired. - int rc = std::system(std::format( - "openssl x509 -in '{}' -noout -checkend 0 >/dev/null 2>&1", - s.certPath).c_str()); - if (rc != 0) needRegen = true; - } - if (needRegen) { - // Inline openssl config so we get exactly the extensions Chromium's - // WebTransport cert-hash verifier accepts (BasicConstraints CA:FALSE, - // KeyUsage digitalSignature, EKU serverAuth, SAN). Skipping the - // implicit subjectKeyIdentifier / authorityKeyIdentifier that - // `openssl req -x509 -addext ...` would otherwise add. - std::string cmd = std::format( - "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 " - "-keyout '{}' -out '{}' -days 10 -nodes " - "-extensions v3_wt " - "-config /dev/stdin >/dev/null 2>&1 <<'CONFIG'\n" - "[req]\n" - "distinguished_name = req_dn\n" - "prompt = no\n" - "[req_dn]\n" - "CN = localhost\n" - "[v3_wt]\n" - "basicConstraints = critical, CA:FALSE\n" - "keyUsage = critical, digitalSignature\n" - "extendedKeyUsage = serverAuth\n" - "subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1\n" - "CONFIG\n", - s.keyPath, s.certPath); - int rc = std::system(cmd.c_str()); - if (rc != 0) { - throw QUICException(std::format( - "openssl CLI failed to generate self-signed cert " - "(exit {}); install openssl or pass certPath/keyPath", - rc)); + // The lock is held for a read of two small files, or for making a pair, + // milliseconds either way; the bound is for a holder that was stopped. + constexpr std::chrono::seconds lockTimeout{ 10 }; + + // A file descriptor, closed on scope exit. + struct Descriptor { + int fd = -1; + explicit Descriptor(int fd) : fd(fd) {} + Descriptor(Descriptor&& other) noexcept : fd(std::exchange(other.fd, -1)) {} + Descriptor(const Descriptor&) = delete; + Descriptor& operator=(const Descriptor&) = delete; + ~Descriptor() { if (fd >= 0) ::close(fd); } + }; + + [[noreturn]] void ThrowCertificateError(std::string_view what, const std::filesystem::path& path) { + throw QUICException(std::format("selfSigned: {} {}: {}; pass certPath/keyPath instead", + what, path.string(), std::strerror(errno))); + } + + std::filesystem::path CertificateDirectory() { + // The XDG spec says to ignore a relative path in these. + auto absolute = [](const char* name) -> std::optional { + const char* value = std::getenv(name); + if (value == nullptr || value[0] != '/') return std::nullopt; + return std::filesystem::path(value); + }; + if (auto cache = absolute("XDG_CACHE_HOME")) return *cache / "crafter-network" / "quic-cert"; + if (auto home = absolute("HOME")) return *home / ".cache" / "crafter-network" / "quic-cert"; + if (auto runtime = absolute("XDG_RUNTIME_DIR")) return *runtime / "crafter-network" / "quic-cert"; + throw QUICException("selfSigned: no per-user directory for the certificate " + "(XDG_CACHE_HOME, HOME and XDG_RUNTIME_DIR are all unset); " + "pass certPath/keyPath instead"); + } + + // Makes whatever is missing of `path`, 0700, and opens the last + // directory, which must be this user's and nobody else's. + Descriptor OpenPrivateDirectory(const std::filesystem::path& path) { + std::filesystem::path partial; + for (const std::filesystem::path& part : path) { + partial /= part; + if (::mkdir(partial.c_str(), 0700) != 0 && errno != EEXIST) { + ThrowCertificateError("could not create", partial); } } - cached = std::move(s); + Descriptor directory(::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)); + if (directory.fd < 0) ThrowCertificateError("could not open", path); + struct stat info {}; + if (::fstat(directory.fd, &info) != 0) ThrowCertificateError("could not stat", path); + if (info.st_uid != ::geteuid()) { + throw QUICException(std::format("selfSigned: {} belongs to another user; " + "pass certPath/keyPath instead", path.string())); + } + if ((info.st_mode & 077) != 0 && ::fchmod(directory.fd, 0700) != 0) { + ThrowCertificateError("could not make private", path); + } + return directory; + } + + // An exclusive record lock on `lock` in `directory`, held until the + // returned descriptor closes, and given up on after lockTimeout. + Descriptor LockDirectory(int directory, const std::filesystem::path& path) { + Descriptor lock(::openat(directory, "lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600)); + if (lock.fd < 0) ThrowCertificateError("could not open", path / "lock"); + struct flock whole {}; + whole.l_type = F_WRLCK; + whole.l_whence = SEEK_SET; + const auto deadline = std::chrono::steady_clock::now() + lockTimeout; + while (::fcntl(lock.fd, F_SETLK, &whole) != 0) { + if (errno == EINTR) continue; + if (errno != EACCES && errno != EAGAIN) ThrowCertificateError("could not lock", path / "lock"); + if (std::chrono::steady_clock::now() >= deadline) { + throw QUICException(std::format("selfSigned: another process held {} for {} s", + (path / "lock").string(), lockTimeout.count())); + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return lock; + } + + // `name` in `directory`, or nothing when it is missing, unreadable, or + // far too big for a PEM. + std::optional ReadAt(int directory, const char* name) { + Descriptor file(::openat(directory, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)); + if (file.fd < 0) return std::nullopt; + std::string contents; + char buffer[4096]; + for (;;) { + const ssize_t got = ::read(file.fd, buffer, sizeof(buffer)); + if (got == 0) return contents; + if (got < 0) { + if (errno == EINTR) continue; + return std::nullopt; + } + contents.append(buffer, static_cast(got)); + if (contents.size() > 64 * 1024) return std::nullopt; + } + } + + // Replaces `name` in `directory` by writing a temporary file and + // renaming it over the old one. Only the holder of the lock writes here, + // so a temporary file found is one a crashed writer left. + void WriteAt(int directory, const std::filesystem::path& path, const std::string& name, + const std::string& contents, mode_t mode) { + const std::string temporary = name + ".new"; + ::unlinkat(directory, temporary.c_str(), 0); + Descriptor file(::openat(directory, temporary.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode)); + if (file.fd < 0) ThrowCertificateError("could not create", path / temporary); + for (std::size_t written = 0; written < contents.size();) { + const ssize_t put = ::write(file.fd, contents.data() + written, contents.size() - written); + if (put < 0) { + if (errno == EINTR) continue; + ThrowCertificateError("could not write", path / temporary); + } + written += static_cast(put); + } + if (::fsync(file.fd) != 0) ThrowCertificateError("could not flush", path / temporary); + if (::renameat(directory, temporary.c_str(), directory, name.c_str()) != 0) { + ThrowCertificateError("could not rename into", path / name); + } + } + + // The pair on disk if it is good for more than renewWithin, else a new + // one written there. Under the directory's lock throughout. + SelfSignedCert LoadOrMakeSelfSignedCert() { + const std::filesystem::path path = CertificateDirectory(); + const Descriptor directory = OpenPrivateDirectory(path); + const Descriptor lock = LockDirectory(directory.fd, path); + SelfSignedCert cert; + cert.certPath = (path / "cert.pem").string(); + cert.keyPath = (path / "key.pem").string(); + const auto now = std::chrono::system_clock::now(); + + std::optional certificate = ReadAt(directory.fd, "cert.pem"); + std::optional key = ReadAt(directory.fd, "key.pem"); + if (certificate && key) { + cert.pem = TLSCertificatePem{ std::move(*certificate), std::move(*key) }; + const auto expiry = CertificatePairExpiry(cert.pem); + if (expiry && *expiry - now > renewWithin) { + cert.notAfter = *expiry; + return cert; + } + } + + std::optional expiry; + try { + cert.pem = MakeWebTransportCertificatePem(); + expiry = CertificatePairExpiry(cert.pem); + } catch (const TLSException& e) { + throw QUICException(std::format("selfSigned: {}", e.what())); + } + if (!expiry) throw QUICException("selfSigned: the certificate just made does not check out"); + cert.notAfter = *expiry; + // The key first, readable by this user alone; the certificate is + // public. Each rename is atomic, the pair is not, and that is fine: + // every reader holds the lock. + WriteAt(directory.fd, path, "key.pem", cert.pem.privateKey, 0600); + WriteAt(directory.fd, path, "cert.pem", cert.pem.certificate, 0644); + return cert; + } + + // This process's pair: loaded on first use and again once it has less + // than renewWithin left, so every listener in between serves the same + // certificate, the one GetSelfSignedCertificatePath() names. + SelfSignedCert GetSelfSignedCert() { + static std::mutex mutex; + static std::optional cached; + std::lock_guard lock(mutex); + if (!cached || cached->notAfter - std::chrono::system_clock::now() <= renewWithin) { + cached = LoadOrMakeSelfSignedCert(); + } return *cached; } + + // The PKCS#12 a selfSigned listener hands msquic is made and parsed in + // memory within one call, so its password protects nothing; msquic's + // own code does the same with a random one. + constexpr const char* pkcs12Password = "crafter-network"; } struct ListenerQUIC::Impl { @@ -255,6 +401,36 @@ static HQUIC OpenServerConfiguration(const std::string& alpn, std::vector& alpnBufOut) { QUIC_BUFFER alpnBuffer = MakeAlpn(alpn, alpnBufOut); + // The credential first, since getting it can throw and nothing is open + // yet. selfSigned reaches msquic from memory, as PKCS#12, so the files, + // which another process may replace, are read once and under their + // lock (see GetSelfSignedCert); a certPath/keyPath pair is msquic's to + // read. + QUIC_CREDENTIAL_CONFIG cc{}; + QUIC_CERTIFICATE_FILE certFile{}; + QUIC_CERTIFICATE_PKCS12 pkcs12{}; + std::vector pkcs12Blob; + std::string certDescription = creds.certPath; + if (creds.selfSigned) { + const SelfSignedCert cert = GetSelfSignedCert(); + try { + pkcs12Blob = EncodePkcs12(cert.pem, pkcs12Password); + } catch (const TLSException& e) { + throw QUICException(std::format("selfSigned: {}", e.what())); + } + pkcs12.Asn1Blob = pkcs12Blob.data(); + pkcs12.Asn1BlobLength = static_cast(pkcs12Blob.size()); + pkcs12.PrivateKeyPassword = pkcs12Password; + cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12; + cc.CertificatePkcs12 = &pkcs12; + certDescription = cert.certPath; + } else { + certFile.CertificateFile = creds.certPath.c_str(); + certFile.PrivateKeyFile = creds.keyPath.c_str(); + cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE; + cc.CertificateFile = &certFile; + } + QUIC_SETTINGS settings{}; settings.IsSet.IdleTimeoutMs = 1; settings.IdleTimeoutMs = creds.settings.idleTimeoutMs; @@ -274,29 +450,15 @@ static HQUIC OpenServerConfiguration(const std::string& alpn, &settings, sizeof(settings), nullptr, &cfg); if (QUIC_FAILED(s)) throw QUICException(std::format("ConfigurationOpen failed: 0x{:x}", static_cast(s))); - QUIC_CREDENTIAL_CONFIG cc{}; - QUIC_CERTIFICATE_FILE certFile{}; - std::string effectiveCertPath; - std::string effectiveKeyPath; - if (creds.selfSigned) { - const SelfSignedCert& s = GetSelfSignedCert(); - effectiveCertPath = s.certPath; - effectiveKeyPath = s.keyPath; - } else { - effectiveCertPath = creds.certPath; - effectiveKeyPath = creds.keyPath; - } - certFile.CertificateFile = effectiveCertPath.c_str(); - certFile.PrivateKeyFile = effectiveKeyPath.c_str(); - cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE; - cc.CertificateFile = &certFile; + // Synchronous, without QUIC_CREDENTIAL_FLAG_LOAD_ASYNCHRONOUS: msquic has + // parsed the PKCS#12 into an SSL_CTX of its own before this returns. s = Runtime().api->ConfigurationLoadCredential(cfg, &cc); if (QUIC_FAILED(s)) { Runtime().api->ConfigurationClose(cfg); throw QUICException(std::format("ConfigurationLoadCredential failed: 0x{:x}" " (selfSigned={}, cert={})", static_cast(s), - creds.selfSigned, creds.certPath)); + creds.selfSigned, certDescription)); } return cfg; } @@ -472,49 +634,16 @@ void ListenerQUIC::ListenAsyncAsync() { } std::array Crafter::ComputeCertificateHashSHA256(const std::string& certPath) { - // Convert PEM → DER → SHA-256 via openssl, capture hex digest from stdout. - // openssl pipes give us "SHA2-256(stdin)= \n"; we parse the trailing - // hex run. Shelling out keeps msquic the sole TLS dependency. - std::string cmd = std::format( - "openssl x509 -in '{}' -outform der | openssl dgst -sha256 2>/dev/null", - certPath); - FILE* p = popen(cmd.c_str(), "r"); - if (!p) throw QUICException("popen failed while computing cert hash"); - std::string out; - char chunk[256]; - while (auto n = std::fread(chunk, 1, sizeof(chunk), p)) { - out.append(chunk, n); + // In-process through :TLS; this used to pipe the file through two + // openssl CLI invocations and parse the hex back out of the output. + std::ifstream file(certPath, std::ios::binary); + if (!file) throw QUICException(std::format("could not open {} to hash it", certPath)); + const std::string pem((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + try { + return CertificateSha256(pem); + } catch (const TLSException& e) { + throw QUICException(std::format("could not compute the SHA-256 of {}: {}", certPath, e.what())); } - int rc = pclose(p); - if (rc != 0) { - throw QUICException(std::format( - "openssl failed to compute SHA-256 of {} (exit {})", certPath, rc)); - } - auto eq = out.rfind('='); - if (eq == std::string::npos) { - throw QUICException("could not parse openssl dgst output"); - } - std::string hex; - for (std::size_t i = eq + 1; i < out.size(); ++i) { - char c = out[i]; - if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - hex.push_back(c); - } - } - if (hex.size() != 64) { - throw QUICException(std::format( - "unexpected SHA-256 hex length {} (expected 64)", hex.size())); - } - std::array result{}; - for (std::size_t i = 0; i < 32; ++i) { - auto nibble = [](char c) -> std::uint8_t { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); - return 10 + (c - 'A'); - }; - result[i] = (nibble(hex[2 * i]) << 4) | nibble(hex[2 * i + 1]); - } - return result; } std::string Crafter::GetSelfSignedCertificatePath() { diff --git a/implementations/Crafter.Network-TLS.cpp b/implementations/Crafter.Network-TLS.cpp index 4977021..2c97244 100644 --- a/implementations/Crafter.Network-TLS.cpp +++ b/implementations/Crafter.Network-TLS.cpp @@ -8,11 +8,14 @@ module; #include #include #include +#include #include +#include #include #include #include +#include #include #include #include @@ -78,6 +81,8 @@ namespace { using OwnedBio = Owned; using OwnedKey = Owned; using OwnedCertificate = Owned; + using OwnedBignum = Owned; + using OwnedPkcs12 = 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 @@ -167,7 +172,9 @@ namespace { if (added != 1) throw TLSException(Describe("could not add certificate extension")); } - TLSCertificatePem MakeSelfSignedCertificate() { + // `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")); @@ -177,7 +184,17 @@ namespace { // 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); + 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); @@ -198,8 +215,7 @@ namespace { 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_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. @@ -716,6 +732,79 @@ const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() { static std::mutex mutex; static std::optional cached; std::lock_guard lock(mutex); - if (!cached) cached = MakeSelfSignedCertificate(); + 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 +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(pem.certificate.size()))); + OwnedBio keyBio(BIO_new_mem_buf(pem.privateKey.data(), static_cast(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 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 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 encoded(der, der + length); + OPENSSL_free(der); + return encoded; +} + +std::array 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 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; +} diff --git a/interfaces/Crafter.Network-ClientQUIC.cppm b/interfaces/Crafter.Network-ClientQUIC.cppm index 4333bd8..015e6fb 100644 --- a/interfaces/Crafter.Network-ClientQUIC.cppm +++ b/interfaces/Crafter.Network-ClientQUIC.cppm @@ -51,7 +51,9 @@ namespace Crafter { }; // Server certificate sources. Pick one of (filePaths) or (selfSigned). - // selfSigned generates an in-memory ephemeral cert — fine for dev/LAN. + // selfSigned serves a development certificate made in-process and kept + // per user under $XDG_CACHE_HOME/crafter-network/quic-cert/, reused + // while valid (see GetSelfSignedCertificatePath) — fine for dev/LAN. export struct QUICServerCredentials { std::string certPath; std::string keyPath; diff --git a/interfaces/Crafter.Network-ListenerQUIC.cppm b/interfaces/Crafter.Network-ListenerQUIC.cppm index f059d51..cf6a5c2 100644 --- a/interfaces/Crafter.Network-ListenerQUIC.cppm +++ b/interfaces/Crafter.Network-ListenerQUIC.cppm @@ -86,12 +86,23 @@ namespace Crafter { // Returns the 32-byte digest. Intended for surfacing the self-signed cert // hash to a browser peer (Chrome's WebTransport requires the client to // pass this hash via `serverCertificateHashes` when peering against a - // cert that's not in the system trust store). Shells out to openssl. + // cert that's not in the system trust store). In-process, through + // OpenSSL; throws QUICException for a file it cannot read or parse. export std::array ComputeCertificateHashSHA256(const std::string& certPath); - // Path of the lazily-generated self-signed cert (PEM). Triggers generation - // on first call. Useful for piping into ComputeCertificateHashSHA256 so - // a browser peer can be told the hash to put in `serverCertificateHashes`. + // Path of the self-signed cert (PEM) QUICServerCredentials{selfSigned} + // serves; makes it on first use. Useful for piping into + // ComputeCertificateHashSHA256 so a browser peer can be told the hash to + // put in `serverCertificateHashes`. + // + // One pair per user, in $XDG_CACHE_HOME/crafter-network/quic-cert/ + // (else ~/.cache/..., else $XDG_RUNTIME_DIR/...), a 0700 directory, made + // in-process (no openssl CLI). It is reused across restarts, and so its + // hash stays pinned, until it has less than a day of its ten left; then + // the next process to need it makes a new one. Processes starting at + // once share one pair: a lock covers the check and the replacement. + // Throws QUICException when no such directory can be had (none of those + // variables set, or the directory belongs to someone else). export std::string GetSelfSignedCertificatePath(); } #endif diff --git a/interfaces/Crafter.Network-TLS.cppm b/interfaces/Crafter.Network-TLS.cppm index 219e05b..a884ac3 100644 --- a/interfaces/Crafter.Network-TLS.cppm +++ b/interfaces/Crafter.Network-TLS.cppm @@ -175,5 +175,37 @@ namespace Crafter { // 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 + 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 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 CertificateSha256(const std::string& pem); } #endif