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

View file

@ -3,15 +3,18 @@
module;
#include <msquic.h>
#include <cerrno>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
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<SelfSignedCert> 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<std::filesystem::path> {
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<std::string> 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<std::size_t>(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<std::size_t>(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<std::string> certificate = ReadAt(directory.fd, "cert.pem");
std::optional<std::string> 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<std::chrono::system_clock::time_point> 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<SelfSignedCert> 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<std::uint8_t>& 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<std::uint8_t> 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<std::uint32_t>(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<unsigned>(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<unsigned>(s),
creds.selfSigned, creds.certPath));
creds.selfSigned, certDescription));
}
return cfg;
}
@ -472,49 +634,16 @@ void ListenerQUIC::ListenAsyncAsync() {
}
std::array<std::uint8_t, 32> 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)= <hex>\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<char>(file)), std::istreambuf_iterator<char>());
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<std::uint8_t, 32> 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() {

View file

@ -8,11 +8,14 @@ module;
#include <pthread.h>
#include <cerrno>
#include <climits>
#include <time.h>
#include <openssl/bio.h>
#include <openssl/bn.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/pkcs12.h>
#include <openssl/ssl.h>
#include <openssl/sslerr.h>
#include <openssl/x509v3.h>
@ -78,6 +81,8 @@ namespace {
using OwnedBio = Owned<BIO, BIO_free_all>;
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
using OwnedCertificate = Owned<X509, X509_free>;
using OwnedBignum = Owned<BIGNUM, BN_free>;
using OwnedPkcs12 = Owned<PKCS12, PKCS12_free>;
// 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<TLSCertificatePem> 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<std::chrono::system_clock::time_point>
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<int>(pem.certificate.size())));
OwnedBio keyBio(BIO_new_mem_buf(pem.privateKey.data(), static_cast<int>(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<std::chrono::system_clock::time_point> 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()), &notAfter) == 1) {
expiry = std::chrono::system_clock::from_time_t(timegm(&notAfter));
}
// What a bad pair left on the queue is not anyone else's error.
ERR_clear_error();
return expiry;
}
std::vector<std::uint8_t> 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<std::uint8_t> encoded(der, der + length);
OPENSSL_free(der);
return encoded;
}
std::array<std::uint8_t, 32> 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<std::uint8_t, 32> 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;
}