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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:56:44 +01:00
commit 6b8b485e38
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 255 additions and 0 deletions

View file

@ -205,6 +205,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg });
cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg });
cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg });
cfg.AddTest("ShouldShareSelfSignedCertificateQUIC").Dependencies({ &cfg });
cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg }); cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg });
} else { } else {
// Only a --no-quic build can show that msquic stayed out. // Only a --no-quic build can show that msquic stayed out.

View file

@ -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 <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
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<std::uint8_t, 32>& 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 <certificate hash> <certificate path>" or "FAIL <why>" 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<std::string> problems(static_cast<std::size_t>(listeners));
std::vector<std::thread> 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<char> got = stream.RecieveUntilCloseSync();
stream.SendSync(got.data(), static_cast<std::uint32_t>(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<char> echoed = stream.RecieveUntilCloseSync();
if (std::string(echoed.begin(), echoed.end()) != "hi") problems[static_cast<std::size_t>(i)] = "no echo";
} catch (const std::exception& e) {
problems[static_cast<std::size_t>(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<std::size_t>(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<std::string> RunChildren(int count, int listeners) {
int go[2];
if (pipe(go) != 0) return std::vector<std::string>(static_cast<std::size_t>(count), "FAIL pipe");
std::vector<std::pair<pid_t, int>> 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<std::string> 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<std::size_t>(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 <hash> <path>" line.
std::string HashOf(const std::string& line) { return Ok(line) ? line.substr(3, 64) : std::string(); }
// The path in an "OK <hash> <path>" 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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;
}