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:
parent
61d3e8c26a
commit
6b8b485e38
2 changed files with 255 additions and 0 deletions
254
tests/ShouldShareSelfSignedCertificateQUIC/main.cpp
Normal file
254
tests/ShouldShareSelfSignedCertificateQUIC/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue