From 6b8b485e38d41853c9f993e8f1a9a69426fc3cfd Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 01:56:44 +0100 Subject: [PATCH] test(listener): six processes share one selfSigned certificate from cold ShouldShareSelfSignedCertificateQUIC: six processes, forked before any thread or msquic exists and released together, each start two threads that create a selfSigned listener at once against an empty cache (XDG_CACHE_HOME pointed at a fresh directory) and with no openssl on PATH, and connect to both. All twelve listeners serve, and all six processes report the same certificate hash and a path under XDG_CACHE_HOME. The directory is 0700 and the key 0600, nothing temporary is left behind, a process started later serves the same certificate, and a mismatched key planted on disk, what the race used to leave, is replaced by the next process rather than loaded. Against the previous implementation every child fails (`openssl: command not found`), as do the location and permission checks. The parent starts no thread, so forking stays safe, and its watchdog is alarm(55); each child's is alarm(40). Co-Authored-By: Claude Opus 5.5 --- project.cpp | 1 + .../main.cpp | 254 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 tests/ShouldShareSelfSignedCertificateQUIC/main.cpp diff --git a/project.cpp b/project.cpp index d214ef7..16817d0 100644 --- a/project.cpp +++ b/project.cpp @@ -205,6 +205,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg }); + cfg.AddTest("ShouldShareSelfSignedCertificateQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg }); } else { // Only a --no-quic build can show that msquic stayed out. diff --git a/tests/ShouldShareSelfSignedCertificateQUIC/main.cpp b/tests/ShouldShareSelfSignedCertificateQUIC/main.cpp new file mode 100644 index 0000000..97e057f --- /dev/null +++ b/tests/ShouldShareSelfSignedCertificateQUIC/main.cpp @@ -0,0 +1,254 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// QUICServerCredentials{selfSigned = true} from a cold cache, in several +// processes at once. The pair used to be made by the openssl CLI straight +// into the fixed /tmp/crafter-network-quic-cert/{key,cert}.pem, guarded by an +// in-process mutex only: test binaries started together each found no pair, +// each ran openssl over the same two files, and one could load another run's +// key with its own certificate, failing in ConfigurationLoadCredential. The +// directory was shared by every user of the machine, and without the openssl +// CLI there was no certificate at all. +// +// Six processes, forked before any thread or msquic exists, are released at +// once; each starts two threads that create a selfSigned listener together, +// against an empty cache (XDG_CACHE_HOME pointed at a fresh directory) and +// with no openssl on PATH, and connects to both. Every listener must serve, +// and every process must report the same certificate: one made the pair and +// the rest read it. Then: +// - the directory is the user's alone (0700) and so is the key (0600); +// - a process started later serves the same certificate, so a browser's +// pinned hash survives a restart; +// - a mismatched pair left on disk, what the race used to leave behind, is +// replaced by the next process rather than loaded. +// +// The parent never starts msquic or a thread, so forking stays safe: every +// listener lives in a child, which answers with one line through a pipe. + +#include +#include +#include +#include +#include +#include + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +const std::string alpn = "crafter/test-certificate"; + +std::string Hex(const std::array& bytes) { + std::string text; + for (std::uint8_t byte : bytes) text += std::format("{:02x}", byte); + return text; +} + +// One child: wait for the byte on `go`, start `listeners` selfSigned +// listeners together on threads of their own, reach each with a client, and +// write "OK " or "FAIL " to +// `report`. +[[noreturn]] void Child(int go, int report, int listeners) { + // A child that hangs dies and so answers nothing, which the parent + // counts as a failure, inside the runner's 60 s. + alarm(40); + char byte = 0; + while (read(go, &byte, 1) < 0 && errno == EINTR) {} + close(go); + + std::string line; + try { + ThreadPool::Start(); + std::latch together(listeners); + std::vector problems(static_cast(listeners)); + std::vector threads; + for (int i = 0; i < listeners; ++i) { + threads.emplace_back([&, i] { + try { + together.arrive_and_wait(); + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [](ClientQUIC* peer) { + peer->OnStream([](QUICStream stream) { + try { + std::vector got = stream.RecieveUntilCloseSync(); + stream.SendSync(got.data(), static_cast(got.size()), true); + } catch (...) {} + }); + }); + listener.ListenAsyncAsync(); + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds); + QUICStream stream = client.OpenStream(); + stream.SendSync("hi", 2, true); + std::vector echoed = stream.RecieveUntilCloseSync(); + if (std::string(echoed.begin(), echoed.end()) != "hi") problems[static_cast(i)] = "no echo"; + } catch (const std::exception& e) { + problems[static_cast(i)] = e.what(); + } + }); + } + for (std::thread& thread : threads) thread.join(); + std::string problem; + for (const std::string& p : problems) if (!p.empty()) problem = p; + if (problem.empty()) { + const std::string path = GetSelfSignedCertificatePath(); + line = std::format("OK {} {}", Hex(ComputeCertificateHashSHA256(path)), path); + } else { + line = "FAIL " + problem; + } + } catch (const std::exception& e) { + line = std::string("FAIL ") + e.what(); + } + line += '\n'; + for (std::size_t written = 0; written < line.size();) { + const ssize_t put = write(report, line.data() + written, line.size() - written); + if (put < 0) { + if (errno == EINTR) continue; + break; + } + written += static_cast(put); + } + // The accepted peers are still open: skip msquic's static teardown, + // which would wait for their idle timeout. + std::_Exit(0); +} + +// Forks `count` children, releases them together, and returns their lines, +// "FAIL no answer" for one that died first. +std::vector RunChildren(int count, int listeners) { + int go[2]; + if (pipe(go) != 0) return std::vector(static_cast(count), "FAIL pipe"); + std::vector> children; + for (int i = 0; i < count; ++i) { + int report[2]; + if (pipe(report) != 0) break; + const pid_t pid = fork(); + if (pid == 0) { + close(go[1]); + close(report[0]); + for (const auto& [sibling, siblingReport] : children) close(siblingReport); + Child(go[0], report[1], listeners); + } + close(report[1]); + children.emplace_back(pid, report[0]); + } + close(go[0]); + const std::string release(children.size(), 'x'); + (void)!write(go[1], release.data(), release.size()); + close(go[1]); + + std::vector lines; + for (const auto& [pid, report] : children) { + std::string line; + char buffer[512]; + for (;;) { + const ssize_t got = read(report, buffer, sizeof(buffer)); + if (got == 0) break; + if (got < 0) { + if (errno == EINTR) continue; + break; + } + line.append(buffer, static_cast(got)); + } + close(report); + int status = 0; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + if (!line.empty() && line.back() == '\n') line.pop_back(); + lines.push_back(line.empty() ? "FAIL no answer" : line); + } + return lines; +} + +bool Ok(const std::string& line) { return line.starts_with("OK "); } + +// The hash in an "OK " line. +std::string HashOf(const std::string& line) { return Ok(line) ? line.substr(3, 64) : std::string(); } + +// The path in an "OK " line. +std::string PathOf(const std::string& line) { return Ok(line) && line.size() > 68 ? line.substr(68) : std::string(); } + +} // namespace + +int main() { + // The watchdog, without a thread: SIGALRM's default action ends the + // process, which the runner counts as a failure. + alarm(55); + + char scratch[] = "/tmp/crafter-network-certificate-XXXXXX"; + if (mkdtemp(scratch) == nullptr) { + std::println("FAIL mkdtemp: {}", std::strerror(errno)); + return 1; + } + const std::filesystem::path cache = scratch; + const std::filesystem::path directory = cache / "crafter-network" / "quic-cert"; + setenv("XDG_CACHE_HOME", scratch, 1); + // No openssl to fall back on, wherever it is installed. + setenv("PATH", (cache / "no-programs-here").c_str(), 1); + + // ── Six processes, two listeners each, from cold ───────────────────────── + const std::vector cold = RunChildren(6, 2); + for (const std::string& line : cold) std::println(" {}", line); + Check(std::ranges::all_of(cold, Ok), + "twelve listeners in six processes started together from an empty cache, with no openssl CLI, were all served"); + std::set hashes; + for (const std::string& line : cold) hashes.insert(HashOf(line)); + Check(hashes.size() == 1 && !hashes.begin()->empty(), + "and all six processes serve one certificate: one made it, the rest read it"); + const std::string first = HashOf(cold.front()); + Check(PathOf(cold.front()) == (directory / "cert.pem").string(), + std::format("it lives under XDG_CACHE_HOME ({})", PathOf(cold.front()))); + + // ── The directory is the user's alone ──────────────────────────────────── + struct stat info {}; + Check(stat(directory.c_str(), &info) == 0 && (info.st_mode & 0777) == 0700 && info.st_uid == geteuid(), + "the certificate directory is the user's, mode 0700"); + Check(stat((directory / "key.pem").c_str(), &info) == 0 && (info.st_mode & 077) == 0, + "the key is readable by the user alone"); + bool leftovers = false; + std::error_code error; + for (const auto& entry : std::filesystem::directory_iterator(directory, error)) { + leftovers = leftovers || entry.path().extension() == ".new"; + } + Check(!leftovers, "no temporary file is left behind"); + + // ── A restart keeps the certificate ────────────────────────────────────── + const std::vector restart = RunChildren(1, 1); + std::println(" {}", restart.front()); + Check(Ok(restart.front()) && HashOf(restart.front()) == first, + "a process started later serves the same certificate, so a pinned hash survives a restart"); + + // ── A mismatched pair is replaced, not loaded ──────────────────────────── + // Another valid P-256 key, of the TLS partition's certificate: what a + // torn replacement leaves on disk. + { + std::ofstream key(directory / "key.pem", std::ios::binary | std::ios::trunc); + key << GetSelfSignedCertificatePem().privateKey; + } + const std::vector mismatched = RunChildren(1, 1); + std::println(" {}", mismatched.front()); + Check(Ok(mismatched.front()) && HashOf(mismatched.front()) != first, + "a mismatched pair left on disk is replaced by a new one, which serves"); + try { + Check(Hex(ComputeCertificateHashSHA256((directory / "cert.pem").string())) == HashOf(mismatched.front()), + "and the new certificate is the one on disk"); + } catch (const std::exception& e) { + Check(false, std::format("hashing the certificate on disk threw: {}", e.what())); + } + + std::filesystem::remove_all(cache, error); + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + std::fflush(stdout); + return failures == 0 ? 0 : 1; +}