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:
parent
5068f0acd7
commit
61d3e8c26a
5 changed files with 404 additions and 141 deletions
|
|
@ -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()), ¬After) == 1) {
|
||||
expiry = std::chrono::system_clock::from_time_t(timegm(¬After));
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue