diff --git a/implementations/Crafter.Network-ListenerQUIC.cpp b/implementations/Crafter.Network-ListenerQUIC.cpp index a8be0e8..d1ef71e 100644 --- a/implementations/Crafter.Network-ListenerQUIC.cpp +++ b/implementations/Crafter.Network-ListenerQUIC.cpp @@ -3,15 +3,18 @@ module; #include +#include #include -#include #include +#include #include #include +#include #include 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 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 { + 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 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(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(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 certificate = ReadAt(directory.fd, "cert.pem"); + std::optional 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 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 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& 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 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(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(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(s), - creds.selfSigned, creds.certPath)); + creds.selfSigned, certDescription)); } return cfg; } @@ -472,49 +634,16 @@ void ListenerQUIC::ListenAsyncAsync() { } std::array 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)= \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(file)), std::istreambuf_iterator()); + 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 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() { diff --git a/implementations/Crafter.Network-TLS.cpp b/implementations/Crafter.Network-TLS.cpp index 4977021..2c97244 100644 --- a/implementations/Crafter.Network-TLS.cpp +++ b/implementations/Crafter.Network-TLS.cpp @@ -8,11 +8,14 @@ module; #include #include #include +#include #include +#include #include #include #include +#include #include #include #include @@ -78,6 +81,8 @@ namespace { using OwnedBio = Owned; using OwnedKey = Owned; using OwnedCertificate = Owned; + using OwnedBignum = Owned; + using OwnedPkcs12 = Owned; // 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 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 +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(pem.certificate.size()))); + OwnedBio keyBio(BIO_new_mem_buf(pem.privateKey.data(), static_cast(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 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 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 encoded(der, der + length); + OPENSSL_free(der); + return encoded; +} + +std::array 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 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; +} diff --git a/interfaces/Crafter.Network-ClientQUIC.cppm b/interfaces/Crafter.Network-ClientQUIC.cppm index 4333bd8..015e6fb 100644 --- a/interfaces/Crafter.Network-ClientQUIC.cppm +++ b/interfaces/Crafter.Network-ClientQUIC.cppm @@ -51,7 +51,9 @@ namespace Crafter { }; // Server certificate sources. Pick one of (filePaths) or (selfSigned). - // selfSigned generates an in-memory ephemeral cert — fine for dev/LAN. + // selfSigned serves a development certificate made in-process and kept + // per user under $XDG_CACHE_HOME/crafter-network/quic-cert/, reused + // while valid (see GetSelfSignedCertificatePath) — fine for dev/LAN. export struct QUICServerCredentials { std::string certPath; std::string keyPath; diff --git a/interfaces/Crafter.Network-ListenerQUIC.cppm b/interfaces/Crafter.Network-ListenerQUIC.cppm index f059d51..cf6a5c2 100644 --- a/interfaces/Crafter.Network-ListenerQUIC.cppm +++ b/interfaces/Crafter.Network-ListenerQUIC.cppm @@ -86,12 +86,23 @@ namespace Crafter { // Returns the 32-byte digest. Intended for surfacing the self-signed cert // hash to a browser peer (Chrome's WebTransport requires the client to // pass this hash via `serverCertificateHashes` when peering against a - // cert that's not in the system trust store). Shells out to openssl. + // cert that's not in the system trust store). In-process, through + // OpenSSL; throws QUICException for a file it cannot read or parse. export std::array ComputeCertificateHashSHA256(const std::string& certPath); - // Path of the lazily-generated self-signed cert (PEM). Triggers generation - // on first call. Useful for piping into ComputeCertificateHashSHA256 so - // a browser peer can be told the hash to put in `serverCertificateHashes`. + // Path of the self-signed cert (PEM) QUICServerCredentials{selfSigned} + // serves; makes it on first use. Useful for piping into + // ComputeCertificateHashSHA256 so a browser peer can be told the hash to + // put in `serverCertificateHashes`. + // + // One pair per user, in $XDG_CACHE_HOME/crafter-network/quic-cert/ + // (else ~/.cache/..., else $XDG_RUNTIME_DIR/...), a 0700 directory, made + // in-process (no openssl CLI). It is reused across restarts, and so its + // hash stays pinned, until it has less than a day of its ten left; then + // the next process to need it makes a new one. Processes starting at + // once share one pair: a lock covers the check and the replacement. + // Throws QUICException when no such directory can be had (none of those + // variables set, or the directory belongs to someone else). export std::string GetSelfSignedCertificatePath(); } #endif diff --git a/interfaces/Crafter.Network-TLS.cppm b/interfaces/Crafter.Network-TLS.cppm index 219e05b..a884ac3 100644 --- a/interfaces/Crafter.Network-TLS.cppm +++ b/interfaces/Crafter.Network-TLS.cppm @@ -175,5 +175,37 @@ namespace Crafter { // valid for 10 days. Development and tests only — it is regenerated on // every process start and no peer has any reason to trust it. export const TLSCertificatePem& GetSelfSignedCertificatePem(); + + // ── Module-internal, not exported ──────────────────────────────────── + // The certificate work :ListenerQUIC's self-signed credential needs. + // It lives here so that libssl stays behind this one partition: the + // listener keeps the pair on disk with plain POSIX calls and leaves + // every OpenSSL call to these. + + // A fresh pair shaped for Chromium's WebTransport + // serverCertificateHashes, which pins a hash instead of trusting a CA + // and in exchange demands: ECDSA P-256 signed with SHA-256, a validity + // of under 14 days (10, backdated an hour), BasicConstraints CA:FALSE, + // KeyUsage digitalSignature, EKU serverAuth, and SANs covering the name + // dialled (localhost, 127.0.0.1, ::1). A random serial, since the pair + // is replaced every ten days under the same issuer name. Throws + // TLSException. + TLSCertificatePem MakeWebTransportCertificatePem(); + + // When the certificate in `pem` stops being valid, provided it parses, + // has begun being valid, and `pem.privateKey` is its key; nothing for + // anything else. Never throws: a bad pair on disk is one to replace. + std::optional + CertificatePairExpiry(const TLSCertificatePem& pem); + + // The pair as a DER-encoded PKCS#12 under `password`: the in-memory + // credential msquic takes as QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12. + // Throws TLSException. + std::vector EncodePkcs12(const TLSCertificatePem& pem, + const std::string& password); + + // SHA-256 of the DER encoding of the first certificate in `pem`, the + // value serverCertificateHashes pins. Throws TLSException. + std::array CertificateSha256(const std::string& pem); } #endif