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>
254 lines
11 KiB
C++
254 lines
11 KiB
C++
//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;
|
|
}
|